新增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>pull/1/head
parent
7dc4d142b3
commit
82a28b7aa7
@ -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,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(); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue