From 82a28b7aa761701254caf53e40fd4eb3e0a6711f Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 22:06:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P0=E5=8A=9F=E8=83=BD=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=20-=205=E5=91=BD=E4=BB=A4+2=E5=B7=A5=E5=85=B7+=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=A1=AE=E8=AE=A4+Thinking=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增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> --- .../claudecode/command/impl/CopyCommand.java | 69 ++++++ .../claudecode/command/impl/DiffCommand.java | 139 +++++++++++ .../command/impl/MemoryCommand.java | 157 ++++++++++++ .../command/impl/SkillsCommand.java | 72 ++++++ .../command/impl/VersionCommand.java | 64 +++++ .../java/com/claudecode/config/AppConfig.java | 9 +- .../java/com/claudecode/core/AgentLoop.java | 93 ++++++- .../java/com/claudecode/repl/ReplSession.java | 124 ++++++++++ .../tool/impl/AskUserQuestionTool.java | 126 ++++++++++ .../claudecode/tool/impl/WebSearchTool.java | 230 ++++++++++++++++++ 需求文档.md | 144 +++++++++-- 11 files changed, 1204 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/claudecode/command/impl/CopyCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/DiffCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/MemoryCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/SkillsCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/VersionCommand.java create mode 100644 src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/WebSearchTool.java diff --git a/src/main/java/com/claudecode/command/impl/CopyCommand.java b/src/main/java/com/claudecode/command/impl/CopyCommand.java new file mode 100644 index 0000000..c74e278 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/CopyCommand.java @@ -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 回复复制到系统剪贴板。 + *

+ * 从消息历史中提取最后一条 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 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()); + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/DiffCommand.java b/src/main/java/com/claudecode/command/impl/DiffCommand.java new file mode 100644 index 0000000..7e283dc --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/DiffCommand.java @@ -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 未提交的变更。 + *

+ * 对应 claude-code 的 /diff 命令,展示工作区的变更内容: + *

+ */ +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(); + 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(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/MemoryCommand.java b/src/main/java/com/claudecode/command/impl/MemoryCommand.java new file mode 100644 index 0000000..b9a2177 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/MemoryCommand.java @@ -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 记忆文件。 + *

+ * 对应 claude-code 的 /memory 命令,支持: + *

+ */ +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 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()); + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/SkillsCommand.java b/src/main/java/com/claudecode/command/impl/SkillsCommand.java new file mode 100644 index 0000000..e97437a --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/SkillsCommand.java @@ -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 命令 —— 列出所有可用的技能。 + *

