From 399cea4478f0e0a5a69586f125407f94e27cb749 Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 21:35:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase5B=20=E5=AF=B9=E8=AF=9D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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> --- .../command/impl/HistoryCommand.java | 56 ++++ .../java/com/claudecode/config/AppConfig.java | 1 + .../core/ConversationPersistence.java | 246 ++++++++++++++++++ .../java/com/claudecode/repl/ReplSession.java | 31 ++- 4 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/claudecode/command/impl/HistoryCommand.java create mode 100644 src/main/java/com/claudecode/core/ConversationPersistence.java diff --git a/src/main/java/com/claudecode/command/impl/HistoryCommand.java b/src/main/java/com/claudecode/command/impl/HistoryCommand.java new file mode 100644 index 0000000..23b51d8 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/HistoryCommand.java @@ -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 命令 —— 列出保存的对话历史。 + *

+ * 显示最近的对话记录,包括时间、摘要和消息数量。 + */ +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(); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 8d2b396..a072bb0 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -74,6 +74,7 @@ public class AppConfig { new ContextCommand(), new InitCommand(), new ConfigCommand(), + new HistoryCommand(), new ExitCommand() ); return registry; diff --git a/src/main/java/com/claudecode/core/ConversationPersistence.java b/src/main/java/com/claudecode/core/ConversationPersistence.java new file mode 100644 index 0000000..6ce7fee --- /dev/null +++ b/src/main/java/com/claudecode/core/ConversationPersistence.java @@ -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 的会话持久化机制。 + *

+ * 将对话消息序列化为 JSON 存储到 ~/.claude-code-java/conversations/ 目录。 + * 支持保存、加载和列出历史对话。 + *

+ * 存储格式:每条消息序列化为简化的 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 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 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 loadLatest() { + Path latest = findLatestFile(); + if (latest == null) return List.of(); + return loadFromFile(latest); + } + + /** + * 从指定文件加载对话历史。 + */ + public List 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 listConversations() { + List summaries = new ArrayList<>(); + + try (Stream 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 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 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 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 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 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 messages + ) {} + + public record MessageRecord( + String role, + String content, + List toolCalls, + List 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 + ) {} +} diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java index e3d9be0..9354e9c 100644 --- a/src/main/java/com/claudecode/repl/ReplSession.java +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -5,6 +5,7 @@ import com.claudecode.command.CommandContext; import com.claudecode.command.CommandRegistry; import com.claudecode.console.*; import com.claudecode.core.AgentLoop; +import com.claudecode.core.ConversationPersistence; import com.claudecode.tool.ToolRegistry; import org.jline.reader.*; import org.jline.reader.impl.DefaultParser; @@ -42,11 +43,14 @@ public class ReplSession { private final ToolRegistry toolRegistry; private final CommandRegistry commandRegistry; private final ProviderInfo providerInfo; + private final ConversationPersistence persistence; private final PrintStream out; private final ToolStatusRenderer toolStatusRenderer; private final MarkdownRenderer markdownRenderer; private final SpinnerAnimation spinner; + /** 对话摘要(取第一次用户输入的前40字) */ + private String conversationSummary = ""; private volatile boolean running = true; public ReplSession(AgentLoop agentLoop, @@ -57,6 +61,7 @@ public class ReplSession { this.toolRegistry = toolRegistry; this.commandRegistry = commandRegistry; this.providerInfo = providerInfo; + this.persistence = new ConversationPersistence(); // 强制使用 UTF-8 编码输出,确保 emoji 等 Unicode 字符在 Windows 终端正常显示 this.out = new PrintStream(System.out, true, StandardCharsets.UTF_8); this.toolStatusRenderer = new ToolStatusRenderer(out); @@ -163,6 +168,7 @@ public class ReplSession { handleInput(input, cmdContext); } + saveConversation(); out.println(AnsiStyle.dim("\n Goodbye! 👋\n")); } } @@ -229,11 +235,10 @@ public class ReplSession { handleInput(input, cmdContext); } + saveConversation(); out.println(AnsiStyle.dim("\n Goodbye! 👋\n")); } - // ==================== 公共输入处理 ==================== - /** 处理用户输入(命令分发或 Agent 调用) */ private void handleInput(String input, CommandContext cmdContext) { // 斜杠命令 @@ -244,6 +249,11 @@ public class ReplSession { return; } + // 记录对话摘要(取第一次用户输入前40字) + if (conversationSummary.isEmpty()) { + conversationSummary = input.length() > 40 ? input.substring(0, 40) : input; + } + // Agent 循环(流式输出) try { spinner.start("Thinking..."); @@ -266,6 +276,23 @@ public class ReplSession { } } + /** 退出时保存对话历史 */ + private void saveConversation() { + var history = agentLoop.getMessageHistory(); + // 只有有实际对话内容时才保存(至少包含系统提示+用户消息+助手回复) + if (history.size() > 2) { + var file = persistence.save(history, conversationSummary); + if (file != null) { + out.println(AnsiStyle.dim(" 💾 对话已保存: " + file.getFileName())); + } + } + } + + /** 获取对话持久化管理器 */ + public ConversationPersistence getPersistence() { + return persistence; + } + public void stop() { running = false; }