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