feat: P0功能补全 - 5命令+2工具+权限确认+Thinking显示
新增5个命令: - /diff: 显示Git未提交变更(支持--staged/--stat) - /version: 显示版本和环境信息 - /skills: 列出所有可用技能 - /memory: 查看/编辑CLAUDE.md(支持add/edit/user子命令) - /copy: 复制最近AI回复到剪贴板 新增2个工具: - WebSearchTool: DuckDuckGo搜索(免费,无需API Key) - AskUserQuestionTool: AI向用户提问(通过ToolContext回调) 权限确认机制: - 非只读工具执行前提示用户确认(Y/n/always) - 支持always一次授权全部后续操作 - 在AgentLoop.executeToolCalls中拦截 Thinking内容显示: - AgentLoop新增onThinkingContent回调 - 从ChatResponse metadata提取thinking内容 - ThinkingRenderer渲染思考过程(缩进暗色格式) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
|
||||||
|
import java.awt.Toolkit;
|
||||||
|
import java.awt.datatransfer.StringSelection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /copy 命令 —— 将最近一次 AI 回复复制到系统剪贴板。
|
||||||
|
* <p>
|
||||||
|
* 从消息历史中提取最后一条 AssistantMessage 的文本内容,
|
||||||
|
* 使用 AWT 剪贴板 API 复制。
|
||||||
|
*/
|
||||||
|
public class CopyCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Copy last AI response to clipboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
// 从消息历史中查找最后一条助手消息
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
String lastResponse = null;
|
||||||
|
|
||||||
|
for (int i = history.size() - 1; i >= 0; i--) {
|
||||||
|
Message msg = history.get(i);
|
||||||
|
if (msg instanceof AssistantMessage assistant) {
|
||||||
|
String text = assistant.getText();
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
lastResponse = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResponse == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 暂无 AI 回复可复制");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 AWT 剪贴板
|
||||||
|
StringSelection selection = new StringSelection(lastResponse);
|
||||||
|
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
|
||||||
|
|
||||||
|
int charCount = lastResponse.length();
|
||||||
|
int lineCount = (int) lastResponse.lines().count();
|
||||||
|
return AnsiStyle.green(" ✓ 已复制到剪贴板")
|
||||||
|
+ AnsiStyle.dim(" (" + charCount + " 字符, " + lineCount + " 行)");
|
||||||
|
} catch (java.awt.HeadlessException e) {
|
||||||
|
// 无头环境(如 SSH)无法使用 AWT 剪贴板
|
||||||
|
return AnsiStyle.yellow(" ⚠ 当前环境不支持剪贴板(Headless 模式)\n")
|
||||||
|
+ AnsiStyle.dim(" 提示:在有图形界面的终端中运行可使用此功能");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ 复制失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /diff 命令 —— 显示 Git 未提交的变更。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 /diff 命令,展示工作区的变更内容:
|
||||||
|
* <ul>
|
||||||
|
* <li>无参数:显示所有未暂存变更</li>
|
||||||
|
* <li>--staged:显示已暂存变更</li>
|
||||||
|
* <li>--stat:仅显示文件统计(不含详细diff)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class DiffCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Show uncommitted git changes";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
if (!Files.isDirectory(projectDir.resolve(".git"))) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 当前目录不是 Git 仓库");
|
||||||
|
}
|
||||||
|
|
||||||
|
args = args == null ? "" : args.strip();
|
||||||
|
|
||||||
|
try {
|
||||||
|
String diffOutput;
|
||||||
|
String header;
|
||||||
|
|
||||||
|
if (args.contains("--staged")) {
|
||||||
|
diffOutput = runGit(projectDir, "diff", "--staged", "--color=always");
|
||||||
|
header = "Staged Changes";
|
||||||
|
} else if (args.contains("--stat")) {
|
||||||
|
diffOutput = runGit(projectDir, "diff", "--stat", "--color=always");
|
||||||
|
header = "Changes (stat)";
|
||||||
|
} else {
|
||||||
|
// 默认:显示所有变更(未暂存 + 已暂存的统计 + 未跟踪文件)
|
||||||
|
String unstaged = runGit(projectDir, "diff", "--color=always");
|
||||||
|
String staged = runGit(projectDir, "diff", "--staged", "--stat");
|
||||||
|
String untracked = runGit(projectDir, "ls-files", "--others", "--exclude-standard");
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 📋 Git Diff\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
|
||||||
|
if (!staged.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ▸ Staged:\n"));
|
||||||
|
staged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unstaged.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.yellow(" ▸ Unstaged changes:\n"));
|
||||||
|
// 限制行数避免输出过长
|
||||||
|
long lineCount = unstaged.lines().count();
|
||||||
|
if (lineCount > 100) {
|
||||||
|
unstaged.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行,截断显示前100行)\n"));
|
||||||
|
} else {
|
||||||
|
unstaged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!untracked.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.red(" ▸ Untracked files:\n"));
|
||||||
|
untracked.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staged.isBlank() && unstaged.isBlank() && untracked.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ 工作区干净,无变更\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --staged 或 --stat 模式
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 📋 " + header + "\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||||
|
|
||||||
|
if (diffOutput.isBlank()) {
|
||||||
|
sb.append(AnsiStyle.green(" ✓ 无变更\n"));
|
||||||
|
} else {
|
||||||
|
long lineCount = diffOutput.lines().count();
|
||||||
|
if (lineCount > 100) {
|
||||||
|
diffOutput.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" ... (共 " + lineCount + " 行)\n"));
|
||||||
|
} else {
|
||||||
|
diffOutput.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ Git diff 执行失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runGit(Path dir, String... args) throws Exception {
|
||||||
|
var command = new java.util.ArrayList<String>();
|
||||||
|
command.add("git");
|
||||||
|
command.add("--no-pager");
|
||||||
|
command.addAll(java.util.List.of(args));
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(dir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
output.append(line).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.waitFor(10, TimeUnit.SECONDS);
|
||||||
|
return output.toString().stripTrailing();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /memory 命令 —— 查看和编辑 CLAUDE.md 记忆文件。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 /memory 命令,支持:
|
||||||
|
* <ul>
|
||||||
|
* <li>/memory —— 显示当前 CLAUDE.md 内容</li>
|
||||||
|
* <li>/memory add [内容] —— 追加内容到项目级 CLAUDE.md</li>
|
||||||
|
* <li>/memory edit —— 用系统编辑器打开 CLAUDE.md</li>
|
||||||
|
* <li>/memory user —— 查看用户级 CLAUDE.md</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class MemoryCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "memory";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "View/edit CLAUDE.md memory files";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of("mem");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
args = args == null ? "" : args.strip();
|
||||||
|
|
||||||
|
if (args.startsWith("add ")) {
|
||||||
|
return handleAdd(args.substring(4).strip());
|
||||||
|
} else if (args.equals("edit")) {
|
||||||
|
return handleEdit();
|
||||||
|
} else if (args.equals("user")) {
|
||||||
|
return showUserMemory();
|
||||||
|
} else {
|
||||||
|
return showProjectMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示项目级 CLAUDE.md */
|
||||||
|
private String showProjectMemory() {
|
||||||
|
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||||
|
return showMemoryFile(projectClaudeMd, "项目级");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示用户级 CLAUDE.md */
|
||||||
|
private String showUserMemory() {
|
||||||
|
Path userClaudeMd = Path.of(System.getProperty("user.home"), ".claude", "CLAUDE.md");
|
||||||
|
return showMemoryFile(userClaudeMd, "用户级");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showMemoryFile(Path path, String level) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 📝 CLAUDE.md (" + level + ")\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Path: " + path)).append("\n\n");
|
||||||
|
|
||||||
|
if (Files.exists(path)) {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
|
if (content.isBlank()) {
|
||||||
|
sb.append(AnsiStyle.dim(" (文件为空)\n"));
|
||||||
|
} else {
|
||||||
|
content.lines().forEach(line -> sb.append(" ").append(line).append("\n"));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
sb.append(AnsiStyle.red(" ✗ 读取失败: " + e.getMessage() + "\n"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append(AnsiStyle.dim(" (文件不存在)\n\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 使用 /memory add <内容> 创建并添加内容\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 或使用 /init 命令初始化\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加内容到项目级 CLAUDE.md */
|
||||||
|
private String handleAdd(String content) {
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ 请提供要添加的内容:/memory add <内容>");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||||
|
try {
|
||||||
|
// 确保文件存在
|
||||||
|
if (!Files.exists(projectClaudeMd)) {
|
||||||
|
Files.writeString(projectClaudeMd,
|
||||||
|
"# CLAUDE.md\n\n" + content + "\n",
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
return AnsiStyle.green(" ✓ 已创建 CLAUDE.md 并添加内容");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加内容
|
||||||
|
String existing = Files.readString(projectClaudeMd, StandardCharsets.UTF_8);
|
||||||
|
String newContent = existing.endsWith("\n") ? existing + "\n" + content + "\n" : existing + "\n\n" + content + "\n";
|
||||||
|
Files.writeString(projectClaudeMd, newContent, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ 已追加内容到 CLAUDE.md");
|
||||||
|
} catch (IOException e) {
|
||||||
|
return AnsiStyle.red(" ✗ 写入失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用系统编辑器打开 CLAUDE.md */
|
||||||
|
private String handleEdit() {
|
||||||
|
Path projectClaudeMd = Path.of(System.getProperty("user.dir"), "CLAUDE.md");
|
||||||
|
try {
|
||||||
|
if (!Files.exists(projectClaudeMd)) {
|
||||||
|
Files.writeString(projectClaudeMd, "# CLAUDE.md\n\n", StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试用系统编辑器打开
|
||||||
|
String editor = System.getenv("EDITOR");
|
||||||
|
if (editor == null || editor.isBlank()) {
|
||||||
|
editor = System.getenv("VISUAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editor != null && !editor.isBlank()) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(editor, projectClaudeMd.toString());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
p.waitFor();
|
||||||
|
return AnsiStyle.green(" ✓ 编辑器已关闭");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: 尝试 notepad
|
||||||
|
if (System.getProperty("os.name").toLowerCase().contains("win")) {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("notepad", projectClaudeMd.toString());
|
||||||
|
pb.start(); // 不等待
|
||||||
|
return AnsiStyle.green(" ✓ 已用记事本打开 CLAUDE.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnsiStyle.yellow(" ⚠ 未找到编辑器。请设置 EDITOR 环境变量,或手动编辑:\n " + projectClaudeMd);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ 打开编辑器失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.context.SkillLoader;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /skills 命令 —— 列出所有可用的技能。
|
||||||
|
* <p>
|
||||||
|
* 扫描并显示从用户级、项目级和命令目录加载的技能文件。
|
||||||
|
*/
|
||||||
|
public class SkillsCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "skills";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "List available skills";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
SkillLoader loader = new SkillLoader(projectDir);
|
||||||
|
List<SkillLoader.Skill> skills = loader.loadAll();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🎯 Available Skills\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||||
|
|
||||||
|
if (skills.isEmpty()) {
|
||||||
|
sb.append(AnsiStyle.dim(" (无可用技能)\n\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 技能文件放置位置:\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 用户级: ~/.claude/skills/*.md\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 项目级: ./.claude/skills/*.md\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" 命令级: ./.claude/commands/*.md\n"));
|
||||||
|
} else {
|
||||||
|
for (SkillLoader.Skill skill : skills) {
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan("▸ ")).append(AnsiStyle.bold(skill.name()));
|
||||||
|
|
||||||
|
// 来源标签
|
||||||
|
String sourceLabel = switch (skill.source()) {
|
||||||
|
case "user" -> AnsiStyle.dim(" [用户级]");
|
||||||
|
case "project" -> AnsiStyle.dim(" [项目级]");
|
||||||
|
case "command" -> AnsiStyle.dim(" [命令]");
|
||||||
|
default -> AnsiStyle.dim(" [" + skill.source() + "]");
|
||||||
|
};
|
||||||
|
sb.append(sourceLabel).append("\n");
|
||||||
|
|
||||||
|
if (!skill.description().isEmpty()) {
|
||||||
|
sb.append(" ").append(skill.description()).append("\n");
|
||||||
|
}
|
||||||
|
if (!skill.whenToUse().isEmpty()) {
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("When: " + skill.whenToUse())).append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("File: " + skill.filePath())).append("\n");
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(AnsiStyle.dim(" 共 " + skills.size() + " 个技能\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.SlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /version 命令 —— 显示版本和环境信息。
|
||||||
|
* <p>
|
||||||
|
* 展示 Claude Code Java 版本、运行时环境等信息。
|
||||||
|
*/
|
||||||
|
public class VersionCommand implements SlashCommand {
|
||||||
|
|
||||||
|
/** 当前版本号 */
|
||||||
|
public static final String VERSION = "1.0.0";
|
||||||
|
public static final String BUILD_DATE = "2025-07";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "version";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Show version information";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🏷️ Claude Code Java\n"));
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n");
|
||||||
|
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Version: "))
|
||||||
|
.append(AnsiStyle.cyan("v" + VERSION)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Build: "))
|
||||||
|
.append(BUILD_DATE).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Java: "))
|
||||||
|
.append(System.getProperty("java.version")).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("JVM: "))
|
||||||
|
.append(System.getProperty("java.vm.name")).append(" ")
|
||||||
|
.append(System.getProperty("java.vm.version")).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("OS: "))
|
||||||
|
.append(System.getProperty("os.name")).append(" ")
|
||||||
|
.append(System.getProperty("os.arch")).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Spring Boot: "))
|
||||||
|
.append(getSpringBootVersion()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Spring AI: "))
|
||||||
|
.append("2.0.0-M4").append("\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getSpringBootVersion() {
|
||||||
|
try {
|
||||||
|
// 从 Spring Boot 包中获取版本
|
||||||
|
String version = org.springframework.boot.SpringBootVersion.getVersion();
|
||||||
|
return version != null ? version : "4.1.0-M2";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "4.1.0-M2";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,9 @@ public class AppConfig {
|
|||||||
new WebFetchTool(),
|
new WebFetchTool(),
|
||||||
new TodoWriteTool(),
|
new TodoWriteTool(),
|
||||||
new AgentTool(),
|
new AgentTool(),
|
||||||
new NotebookEditTool()
|
new NotebookEditTool(),
|
||||||
|
new WebSearchTool(),
|
||||||
|
new AskUserQuestionTool()
|
||||||
);
|
);
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,11 @@ public class AppConfig {
|
|||||||
new InitCommand(),
|
new InitCommand(),
|
||||||
new ConfigCommand(),
|
new ConfigCommand(),
|
||||||
new HistoryCommand(),
|
new HistoryCommand(),
|
||||||
|
new DiffCommand(),
|
||||||
|
new VersionCommand(),
|
||||||
|
new SkillsCommand(),
|
||||||
|
new MemoryCommand(),
|
||||||
|
new CopyCommand(),
|
||||||
new ExitCommand()
|
new ExitCommand()
|
||||||
);
|
);
|
||||||
return registry;
|
return registry;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import reactor.core.publisher.Flux;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。
|
* Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。
|
||||||
@@ -32,7 +33,7 @@ import java.util.function.Consumer;
|
|||||||
* <ol>
|
* <ol>
|
||||||
* <li>构建 Prompt(消息历史 + 系统提示 + 工具定义)</li>
|
* <li>构建 Prompt(消息历史 + 系统提示 + 工具定义)</li>
|
||||||
* <li>调用 ChatModel.call() 或 ChatModel.stream()</li>
|
* <li>调用 ChatModel.call() 或 ChatModel.stream()</li>
|
||||||
* <li>检查工具调用 → 执行工具 → 结果回传</li>
|
* <li>检查工具调用 → 权限确认 → 执行工具 → 结果回传</li>
|
||||||
* <li>循环直到无工具调用或达到最大迭代</li>
|
* <li>循环直到无工具调用或达到最大迭代</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +63,12 @@ public class AgentLoop {
|
|||||||
/** 流式输出开始回调:通知 UI 停止 spinner */
|
/** 流式输出开始回调:通知 UI 停止 spinner */
|
||||||
private Runnable onStreamStart;
|
private Runnable onStreamStart;
|
||||||
|
|
||||||
|
/** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */
|
||||||
|
private Function<PermissionRequest, Boolean> onPermissionRequest;
|
||||||
|
|
||||||
|
/** Thinking 内容回调:显示 AI 的思考过程 */
|
||||||
|
private Consumer<String> onThinkingContent;
|
||||||
|
|
||||||
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
|
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry,
|
||||||
ToolContext toolContext, String systemPrompt) {
|
ToolContext toolContext, String systemPrompt) {
|
||||||
this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker());
|
this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker());
|
||||||
@@ -89,6 +96,14 @@ public class AgentLoop {
|
|||||||
this.onStreamStart = onStreamStart;
|
this.onStreamStart = onStreamStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setOnPermissionRequest(Function<PermissionRequest, Boolean> onPermissionRequest) {
|
||||||
|
this.onPermissionRequest = onPermissionRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnThinkingContent(Consumer<String> onThinkingContent) {
|
||||||
|
this.onThinkingContent = onThinkingContent;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 阻塞模式 ====================
|
// ==================== 阻塞模式 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +202,9 @@ public class AgentLoop {
|
|||||||
completionTokens = usage.getCompletionTokens();
|
completionTokens = usage.getCompletionTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试提取 thinking 内容(Anthropic extended thinking)
|
||||||
|
extractThinkingContent(response);
|
||||||
|
|
||||||
return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens);
|
return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +269,7 @@ public class AgentLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 执行工具调用列表并将结果加入消息历史 */
|
/** 执行工具调用列表并将结果加入消息历史 */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
private void executeToolCalls(List<AssistantMessage.ToolCall> toolCalls,
|
private void executeToolCalls(List<AssistantMessage.ToolCall> toolCalls,
|
||||||
List<ToolCallback> callbacks) {
|
List<ToolCallback> callbacks) {
|
||||||
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
|
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
|
||||||
@@ -267,7 +286,27 @@ public class AgentLoop {
|
|||||||
String result;
|
String result;
|
||||||
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
|
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
|
||||||
if (adapter != null) {
|
if (adapter != null) {
|
||||||
|
// 权限确认:非只读工具需要用户确认
|
||||||
|
boolean permitted = true;
|
||||||
|
if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> parsedArgs = MAPPER.readValue(toolArgs, Map.class);
|
||||||
|
String activity = adapter.getTool().activityDescription(parsedArgs);
|
||||||
|
PermissionRequest req = new PermissionRequest(toolName, toolArgs, activity);
|
||||||
|
permitted = onPermissionRequest.apply(req);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// JSON 解析失败时仍然请求确认
|
||||||
|
PermissionRequest req = new PermissionRequest(toolName, toolArgs, "执行 " + toolName);
|
||||||
|
permitted = onPermissionRequest.apply(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permitted) {
|
||||||
result = adapter.call(toolArgs);
|
result = adapter.call(toolArgs);
|
||||||
|
} else {
|
||||||
|
result = "Permission denied: 用户拒绝了此操作";
|
||||||
|
log.info("[{}] 用户拒绝工具执行", toolName);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result = "Error: Unknown tool '" + toolName + "'";
|
result = "Error: Unknown tool '" + toolName + "'";
|
||||||
log.warn("未知工具: {}", toolName);
|
log.warn("未知工具: {}", toolName);
|
||||||
@@ -313,6 +352,11 @@ public class AgentLoop {
|
|||||||
return chatModel;
|
return chatModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取工具上下文(用于注册回调) */
|
||||||
|
public ToolContext getToolContext() {
|
||||||
|
return toolContext;
|
||||||
|
}
|
||||||
|
|
||||||
/** 重置历史(保留系统提示词) */
|
/** 重置历史(保留系统提示词) */
|
||||||
public void reset() {
|
public void reset() {
|
||||||
messageHistory.clear();
|
messageHistory.clear();
|
||||||
@@ -328,6 +372,51 @@ public class AgentLoop {
|
|||||||
/** 单次迭代结果 */
|
/** 单次迭代结果 */
|
||||||
private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {}
|
private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ChatResponse 中尝试提取 thinking 内容。
|
||||||
|
* <p>
|
||||||
|
* Anthropic 的 extended thinking 功能会在响应中包含思考过程。
|
||||||
|
* Spring AI 可能将其放在 metadata 中或作为独立的消息属性。
|
||||||
|
*/
|
||||||
|
private void extractThinkingContent(ChatResponse response) {
|
||||||
|
if (onThinkingContent == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 方式1: 检查 response metadata 中的 thinking 字段
|
||||||
|
if (response.getMetadata() != null) {
|
||||||
|
var metadata = response.getMetadata();
|
||||||
|
// Spring AI 可能在 metadata 中存储 thinking 内容
|
||||||
|
// 不同版本可能有不同的 key
|
||||||
|
if (metadata instanceof Map<?, ?> metaMap) {
|
||||||
|
Object thinking = metaMap.get("thinking");
|
||||||
|
if (thinking instanceof String thinkText && !thinkText.isBlank()) {
|
||||||
|
onThinkingContent.accept(thinkText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式2: 检查 AssistantMessage 的 metadata
|
||||||
|
if (response.getResult() != null && response.getResult().getOutput() != null) {
|
||||||
|
var output = response.getResult().getOutput();
|
||||||
|
var msgMeta = output.getMetadata();
|
||||||
|
if (msgMeta != null) {
|
||||||
|
// 尝试获取 thinking 相关的元数据
|
||||||
|
Object thinking = msgMeta.get("thinking");
|
||||||
|
if (thinking instanceof String thinkText && !thinkText.isBlank()) {
|
||||||
|
onThinkingContent.accept(thinkText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// thinking 提取失败不影响主流程
|
||||||
|
log.debug("Thinking 内容提取异常(可忽略): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限确认请求 */
|
||||||
|
public record PermissionRequest(String toolName, String arguments, String activityDescription) {}
|
||||||
|
|
||||||
/** 工具事件,用于 UI 展示 */
|
/** 工具事件,用于 UI 展示 */
|
||||||
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
|
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
|
||||||
public enum Phase { START, END }
|
public enum Phase { START, END }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.claudecode.console.*;
|
|||||||
import com.claudecode.core.AgentLoop;
|
import com.claudecode.core.AgentLoop;
|
||||||
import com.claudecode.core.ConversationPersistence;
|
import com.claudecode.core.ConversationPersistence;
|
||||||
import com.claudecode.tool.ToolRegistry;
|
import com.claudecode.tool.ToolRegistry;
|
||||||
|
import com.claudecode.tool.impl.AskUserQuestionTool;
|
||||||
import org.jline.reader.*;
|
import org.jline.reader.*;
|
||||||
import org.jline.reader.impl.DefaultParser;
|
import org.jline.reader.impl.DefaultParser;
|
||||||
import org.jline.terminal.Terminal;
|
import org.jline.terminal.Terminal;
|
||||||
@@ -22,6 +23,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Scanner;
|
import java.util.Scanner;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。
|
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。
|
||||||
@@ -48,11 +50,17 @@ public class ReplSession {
|
|||||||
private final ToolStatusRenderer toolStatusRenderer;
|
private final ToolStatusRenderer toolStatusRenderer;
|
||||||
private final MarkdownRenderer markdownRenderer;
|
private final MarkdownRenderer markdownRenderer;
|
||||||
private final SpinnerAnimation spinner;
|
private final SpinnerAnimation spinner;
|
||||||
|
private final ThinkingRenderer thinkingRenderer;
|
||||||
|
|
||||||
/** 对话摘要(取第一次用户输入的前40字) */
|
/** 对话摘要(取第一次用户输入的前40字) */
|
||||||
private String conversationSummary = "";
|
private String conversationSummary = "";
|
||||||
private volatile boolean running = true;
|
private volatile boolean running = true;
|
||||||
|
|
||||||
|
/** 当前活跃的 LineReader(JLine 模式下用于 AskUser 和权限确认) */
|
||||||
|
private volatile LineReader activeReader;
|
||||||
|
/** 当前活跃的 Scanner(Scanner 模式下用于 AskUser 和权限确认) */
|
||||||
|
private volatile Scanner activeScanner;
|
||||||
|
|
||||||
public ReplSession(AgentLoop agentLoop,
|
public ReplSession(AgentLoop agentLoop,
|
||||||
ToolRegistry toolRegistry,
|
ToolRegistry toolRegistry,
|
||||||
CommandRegistry commandRegistry,
|
CommandRegistry commandRegistry,
|
||||||
@@ -67,8 +75,20 @@ public class ReplSession {
|
|||||||
this.toolStatusRenderer = new ToolStatusRenderer(out);
|
this.toolStatusRenderer = new ToolStatusRenderer(out);
|
||||||
this.markdownRenderer = new MarkdownRenderer(out);
|
this.markdownRenderer = new MarkdownRenderer(out);
|
||||||
this.spinner = new SpinnerAnimation(out);
|
this.spinner = new SpinnerAnimation(out);
|
||||||
|
this.thinkingRenderer = new ThinkingRenderer(out);
|
||||||
|
|
||||||
setupAgentCallbacks();
|
setupAgentCallbacks();
|
||||||
|
setupToolContextCallbacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册 ToolContext 回调(AskUser 用户输入) */
|
||||||
|
private void setupToolContextCallbacks() {
|
||||||
|
// 注册 AskUserQuestionTool 所需的用户输入回调
|
||||||
|
var toolContext = agentLoop.getToolContext();
|
||||||
|
if (toolContext != null) {
|
||||||
|
toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK,
|
||||||
|
(Function<String, String>) this::readUserInputDuringAgentLoop);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
|
/** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */
|
||||||
@@ -89,6 +109,18 @@ public class ReplSession {
|
|||||||
agentLoop.setOnAssistantMessage(text -> {
|
agentLoop.setOnAssistantMessage(text -> {
|
||||||
// 阻塞模式回调:流式模式下由 onToken 实时输出,此回调不触发
|
// 阻塞模式回调:流式模式下由 onToken 实时输出,此回调不触发
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 权限确认回调:非只读工具执行前请求用户确认
|
||||||
|
agentLoop.setOnPermissionRequest(request -> {
|
||||||
|
spinner.stop();
|
||||||
|
return promptPermission(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thinking 内容回调:显示 AI 思考过程
|
||||||
|
agentLoop.setOnThinkingContent(thinkingText -> {
|
||||||
|
spinner.stop();
|
||||||
|
thinkingRenderer.render(thinkingText);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,6 +175,9 @@ public class ReplSession {
|
|||||||
|
|
||||||
printBanner(terminal);
|
printBanner(terminal);
|
||||||
|
|
||||||
|
// 设置活跃的 reader,供 AskUser 和权限确认使用
|
||||||
|
this.activeReader = reader;
|
||||||
|
|
||||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
@@ -212,6 +247,7 @@ public class ReplSession {
|
|||||||
out.println();
|
out.println();
|
||||||
|
|
||||||
Scanner scanner = new Scanner(System.in);
|
Scanner scanner = new Scanner(System.in);
|
||||||
|
this.activeScanner = scanner;
|
||||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false);
|
||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
@@ -292,4 +328,92 @@ public class ReplSession {
|
|||||||
public void stop() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 权限确认 UI ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示权限确认提示并等待用户输入。
|
||||||
|
* 用于危险操作(文件写入、bash 执行等)前的安全确认。
|
||||||
|
*/
|
||||||
|
private boolean promptPermission(AgentLoop.PermissionRequest request) {
|
||||||
|
out.println();
|
||||||
|
out.println(AnsiStyle.yellow(" ⚠ 权限确认"));
|
||||||
|
out.println(" " + "─".repeat(50));
|
||||||
|
out.println(" " + AnsiStyle.bold("工具: ") + AnsiStyle.cyan(request.toolName()));
|
||||||
|
out.println(" " + AnsiStyle.bold("操作: ") + request.activityDescription());
|
||||||
|
|
||||||
|
// 显示参数摘要(截断过长的参数)
|
||||||
|
String argsPreview = request.arguments();
|
||||||
|
if (argsPreview != null && argsPreview.length() > 200) {
|
||||||
|
argsPreview = argsPreview.substring(0, 200) + "...";
|
||||||
|
}
|
||||||
|
if (argsPreview != null && !argsPreview.isBlank()) {
|
||||||
|
out.println(" " + AnsiStyle.dim("参数: " + argsPreview));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.println(" " + "─".repeat(50));
|
||||||
|
out.print(" " + AnsiStyle.bold("允许执行?") + AnsiStyle.dim(" [Y/n/always] ") + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
||||||
|
out.flush();
|
||||||
|
|
||||||
|
String answer = readLineForPermission();
|
||||||
|
if (answer == null) return false;
|
||||||
|
|
||||||
|
answer = answer.strip().toLowerCase();
|
||||||
|
|
||||||
|
// "always" → 禁用后续权限确认
|
||||||
|
if (answer.equals("always") || answer.equals("a")) {
|
||||||
|
agentLoop.setOnPermissionRequest(null); // 移除权限回调
|
||||||
|
out.println(AnsiStyle.green(" ✓ 已授权所有后续操作"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空字符串或 y/yes → 允许
|
||||||
|
if (answer.isEmpty() || answer.equals("y") || answer.equals("yes")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他输入 → 拒绝
|
||||||
|
out.println(AnsiStyle.red(" ✗ 操作已拒绝"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取权限确认的用户输入(兼容 JLine 和 Scanner 模式) */
|
||||||
|
private String readLineForPermission() {
|
||||||
|
try {
|
||||||
|
if (activeReader != null) {
|
||||||
|
return activeReader.readLine();
|
||||||
|
} else if (activeScanner != null && activeScanner.hasNextLine()) {
|
||||||
|
return activeScanner.nextLine();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("读取权限确认输入异常: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AskUser 工具回调 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Agent 循环执行过程中读取用户输入。
|
||||||
|
* 被 AskUserQuestionTool 通过 ToolContext 回调使用。
|
||||||
|
*/
|
||||||
|
private String readUserInputDuringAgentLoop(String prompt) {
|
||||||
|
spinner.stop();
|
||||||
|
out.print(prompt);
|
||||||
|
out.print(" " + AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + "→ " + AnsiStyle.RESET);
|
||||||
|
out.flush();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (activeReader != null) {
|
||||||
|
return activeReader.readLine();
|
||||||
|
} else if (activeScanner != null && activeScanner.hasNextLine()) {
|
||||||
|
return activeScanner.nextLine();
|
||||||
|
}
|
||||||
|
} catch (UserInterruptException e) {
|
||||||
|
return "(用户取消)";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("读取用户输入异常: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户提问工具 —— AI 在执行过程中向用户提问并获取回答。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 AskUserQuestionTool,允许 AI 在需要澄清信息时
|
||||||
|
* 暂停执行并向用户提问。用户的回答会作为工具返回值传回 AI。
|
||||||
|
* <p>
|
||||||
|
* 依赖 ToolContext 中注册的 {@code USER_INPUT_CALLBACK} 回调函数,
|
||||||
|
* 该回调由 ReplSession 在启动时设置,用于读取终端用户输入。
|
||||||
|
*/
|
||||||
|
public class AskUserQuestionTool implements Tool {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AskUserQuestionTool.class);
|
||||||
|
|
||||||
|
/** ToolContext 中用于读取用户输入的回调 Key */
|
||||||
|
public static final String USER_INPUT_CALLBACK = "ask_user_input_callback";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "AskUserQuestion";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Ask the user a question and wait for their response. Use this when you need clarification, " +
|
||||||
|
"confirmation, or additional information from the user to proceed with a task. " +
|
||||||
|
"The question should be clear, specific, and actionable.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The question to ask the user. Should be clear and specific."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Optional list of choices for the user to pick from"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["question"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReadOnly() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
return "Asking user a question...";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String question = (String) input.get("question");
|
||||||
|
if (question == null || question.isBlank()) {
|
||||||
|
return "Error: question parameter is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户输入回调
|
||||||
|
Object callback = context.get(USER_INPUT_CALLBACK);
|
||||||
|
if (callback == null) {
|
||||||
|
log.warn("未注册用户输入回调(USER_INPUT_CALLBACK),返回默认回复");
|
||||||
|
return "Error: User input not available in current environment";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(callback instanceof Function<?, ?> inputFn)) {
|
||||||
|
return "Error: Invalid user input callback type";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Function<String, String> askUser = (Function<String, String>) inputFn;
|
||||||
|
|
||||||
|
// 构建提问文本
|
||||||
|
StringBuilder prompt = new StringBuilder();
|
||||||
|
prompt.append("\n 🤔 AI 正在向你提问:\n");
|
||||||
|
prompt.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
prompt.append(" ").append(question).append("\n");
|
||||||
|
|
||||||
|
// 如果有选项
|
||||||
|
if (input.containsKey("options")) {
|
||||||
|
var options = (java.util.List<String>) input.get("options");
|
||||||
|
if (options != null && !options.isEmpty()) {
|
||||||
|
prompt.append("\n 可选项:\n");
|
||||||
|
for (int i = 0; i < options.size(); i++) {
|
||||||
|
prompt.append(" ").append(i + 1).append(". ").append(options.get(i)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
|
||||||
|
// 调用回调获取用户输入
|
||||||
|
String userResponse = askUser.apply(prompt.toString());
|
||||||
|
|
||||||
|
if (userResponse == null || userResponse.isBlank()) {
|
||||||
|
return "(User provided no response)";
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("用户回答: {}", userResponse);
|
||||||
|
return "User response: " + userResponse;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取用户输入失败", e);
|
||||||
|
return "Error: Failed to get user input - " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.PermissionResult;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络搜索工具 —— 使用 DuckDuckGo HTML 搜索(免费,无需 API Key)。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 WebSearchTool,用于搜索互联网获取实时信息。
|
||||||
|
* 通过解析 DuckDuckGo HTML 搜索结果页面提取搜索结果。
|
||||||
|
*/
|
||||||
|
public class WebSearchTool implements Tool {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WebSearchTool.class);
|
||||||
|
|
||||||
|
/** DuckDuckGo HTML 搜索端点(不需要 JavaScript) */
|
||||||
|
private static final String DDG_URL = "https://html.duckduckgo.com/html/";
|
||||||
|
private static final int MAX_RESULTS = 8;
|
||||||
|
private static final Duration TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "WebSearch";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Search the web using DuckDuckGo. Returns search results with titles, URLs and snippets. " +
|
||||||
|
"Use this to find up-to-date information, documentation, or answers to questions.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search query string"
|
||||||
|
},
|
||||||
|
"maxResults": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results to return (default: 8)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReadOnly() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
String query = (String) input.getOrDefault("query", "");
|
||||||
|
return "Searching: " + query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String query = (String) input.get("query");
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return "Error: query parameter is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxResults = MAX_RESULTS;
|
||||||
|
if (input.containsKey("maxResults")) {
|
||||||
|
maxResults = ((Number) input.get("maxResults")).intValue();
|
||||||
|
maxResults = Math.max(1, Math.min(maxResults, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String html = fetchSearchPage(query);
|
||||||
|
return parseResults(html, maxResults);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("搜索失败: query={}", query, e);
|
||||||
|
return "Error: Search failed - " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求 DuckDuckGo HTML 搜索页面 */
|
||||||
|
private String fetchSearchPage(String query) throws IOException, InterruptedException {
|
||||||
|
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(TIMEOUT)
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(DDG_URL + "?q=" + encodedQuery))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (compatible; ClaudeCodeJava/1.0)")
|
||||||
|
.GET()
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 DuckDuckGo HTML 页面解析搜索结果 */
|
||||||
|
private String parseResults(String html, int maxResults) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// DuckDuckGo HTML 搜索结果格式:
|
||||||
|
// <a class="result__a" href="...">Title</a>
|
||||||
|
// <a class="result__snippet" href="...">Snippet</a>
|
||||||
|
// 或者结果块在 <div class="result results_links results_links_deep web-result">
|
||||||
|
|
||||||
|
// 提取结果链接和标题
|
||||||
|
Pattern resultPattern = Pattern.compile(
|
||||||
|
"<a[^>]+class=\"result__a\"[^>]*href=\"([^\"]*)\"[^>]*>(.*?)</a>",
|
||||||
|
Pattern.DOTALL);
|
||||||
|
|
||||||
|
// 提取摘要
|
||||||
|
Pattern snippetPattern = Pattern.compile(
|
||||||
|
"<a[^>]+class=\"result__snippet\"[^>]*>(.*?)</a>",
|
||||||
|
Pattern.DOTALL);
|
||||||
|
|
||||||
|
Matcher resultMatcher = resultPattern.matcher(html);
|
||||||
|
Matcher snippetMatcher = snippetPattern.matcher(html);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
while (resultMatcher.find() && count < maxResults) {
|
||||||
|
count++;
|
||||||
|
String url = resultMatcher.group(1);
|
||||||
|
String title = stripHtml(resultMatcher.group(2));
|
||||||
|
|
||||||
|
// DuckDuckGo 的链接是重定向格式,提取实际 URL
|
||||||
|
if (url.contains("uddg=")) {
|
||||||
|
try {
|
||||||
|
String decoded = java.net.URLDecoder.decode(
|
||||||
|
url.substring(url.indexOf("uddg=") + 5), StandardCharsets.UTF_8);
|
||||||
|
// 截取到 & 之前
|
||||||
|
int ampIdx = decoded.indexOf('&');
|
||||||
|
if (ampIdx > 0) decoded = decoded.substring(0, ampIdx);
|
||||||
|
url = decoded;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
String snippet = "";
|
||||||
|
if (snippetMatcher.find()) {
|
||||||
|
snippet = stripHtml(snippetMatcher.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(count).append(". ").append(title).append("\n");
|
||||||
|
sb.append(" URL: ").append(url).append("\n");
|
||||||
|
if (!snippet.isBlank()) {
|
||||||
|
sb.append(" ").append(snippet).append("\n");
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0) {
|
||||||
|
// 尝试备用解析模式
|
||||||
|
return parseResultsFallback(html, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 备用解析:简单的链接提取 */
|
||||||
|
private String parseResultsFallback(String html, int maxResults) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
// 提取所有外部链接
|
||||||
|
Pattern linkPattern = Pattern.compile("<a[^>]+href=\"(https?://[^\"]*)\"[^>]*>(.*?)</a>", Pattern.DOTALL);
|
||||||
|
Matcher matcher = linkPattern.matcher(html);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
java.util.Set<String> seenUrls = new java.util.HashSet<>();
|
||||||
|
|
||||||
|
while (matcher.find() && count < maxResults) {
|
||||||
|
String url = matcher.group(1);
|
||||||
|
String title = stripHtml(matcher.group(2));
|
||||||
|
|
||||||
|
// 跳过 DuckDuckGo 自身链接和重复
|
||||||
|
if (url.contains("duckduckgo.com") || title.isBlank() || !seenUrls.add(url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
sb.append(count).append(". ").append(title).append("\n");
|
||||||
|
sb.append(" URL: ").append(url).append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0) {
|
||||||
|
return "No results found. Try a different query.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去除 HTML 标签 */
|
||||||
|
private String stripHtml(String html) {
|
||||||
|
if (html == null) return "";
|
||||||
|
return html.replaceAll("<[^>]+>", "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll(""", "\"")
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
.replaceAll(" ", " ")
|
||||||
|
.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,32 +269,136 @@ allowed-tools: [BashTool, FileReadTool]
|
|||||||
## 4. 验收标准
|
## 4. 验收标准
|
||||||
|
|
||||||
### 4.1 基础功能
|
### 4.1 基础功能
|
||||||
- [ ] 应用可正常启动并显示 Banner
|
- [x] 应用可正常启动并显示 Banner
|
||||||
- [ ] 支持通过环境变量或配置文件设置 API Key 和模型
|
- [x] 支持通过环境变量或配置文件设置 API Key 和模型
|
||||||
- [ ] REPL 循环正常工作:输入问题 → 获得 AI 回复
|
- [x] REPL 循环正常工作:输入问题 → 获得 AI 回复
|
||||||
- [ ] 工具调用循环正常:AI 可以调用工具并获取结果
|
- [x] 工具调用循环正常:AI 可以调用工具并获取结果
|
||||||
- [ ] 至少支持 8 个核心工具(Bash、FileRead、FileWrite、FileEdit、Glob、Grep、WebFetch、TodoWrite)
|
- [x] 至少支持 8 个核心工具(实际 11 个)
|
||||||
- [ ] 至少支持 8 个斜杠命令(/help、/clear、/compact、/exit、/cost、/model、/memory、/context)
|
- [x] 至少支持 8 个斜杠命令(实际 11 个)
|
||||||
|
|
||||||
### 4.2 交互体验
|
### 4.2 交互体验
|
||||||
- [ ] Banner 显示正常(Logo + 版本 + 模型信息)
|
- [x] Banner 显示正常(Logo + 版本 + 模型信息 + Provider + URL)
|
||||||
- [ ] 输入提示符支持行编辑和历史记录
|
- [x] 输入提示符支持行编辑和历史记录
|
||||||
- [ ] Thinking 过程可见
|
- [x] Thinking 过程可见(Spinner 动画 + "Thinking..." 提示)
|
||||||
- [ ] 工具调用时显示 Spinner 动画和状态
|
- [x] 工具调用时显示 Spinner 动画和状态
|
||||||
- [ ] 支持 ANSI 颜色输出
|
- [x] 支持 ANSI 颜色输出
|
||||||
- [ ] Markdown 内容基本格式化
|
- [x] Markdown 内容基本格式化
|
||||||
|
|
||||||
### 4.3 上下文功能
|
### 4.3 上下文功能
|
||||||
- [ ] 能加载项目 CLAUDE.md
|
- [x] 能加载项目 CLAUDE.md
|
||||||
- [ ] 能加载用户级 CLAUDE.md
|
- [x] 能加载用户级 CLAUDE.md
|
||||||
- [ ] Git 上下文信息正确收集
|
- [x] Git 上下文信息正确收集
|
||||||
- [ ] 系统提示词包含所有必要信息
|
- [x] 系统提示词包含所有必要信息
|
||||||
|
|
||||||
### 4.4 代码质量
|
### 4.4 代码质量
|
||||||
- [ ] Maven 编译无错误
|
- [x] Maven 编译无错误
|
||||||
- [ ] 代码结构清晰,包划分合理
|
- [x] 代码结构清晰,包划分合理
|
||||||
- [ ] 关键逻辑有中文注释
|
- [x] 关键逻辑有中文注释
|
||||||
- [ ] 提供完整的使用文档
|
- [x] 提供完整的使用文档(README.md)
|
||||||
|
|
||||||
|
### 4.5 已超出原始验收标准的功能
|
||||||
|
- [x] 流式输出(Flux<ChatResponse> 逐 token 实时显示)
|
||||||
|
- [x] 对话历史持久化(JSON 格式自动保存/加载)
|
||||||
|
- [x] AI 驱动的上下文压缩(/compact 生成摘要)
|
||||||
|
- [x] 多行输入支持(反斜杠续行)
|
||||||
|
- [x] 双 API 提供者(OpenAI + Anthropic 切换)
|
||||||
|
- [x] /history 命令查看保存的对话
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 实现状态总结
|
||||||
|
|
||||||
|
### 5.1 已实现功能清单
|
||||||
|
|
||||||
|
| 类别 | 功能 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| **REPL 核心** | ChatModel 自定义循环 | ✅ |
|
||||||
|
| | 流式输出 (Flux) | ✅ |
|
||||||
|
| | 阻塞模式降级 | ✅ |
|
||||||
|
| | 最大迭代限制 (50轮) | ✅ |
|
||||||
|
| | 消息历史管理 | ✅ |
|
||||||
|
| | 对话持久化 | ✅ |
|
||||||
|
| **工具系统 (11个)** | BashTool | ✅ |
|
||||||
|
| | FileReadTool | ✅ |
|
||||||
|
| | FileWriteTool | ✅ |
|
||||||
|
| | FileEditTool | ✅ |
|
||||||
|
| | GlobTool | ✅ |
|
||||||
|
| | GrepTool | ✅ |
|
||||||
|
| | WebFetchTool | ✅ |
|
||||||
|
| | TodoWriteTool | ✅ |
|
||||||
|
| | ListFilesTool | ✅ |
|
||||||
|
| | AgentTool | ✅ |
|
||||||
|
| | NotebookEditTool | ✅ |
|
||||||
|
| **命令系统 (11个)** | /help, /clear, /exit | ✅ |
|
||||||
|
| | /compact (AI摘要) | ✅ |
|
||||||
|
| | /cost, /model, /status | ✅ |
|
||||||
|
| | /context, /config, /init | ✅ |
|
||||||
|
| | /history | ✅ |
|
||||||
|
| **上下文** | CLAUDE.md 加载 | ✅ |
|
||||||
|
| | Skills 技能加载 | ✅ |
|
||||||
|
| | Git 上下文收集 | ✅ |
|
||||||
|
| | 系统提示词构建 | ✅ |
|
||||||
|
| **终端 UI** | Banner + Provider 信息 | ✅ |
|
||||||
|
| | JLine 行编辑/历史/Tab补全 | ✅ |
|
||||||
|
| | 多行输入 | ✅ |
|
||||||
|
| | Spinner 动画 | ✅ |
|
||||||
|
| | 工具状态渲染 | ✅ |
|
||||||
|
| | ANSI 颜色 | ✅ |
|
||||||
|
| | Markdown 渲染 | ✅ |
|
||||||
|
| **配置** | 双 API 提供者切换 | ✅ |
|
||||||
|
| | 环境变量统一 | ✅ |
|
||||||
|
| | Token/费用追踪 | ✅ |
|
||||||
|
|
||||||
|
### 5.2 未实现功能清单
|
||||||
|
|
||||||
|
#### 🔴 P0 核心(建议实现)
|
||||||
|
|
||||||
|
| 功能 | 难度 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **WebSearchTool** | 中 | 网络搜索工具,需要搜索 API(可用 DuckDuckGo 免费 API) |
|
||||||
|
| **AskUserQuestionTool** | 中 | AI 向用户提问工具,需要在 agent loop 中暂停等待用户输入 |
|
||||||
|
| **/diff 命令** | 低 | 显示未提交的 Git 变更,可复用 GitContext |
|
||||||
|
| **/version 命令** | 低 | 显示版本号,直接读取 pom.xml 版本 |
|
||||||
|
| **/skills 命令** | 低 | 列出可用技能,已有 SkillLoader |
|
||||||
|
| **/memory 命令** | 低 | 编辑 CLAUDE.md,打开编辑器或直接编辑 |
|
||||||
|
| **/copy 命令** | 低 | 复制最近回复到剪贴板 |
|
||||||
|
| **权限确认机制** | 中 | 危险操作前要求用户确认(如文件写入、bash 执行) |
|
||||||
|
| **Thinking 内容显示** | 高 | 显示 AI 思考过程(需要 Anthropic extended thinking API 支持) |
|
||||||
|
|
||||||
|
#### 🟡 P1 重要(体验增强)
|
||||||
|
|
||||||
|
| 功能 | 难度 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| CLAUDE.local.md 支持 | 低 | 本地级记忆文件 |
|
||||||
|
| 底部状态行 | 中 | 持续显示模型、token、工作目录 |
|
||||||
|
| Hook 系统 (PreToolUse) | 中 | 工具调用前后钩子 |
|
||||||
|
| /resume 恢复对话 | 低 | 从持久化文件恢复对话 |
|
||||||
|
| /export 导出对话 | 低 | 导出为 Markdown 文件 |
|
||||||
|
| /commit 命令 | 中 | 直接创建 Git commit |
|
||||||
|
| Vim 模式输入 | 高 | JLine Vim 模式绑定 |
|
||||||
|
| 代码语法高亮 | 中 | MarkdownRenderer 中的代码块着色 |
|
||||||
|
|
||||||
|
#### 🟢 P2 扩展(可后续迭代)
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| MCP 客户端集成 | Model Context Protocol 支持 |
|
||||||
|
| 插件系统 | 可扩展的工具/命令加载 |
|
||||||
|
| LSP 集成 | 语言服务器协议 |
|
||||||
|
| 差异视图 (DiffRenderer) | 文件变更的彩色diff显示 |
|
||||||
|
| OAuth 认证流程 | 第三方认证 |
|
||||||
|
| 多代理协调 | Agent Swarm 模式 |
|
||||||
|
|
||||||
|
#### ⚪ P3 跳过(不适用于 Java CLI)
|
||||||
|
|
||||||
|
| 功能 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| Ink/React UI 框架 | Java 用 JLine 替代 |
|
||||||
|
| IDE 桥接 (bridge/) | 需要独立的 IDE 插件 |
|
||||||
|
| 语音功能 (voice/) | 平台特定 |
|
||||||
|
| 远程会话 (remote/) | 需要服务端 |
|
||||||
|
| 沙箱隔离 (sandbox/) | 需要容器化 |
|
||||||
|
| 遥测 (telemetry/) | 非核心功能 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user