新增 ConversationPersistence: - 退出REPL时自动保存对话到 ~/.claude-code-java/conversations/ - JSON格式存储:系统消息、用户消息、助手消息(含工具调用)、工具响应 - 支持保存、加载最近对话、列出所有对话 - 文件名格式: yyyyMMdd_HHmmss_摘要.json 新增 /history 命令: - 列出最近10条保存的对话记录 - 显示时间、摘要、消息数、工作目录 ReplSession 更新: - 自动记录对话摘要(第一次用户输入前40字) - JLine模式和Scanner模式退出时都保存对话 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
fd262bf98d
commit
399cea4478
@ -0,0 +1,56 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
import com.claudecode.core.ConversationPersistence; |
||||
|
||||
/** |
||||
* /history 命令 —— 列出保存的对话历史。 |
||||
* <p> |
||||
* 显示最近的对话记录,包括时间、摘要和消息数量。 |
||||
*/ |
||||
public class HistoryCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "/history"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "列出保存的对话历史"; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
ConversationPersistence persistence = new ConversationPersistence(); |
||||
var conversations = persistence.listConversations(); |
||||
|
||||
if (conversations.isEmpty()) { |
||||
return AnsiStyle.dim(" 📂 暂无保存的对话历史"); |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append(AnsiStyle.bold(" 📂 对话历史") + AnsiStyle.dim(" (") |
||||
+ conversations.size() + AnsiStyle.dim(" 条记录)\n")); |
||||
sb.append(AnsiStyle.dim(" " + "─".repeat(50)) + "\n"); |
||||
|
||||
int shown = Math.min(conversations.size(), 10); |
||||
for (int i = 0; i < shown; i++) { |
||||
var conv = conversations.get(i); |
||||
sb.append(" " + AnsiStyle.cyan((i + 1) + ".") + " "); |
||||
sb.append(AnsiStyle.bold(conv.summary())); |
||||
sb.append(AnsiStyle.dim(" (" + conv.messageCount() + " messages)") + "\n"); |
||||
sb.append(" " + AnsiStyle.dim(conv.savedAt() + " | " + conv.workingDir()) + "\n"); |
||||
} |
||||
|
||||
if (conversations.size() > 10) { |
||||
sb.append(AnsiStyle.dim(" ... 还有 " + (conversations.size() - 10) + " 条更早的记录\n")); |
||||
} |
||||
|
||||
sb.append(AnsiStyle.dim("\n 对话存储位置: " + persistence.getConversationsDir())); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
} |
||||
@ -0,0 +1,246 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo; |
||||
import com.fasterxml.jackson.core.type.TypeReference; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
import com.fasterxml.jackson.databind.SerializationFeature; |
||||
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.chat.messages.*; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.time.LocalDateTime; |
||||
import java.time.format.DateTimeFormatter; |
||||
import java.util.*; |
||||
import java.util.stream.Stream; |
||||
|
||||
/** |
||||
* 对话历史持久化 —— 对应 claude-code 的会话持久化机制。 |
||||
* <p> |
||||
* 将对话消息序列化为 JSON 存储到 ~/.claude-code-java/conversations/ 目录。 |
||||
* 支持保存、加载和列出历史对话。 |
||||
* <p> |
||||
* 存储格式:每条消息序列化为简化的 JSON 对象,包含角色、内容和工具调用信息。 |
||||
*/ |
||||
public class ConversationPersistence { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConversationPersistence.class); |
||||
private static final ObjectMapper MAPPER = new ObjectMapper() |
||||
.enable(SerializationFeature.INDENT_OUTPUT); |
||||
private static final DateTimeFormatter FILE_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); |
||||
|
||||
private final Path conversationsDir; |
||||
|
||||
public ConversationPersistence() { |
||||
this(Path.of(System.getProperty("user.home"), ".claude-code-java", "conversations")); |
||||
} |
||||
|
||||
public ConversationPersistence(Path conversationsDir) { |
||||
this.conversationsDir = conversationsDir; |
||||
try { |
||||
Files.createDirectories(conversationsDir); |
||||
} catch (IOException e) { |
||||
log.warn("无法创建对话存储目录: {}", e.getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 保存对话历史到文件。 |
||||
* |
||||
* @param messages 消息列表 |
||||
* @param summary 对话摘要(用于文件名/列表显示) |
||||
* @return 保存的文件路径,失败返回 null |
||||
*/ |
||||
public Path save(List<Message> messages, String summary) { |
||||
if (messages == null || messages.isEmpty()) return null; |
||||
|
||||
String timestamp = FILE_DATE_FMT.format(LocalDateTime.now()); |
||||
String safeSummary = sanitizeFilename(summary); |
||||
String filename = timestamp + "_" + safeSummary + ".json"; |
||||
Path file = conversationsDir.resolve(filename); |
||||
|
||||
try { |
||||
List<MessageRecord> records = messages.stream() |
||||
.map(this::toRecord) |
||||
.filter(Objects::nonNull) |
||||
.toList(); |
||||
|
||||
ConversationFile conv = new ConversationFile( |
||||
LocalDateTime.now().toString(), |
||||
summary, |
||||
System.getProperty("user.dir"), |
||||
records |
||||
); |
||||
|
||||
MAPPER.writeValue(file.toFile(), conv); |
||||
log.info("对话已保存: {}", file.getFileName()); |
||||
return file; |
||||
} catch (IOException e) { |
||||
log.error("保存对话失败: {}", e.getMessage()); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 加载最近一次对话历史。 |
||||
* |
||||
* @return 消息列表,无历史时返回空列表 |
||||
*/ |
||||
public List<Message> loadLatest() { |
||||
Path latest = findLatestFile(); |
||||
if (latest == null) return List.of(); |
||||
return loadFromFile(latest); |
||||
} |
||||
|
||||
/** |
||||
* 从指定文件加载对话历史。 |
||||
*/ |
||||
public List<Message> loadFromFile(Path file) { |
||||
try { |
||||
ConversationFile conv = MAPPER.readValue(file.toFile(), ConversationFile.class); |
||||
return conv.messages().stream() |
||||
.map(this::fromRecord) |
||||
.filter(Objects::nonNull) |
||||
.toList(); |
||||
} catch (IOException e) { |
||||
log.error("加载对话失败: {}", e.getMessage()); |
||||
return List.of(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 列出所有保存的对话(按时间倒序)。 |
||||
*/ |
||||
public List<ConversationSummary> listConversations() { |
||||
List<ConversationSummary> summaries = new ArrayList<>(); |
||||
|
||||
try (Stream<Path> paths = Files.list(conversationsDir)) { |
||||
paths.filter(p -> p.toString().endsWith(".json")) |
||||
.sorted(Comparator.reverseOrder()) |
||||
.forEach(file -> { |
||||
try { |
||||
ConversationFile conv = MAPPER.readValue(file.toFile(), ConversationFile.class); |
||||
summaries.add(new ConversationSummary( |
||||
file.getFileName().toString(), |
||||
conv.summary(), |
||||
conv.savedAt(), |
||||
conv.workingDir(), |
||||
conv.messages().size() |
||||
)); |
||||
} catch (IOException e) { |
||||
log.debug("跳过无效对话文件: {}", file.getFileName()); |
||||
} |
||||
}); |
||||
} catch (IOException e) { |
||||
log.warn("列出对话失败: {}", e.getMessage()); |
||||
} |
||||
|
||||
return summaries; |
||||
} |
||||
|
||||
/** 获取对话存储目录 */ |
||||
public Path getConversationsDir() { |
||||
return conversationsDir; |
||||
} |
||||
|
||||
// ==================== 序列化/反序列化 ====================
|
||||
|
||||
private MessageRecord toRecord(Message msg) { |
||||
return switch (msg) { |
||||
case SystemMessage sm -> new MessageRecord("system", sm.getText(), null, null); |
||||
case UserMessage um -> new MessageRecord("user", um.getText(), null, null); |
||||
case AssistantMessage am -> { |
||||
List<ToolCallRecord> toolCalls = null; |
||||
if (am.hasToolCalls()) { |
||||
toolCalls = am.getToolCalls().stream() |
||||
.map(tc -> new ToolCallRecord(tc.id(), tc.name(), tc.arguments())) |
||||
.toList(); |
||||
} |
||||
yield new MessageRecord("assistant", am.getText(), toolCalls, null); |
||||
} |
||||
case ToolResponseMessage trm -> { |
||||
List<ToolResponseRecord> responses = trm.getResponses().stream() |
||||
.map(tr -> new ToolResponseRecord(tr.id(), tr.name(), tr.responseData())) |
||||
.toList(); |
||||
yield new MessageRecord("tool_response", null, null, responses); |
||||
} |
||||
default -> null; |
||||
}; |
||||
} |
||||
|
||||
private Message fromRecord(MessageRecord record) { |
||||
return switch (record.role()) { |
||||
case "system" -> new SystemMessage(record.content() != null ? record.content() : ""); |
||||
case "user" -> new UserMessage(record.content() != null ? record.content() : ""); |
||||
case "assistant" -> { |
||||
if (record.toolCalls() != null && !record.toolCalls().isEmpty()) { |
||||
List<AssistantMessage.ToolCall> toolCalls = record.toolCalls().stream() |
||||
.map(tc -> new AssistantMessage.ToolCall(tc.id(), "function", tc.name(), tc.arguments())) |
||||
.toList(); |
||||
yield AssistantMessage.builder() |
||||
.content(record.content() != null ? record.content() : "") |
||||
.toolCalls(toolCalls) |
||||
.build(); |
||||
} |
||||
yield new AssistantMessage(record.content() != null ? record.content() : ""); |
||||
} |
||||
case "tool_response" -> { |
||||
if (record.toolResponses() != null) { |
||||
List<ToolResponseMessage.ToolResponse> responses = record.toolResponses().stream() |
||||
.map(tr -> new ToolResponseMessage.ToolResponse(tr.id(), tr.name(), tr.data())) |
||||
.toList(); |
||||
yield ToolResponseMessage.builder().responses(responses).build(); |
||||
} |
||||
yield null; |
||||
} |
||||
default -> null; |
||||
}; |
||||
} |
||||
|
||||
private Path findLatestFile() { |
||||
try (Stream<Path> paths = Files.list(conversationsDir)) { |
||||
return paths.filter(p -> p.toString().endsWith(".json")) |
||||
.max(Comparator.naturalOrder()) |
||||
.orElse(null); |
||||
} catch (IOException e) { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
private String sanitizeFilename(String name) { |
||||
if (name == null || name.isBlank()) return "conversation"; |
||||
return name.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_") |
||||
.substring(0, Math.min(name.length(), 40)); |
||||
} |
||||
|
||||
// ==================== JSON 数据结构 ====================
|
||||
|
||||
public record ConversationFile( |
||||
String savedAt, |
||||
String summary, |
||||
String workingDir, |
||||
List<MessageRecord> messages |
||||
) {} |
||||
|
||||
public record MessageRecord( |
||||
String role, |
||||
String content, |
||||
List<ToolCallRecord> toolCalls, |
||||
List<ToolResponseRecord> toolResponses |
||||
) {} |
||||
|
||||
public record ToolCallRecord(String id, String name, String arguments) {} |
||||
public record ToolResponseRecord(String id, String name, String data) {} |
||||
|
||||
/** 对话摘要(用于列表显示) */ |
||||
public record ConversationSummary( |
||||
String filename, |
||||
String summary, |
||||
String savedAt, |
||||
String workingDir, |
||||
int messageCount |
||||
) {} |
||||
} |
||||
Loading…
Reference in new issue