feat: Phase5B 对话历史持久化
新增 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>
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ public class AppConfig {
|
|||||||
new ContextCommand(),
|
new ContextCommand(),
|
||||||
new InitCommand(),
|
new InitCommand(),
|
||||||
new ConfigCommand(),
|
new ConfigCommand(),
|
||||||
|
new HistoryCommand(),
|
||||||
new ExitCommand()
|
new ExitCommand()
|
||||||
);
|
);
|
||||||
return registry;
|
return registry;
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.claudecode.command.CommandContext;
|
|||||||
import com.claudecode.command.CommandRegistry;
|
import com.claudecode.command.CommandRegistry;
|
||||||
import com.claudecode.console.*;
|
import com.claudecode.console.*;
|
||||||
import com.claudecode.core.AgentLoop;
|
import com.claudecode.core.AgentLoop;
|
||||||
|
import com.claudecode.core.ConversationPersistence;
|
||||||
import com.claudecode.tool.ToolRegistry;
|
import com.claudecode.tool.ToolRegistry;
|
||||||
import org.jline.reader.*;
|
import org.jline.reader.*;
|
||||||
import org.jline.reader.impl.DefaultParser;
|
import org.jline.reader.impl.DefaultParser;
|
||||||
@@ -42,11 +43,14 @@ public class ReplSession {
|
|||||||
private final ToolRegistry toolRegistry;
|
private final ToolRegistry toolRegistry;
|
||||||
private final CommandRegistry commandRegistry;
|
private final CommandRegistry commandRegistry;
|
||||||
private final ProviderInfo providerInfo;
|
private final ProviderInfo providerInfo;
|
||||||
|
private final ConversationPersistence persistence;
|
||||||
private final PrintStream out;
|
private final PrintStream out;
|
||||||
private final ToolStatusRenderer toolStatusRenderer;
|
private final ToolStatusRenderer toolStatusRenderer;
|
||||||
private final MarkdownRenderer markdownRenderer;
|
private final MarkdownRenderer markdownRenderer;
|
||||||
private final SpinnerAnimation spinner;
|
private final SpinnerAnimation spinner;
|
||||||
|
|
||||||
|
/** 对话摘要(取第一次用户输入的前40字) */
|
||||||
|
private String conversationSummary = "";
|
||||||
private volatile boolean running = true;
|
private volatile boolean running = true;
|
||||||
|
|
||||||
public ReplSession(AgentLoop agentLoop,
|
public ReplSession(AgentLoop agentLoop,
|
||||||
@@ -57,6 +61,7 @@ public class ReplSession {
|
|||||||
this.toolRegistry = toolRegistry;
|
this.toolRegistry = toolRegistry;
|
||||||
this.commandRegistry = commandRegistry;
|
this.commandRegistry = commandRegistry;
|
||||||
this.providerInfo = providerInfo;
|
this.providerInfo = providerInfo;
|
||||||
|
this.persistence = new ConversationPersistence();
|
||||||
// 强制使用 UTF-8 编码输出,确保 emoji 等 Unicode 字符在 Windows 终端正常显示
|
// 强制使用 UTF-8 编码输出,确保 emoji 等 Unicode 字符在 Windows 终端正常显示
|
||||||
this.out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
|
this.out = new PrintStream(System.out, true, StandardCharsets.UTF_8);
|
||||||
this.toolStatusRenderer = new ToolStatusRenderer(out);
|
this.toolStatusRenderer = new ToolStatusRenderer(out);
|
||||||
@@ -163,6 +168,7 @@ public class ReplSession {
|
|||||||
handleInput(input, cmdContext);
|
handleInput(input, cmdContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveConversation();
|
||||||
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,11 +235,10 @@ public class ReplSession {
|
|||||||
handleInput(input, cmdContext);
|
handleInput(input, cmdContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveConversation();
|
||||||
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
out.println(AnsiStyle.dim("\n Goodbye! 👋\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 公共输入处理 ====================
|
|
||||||
|
|
||||||
/** 处理用户输入(命令分发或 Agent 调用) */
|
/** 处理用户输入(命令分发或 Agent 调用) */
|
||||||
private void handleInput(String input, CommandContext cmdContext) {
|
private void handleInput(String input, CommandContext cmdContext) {
|
||||||
// 斜杠命令
|
// 斜杠命令
|
||||||
@@ -244,6 +249,11 @@ public class ReplSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录对话摘要(取第一次用户输入前40字)
|
||||||
|
if (conversationSummary.isEmpty()) {
|
||||||
|
conversationSummary = input.length() > 40 ? input.substring(0, 40) : input;
|
||||||
|
}
|
||||||
|
|
||||||
// Agent 循环(流式输出)
|
// Agent 循环(流式输出)
|
||||||
try {
|
try {
|
||||||
spinner.start("Thinking...");
|
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() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user