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 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;
|
||||
|
||||
@@ -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;
|
||||
* <ol>
|
||||
* <li>构建 Prompt(消息历史 + 系统提示 + 工具定义)</li>
|
||||
* <li>调用 ChatModel.call() 或 ChatModel.stream()</li>
|
||||
* <li>检查工具调用 → 执行工具 → 结果回传</li>
|
||||
* <li>检查工具调用 → 权限确认 → 执行工具 → 结果回传</li>
|
||||
* <li>循环直到无工具调用或达到最大迭代</li>
|
||||
* </ol>
|
||||
*/
|
||||
@@ -62,6 +63,12 @@ public class AgentLoop {
|
||||
/** 流式输出开始回调:通知 UI 停止 spinner */
|
||||
private Runnable onStreamStart;
|
||||
|
||||
/** 权限确认回调:危险操作前请求用户确认(返回 true 表示允许) */
|
||||
private Function<PermissionRequest, Boolean> onPermissionRequest;
|
||||
|
||||
/** Thinking 内容回调:显示 AI 的思考过程 */
|
||||
private Consumer<String> 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<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();
|
||||
}
|
||||
|
||||
// 尝试提取 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<AssistantMessage.ToolCall> toolCalls,
|
||||
List<ToolCallback> callbacks) {
|
||||
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>();
|
||||
@@ -267,7 +286,27 @@ public class AgentLoop {
|
||||
String result;
|
||||
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName);
|
||||
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);
|
||||
} 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 内容。
|
||||
* <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 展示 */
|
||||
public record ToolEvent(String toolName, Phase phase, String arguments, String result) {
|
||||
public enum Phase { START, END }
|
||||
|
||||
@@ -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<String, String>) 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.1 基础功能
|
||||
- [ ] 应用可正常启动并显示 Banner
|
||||
- [ ] 支持通过环境变量或配置文件设置 API Key 和模型
|
||||
- [ ] REPL 循环正常工作:输入问题 → 获得 AI 回复
|
||||
- [ ] 工具调用循环正常:AI 可以调用工具并获取结果
|
||||
- [ ] 至少支持 8 个核心工具(Bash、FileRead、FileWrite、FileEdit、Glob、Grep、WebFetch、TodoWrite)
|
||||
- [ ] 至少支持 8 个斜杠命令(/help、/clear、/compact、/exit、/cost、/model、/memory、/context)
|
||||
- [x] 应用可正常启动并显示 Banner
|
||||
- [x] 支持通过环境变量或配置文件设置 API Key 和模型
|
||||
- [x] REPL 循环正常工作:输入问题 → 获得 AI 回复
|
||||
- [x] 工具调用循环正常:AI 可以调用工具并获取结果
|
||||
- [x] 至少支持 8 个核心工具(实际 11 个)
|
||||
- [x] 至少支持 8 个斜杠命令(实际 11 个)
|
||||
|
||||
### 4.2 交互体验
|
||||
- [ ] Banner 显示正常(Logo + 版本 + 模型信息)
|
||||
- [ ] 输入提示符支持行编辑和历史记录
|
||||
- [ ] Thinking 过程可见
|
||||
- [ ] 工具调用时显示 Spinner 动画和状态
|
||||
- [ ] 支持 ANSI 颜色输出
|
||||
- [ ] Markdown 内容基本格式化
|
||||
- [x] Banner 显示正常(Logo + 版本 + 模型信息 + Provider + URL)
|
||||
- [x] 输入提示符支持行编辑和历史记录
|
||||
- [x] Thinking 过程可见(Spinner 动画 + "Thinking..." 提示)
|
||||
- [x] 工具调用时显示 Spinner 动画和状态
|
||||
- [x] 支持 ANSI 颜色输出
|
||||
- [x] Markdown 内容基本格式化
|
||||
|
||||
### 4.3 上下文功能
|
||||
- [ ] 能加载项目 CLAUDE.md
|
||||
- [ ] 能加载用户级 CLAUDE.md
|
||||
- [ ] Git 上下文信息正确收集
|
||||
- [ ] 系统提示词包含所有必要信息
|
||||
- [x] 能加载项目 CLAUDE.md
|
||||
- [x] 能加载用户级 CLAUDE.md
|
||||
- [x] Git 上下文信息正确收集
|
||||
- [x] 系统提示词包含所有必要信息
|
||||
|
||||
### 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