新增 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