+ * 扫描并显示从用户级、项目级和命令目录加载的技能文件。 + */ +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 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(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/VersionCommand.java b/src/main/java/com/claudecode/command/impl/VersionCommand.java new file mode 100644 index 0000000..5f646dd --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/VersionCommand.java @@ -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 命令 —— 显示版本和环境信息。 + *

+ * 展示 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"; + } + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index a072bb0..ca08c6b 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -56,7 +56,9 @@ public class AppConfig { new WebFetchTool(), new TodoWriteTool(), new AgentTool(), - new NotebookEditTool() + new NotebookEditTool(), + new WebSearchTool(), + new AskUserQuestionTool() ); return registry; } @@ -75,6 +77,11 @@ public class AppConfig { new InitCommand(), new ConfigCommand(), new HistoryCommand(), + new DiffCommand(), + new VersionCommand(), + new SkillsCommand(), + new MemoryCommand(), + new CopyCommand(), new ExitCommand() ); return registry; diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java index b976073..d0dceb3 100644 --- a/src/main/java/com/claudecode/core/AgentLoop.java +++ b/src/main/java/com/claudecode/core/AgentLoop.java @@ -19,6 +19,7 @@ import reactor.core.publisher.Flux; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.function.Function; /** * Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。 @@ -32,7 +33,7 @@ import java.util.function.Consumer; *

    *
  1. 构建 Prompt(消息历史 + 系统提示 + 工具定义)
  2. *
  3. 调用 ChatModel.call() 或 ChatModel.stream()
  4. - *
  5. 检查工具调用 → 执行工具 → 结果回传
  6. + *
  7. 检查工具调用 → 权限确认 → 执行工具 → 结果回传
  8. *
  9. 循环直到无工具调用或达到最大迭代
  10. *
*/ @@ -62,6 +63,12 @@ public class AgentLoop { /** 流式输出开始回调:通知 UI 停止 spinner */ private Runnable onStreamStart; + /** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */ + private Function onPermissionRequest; + + /** Thinking 内容回调:显示 AI 的思考过程 */ + private Consumer onThinkingContent; + public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, ToolContext toolContext, String systemPrompt) { this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker()); @@ -89,6 +96,14 @@ public class AgentLoop { this.onStreamStart = onStreamStart; } + public void setOnPermissionRequest(Function onPermissionRequest) { + this.onPermissionRequest = onPermissionRequest; + } + + public void setOnThinkingContent(Consumer onThinkingContent) { + this.onThinkingContent = onThinkingContent; + } + // ==================== 阻塞模式 ==================== /** @@ -187,6 +202,9 @@ public class AgentLoop { completionTokens = usage.getCompletionTokens(); } + // 尝试提取 thinking 内容(Anthropic extended thinking) + extractThinkingContent(response); + return new IterationResult(response.getResult().getOutput(), promptTokens, completionTokens); } @@ -251,6 +269,7 @@ public class AgentLoop { } /** 执行工具调用列表并将结果加入消息历史 */ + @SuppressWarnings("unchecked") private void executeToolCalls(List toolCalls, List callbacks) { List toolResponses = new ArrayList<>(); @@ -267,7 +286,27 @@ public class AgentLoop { String result; ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName); if (adapter != null) { - result = adapter.call(toolArgs); + // 权限确认:非只读工具需要用户确认 + boolean permitted = true; + if (!adapter.getTool().isReadOnly() && onPermissionRequest != null) { + try { + Map 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); + } else { + result = "Permission denied: 用户拒绝了此操作"; + log.info("[{}] 用户拒绝工具执行", toolName); + } } else { result = "Error: Unknown tool '" + toolName + "'"; log.warn("未知工具: {}", toolName); @@ -313,6 +352,11 @@ public class AgentLoop { return chatModel; } + /** 获取工具上下文(用于注册回调) */ + public ToolContext getToolContext() { + return toolContext; + } + /** 重置历史(保留系统提示词) */ public void reset() { messageHistory.clear(); @@ -328,6 +372,51 @@ public class AgentLoop { /** 单次迭代结果 */ private record IterationResult(AssistantMessage assistant, long promptTokens, long completionTokens) {} + /** + * 从 ChatResponse 中尝试提取 thinking 内容。 + *

+ * 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 展示 */ public record ToolEvent(String toolName, Phase phase, String arguments, String result) { public enum Phase { START, END } diff --git a/src/main/java/com/claudecode/repl/ReplSession.java b/src/main/java/com/claudecode/repl/ReplSession.java index 06179c0..694098e 100644 --- a/src/main/java/com/claudecode/repl/ReplSession.java +++ b/src/main/java/com/claudecode/repl/ReplSession.java @@ -7,6 +7,7 @@ import com.claudecode.console.*; import com.claudecode.core.AgentLoop; import com.claudecode.core.ConversationPersistence; import com.claudecode.tool.ToolRegistry; +import com.claudecode.tool.impl.AskUserQuestionTool; import org.jline.reader.*; import org.jline.reader.impl.DefaultParser; import org.jline.terminal.Terminal; @@ -22,6 +23,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Scanner; +import java.util.function.Function; /** * REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。 @@ -48,11 +50,17 @@ public class ReplSession { private final ToolStatusRenderer toolStatusRenderer; private final MarkdownRenderer markdownRenderer; private final SpinnerAnimation spinner; + private final ThinkingRenderer thinkingRenderer; /** 对话摘要(取第一次用户输入的前40字) */ private String conversationSummary = ""; private volatile boolean running = true; + /** 当前活跃的 LineReader(JLine 模式下用于 AskUser 和权限确认) */ + private volatile LineReader activeReader; + /** 当前活跃的 Scanner(Scanner 模式下用于 AskUser 和权限确认) */ + private volatile Scanner activeScanner; + public ReplSession(AgentLoop agentLoop, ToolRegistry toolRegistry, CommandRegistry commandRegistry, @@ -67,8 +75,20 @@ public class ReplSession { this.toolStatusRenderer = new ToolStatusRenderer(out); this.markdownRenderer = new MarkdownRenderer(out); this.spinner = new SpinnerAnimation(out); + this.thinkingRenderer = new ThinkingRenderer(out); setupAgentCallbacks(); + setupToolContextCallbacks(); + } + + /** 注册 ToolContext 回调(AskUser 用户输入) */ + private void setupToolContextCallbacks() { + // 注册 AskUserQuestionTool 所需的用户输入回调 + var toolContext = agentLoop.getToolContext(); + if (toolContext != null) { + toolContext.set(AskUserQuestionTool.USER_INPUT_CALLBACK, + (Function) this::readUserInputDuringAgentLoop); + } } /** 注册 AgentLoop 事件回调,驱动控制台 UI 渲染 */ @@ -89,6 +109,18 @@ public class ReplSession { agentLoop.setOnAssistantMessage(text -> { // 阻塞模式回调:流式模式下由 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); + // 设置活跃的 reader,供 AskUser 和权限确认使用 + this.activeReader = reader; + CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); while (running) { @@ -212,6 +247,7 @@ public class ReplSession { out.println(); Scanner scanner = new Scanner(System.in); + this.activeScanner = scanner; CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); while (running) { @@ -292,4 +328,92 @@ public class ReplSession { public void stop() { 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; + } } diff --git a/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java new file mode 100644 index 0000000..c78d52d --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/AskUserQuestionTool.java @@ -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 在执行过程中向用户提问并获取回答。 + *

+ * 对应 claude-code 的 AskUserQuestionTool,允许 AI 在需要澄清信息时 + * 暂停执行并向用户提问。用户的回答会作为工具返回值传回 AI。 + *

+ * 依赖 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 input) { + return "Asking user a question..."; + } + + @Override + @SuppressWarnings("unchecked") + public String execute(Map 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 askUser = (Function) 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) 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(); + } + } +} diff --git a/src/main/java/com/claudecode/tool/impl/WebSearchTool.java b/src/main/java/com/claudecode/tool/impl/WebSearchTool.java new file mode 100644 index 0000000..a71e7cb --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/WebSearchTool.java @@ -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)。 + *

+ * 对应 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 input) { + String query = (String) input.getOrDefault("query", ""); + return "Searching: " + query; + } + + @Override + public String execute(Map 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 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 搜索结果格式: + // Title + // Snippet + // 或者结果块在