feat: Phase3 新增5个工具 (ListFiles, WebFetch, TodoWrite, Agent, NotebookEdit)
新增工具: - ListFilesTool: 目录列表,支持递归深度、隐藏文件过滤、文件大小显示 - WebFetchTool: HTTP内容获取,HTML→文本转换,大小限制100KB,超时30s - TodoWriteTool: 待办任务管理(add/update/complete/delete/list),内存存储 - AgentTool: 子Agent调用,通过ToolContext工厂方法创建独立AgentLoop - NotebookEditTool: Jupyter notebook编辑(read/insert/replace/delete/move) 配置更新: - AppConfig注册11个工具(原6+新5) - AgentLoop工厂方法注入ToolContext,支持AgentTool创建子Agent 工具总数: 6 → 11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -43,7 +43,12 @@ public class AppConfig {
|
|||||||
new FileWriteTool(),
|
new FileWriteTool(),
|
||||||
new FileEditTool(),
|
new FileEditTool(),
|
||||||
new GlobTool(),
|
new GlobTool(),
|
||||||
new GrepTool()
|
new GrepTool(),
|
||||||
|
new ListFilesTool(),
|
||||||
|
new WebFetchTool(),
|
||||||
|
new TodoWriteTool(),
|
||||||
|
new AgentTool(),
|
||||||
|
new NotebookEditTool()
|
||||||
);
|
);
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
@@ -76,7 +81,16 @@ public class AppConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry,
|
public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry,
|
||||||
ToolContext toolContext, String systemPrompt) {
|
ToolContext toolContext, String systemPrompt) {
|
||||||
return new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
||||||
|
|
||||||
|
// 注册子 Agent 工厂到 ToolContext,使 AgentTool 能创建独立的 AgentLoop
|
||||||
|
toolContext.set(AgentTool.AGENT_FACTORY_KEY,
|
||||||
|
(java.util.function.Function<String, String>) prompt -> {
|
||||||
|
AgentLoop subLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt);
|
||||||
|
return subLoop.run(prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return mainLoop;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子 Agent 工具 —— 对应 claude-code/src/tools/agent/AgentTool.ts。
|
||||||
|
* <p>
|
||||||
|
* 创建一个独立的子 Agent 来处理复杂的子任务。子 Agent 拥有独立的消息历史,
|
||||||
|
* 但共享工具集和上下文环境。适用于:
|
||||||
|
* <ul>
|
||||||
|
* <li>需要独立上下文的子任务(如分析另一个文件)</li>
|
||||||
|
* <li>并行处理多个任务</li>
|
||||||
|
* <li>隔离风险操作</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 注意:子 Agent 使用主 Agent 的 ChatModel 和工具集,
|
||||||
|
* 通过 ToolContext 中的 "agentLoop.factory" 获取 AgentLoop 工厂方法。
|
||||||
|
*/
|
||||||
|
public class AgentTool implements Tool {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AgentTool.class);
|
||||||
|
|
||||||
|
/** ToolContext 中存储 AgentLoop 工厂的键名 */
|
||||||
|
public static final String AGENT_FACTORY_KEY = "__agent_factory__";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "Agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Launch a sub-agent to handle a complex task independently. \
|
||||||
|
The sub-agent has its own conversation context but shares tools \
|
||||||
|
and environment. Use this for tasks that require focused attention \
|
||||||
|
or when you want to isolate a subtask. \
|
||||||
|
The sub-agent will execute the given prompt and return its final response.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The task description / prompt for the sub-agent"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Additional context or instructions (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["prompt"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String prompt = (String) input.get("prompt");
|
||||||
|
String additionalContext = (String) input.getOrDefault("context", "");
|
||||||
|
|
||||||
|
if (prompt == null || prompt.isBlank()) {
|
||||||
|
return "Error: 'prompt' is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 ToolContext 获取 AgentLoop 工厂方法
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
java.util.function.Function<String, String> agentFactory =
|
||||||
|
context.getOrDefault(AGENT_FACTORY_KEY, null);
|
||||||
|
|
||||||
|
if (agentFactory == null) {
|
||||||
|
log.warn("AgentTool: 未配置 Agent 工厂,无法创建子 Agent");
|
||||||
|
return "Error: Sub-agent capability is not configured. "
|
||||||
|
+ "The Agent tool requires an agent factory to be registered in the ToolContext.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的子 Agent 提示
|
||||||
|
String fullPrompt = buildSubAgentPrompt(prompt, additionalContext);
|
||||||
|
|
||||||
|
log.info("启动子 Agent,任务: {}", truncate(prompt, 80));
|
||||||
|
|
||||||
|
try {
|
||||||
|
String result = agentFactory.apply(fullPrompt);
|
||||||
|
log.info("子 Agent 完成,结果长度: {} chars", result.length());
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("子 Agent 执行失败", e);
|
||||||
|
return "Error: Sub-agent failed: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建子 Agent 的完整提示词
|
||||||
|
*/
|
||||||
|
private String buildSubAgentPrompt(String prompt, String additionalContext) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("You are a sub-agent tasked with a specific job. ");
|
||||||
|
sb.append("Complete the following task thoroughly and return your findings/results:\n\n");
|
||||||
|
sb.append("## Task\n");
|
||||||
|
sb.append(prompt);
|
||||||
|
|
||||||
|
if (additionalContext != null && !additionalContext.isBlank()) {
|
||||||
|
sb.append("\n\n## Additional Context\n");
|
||||||
|
sb.append(additionalContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n\n## Instructions\n");
|
||||||
|
sb.append("- Focus only on the given task\n");
|
||||||
|
sb.append("- Use available tools as needed\n");
|
||||||
|
sb.append("- Provide a clear, concise result\n");
|
||||||
|
sb.append("- If the task cannot be completed, explain why\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text, int maxLen) {
|
||||||
|
if (text.length() <= maxLen) return text;
|
||||||
|
return text.substring(0, maxLen - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
String prompt = (String) input.getOrDefault("prompt", "");
|
||||||
|
if (prompt.length() > 40) {
|
||||||
|
prompt = prompt.substring(0, 37) + "...";
|
||||||
|
}
|
||||||
|
return "🤖 Sub-agent: " + prompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目录列表工具 —— 对应 claude-code/src/tools/ls/LsTool.ts。
|
||||||
|
* <p>
|
||||||
|
* 列出指定目录的文件和子目录,支持递归深度控制。
|
||||||
|
* 类似 Unix 的 ls / Windows 的 dir。
|
||||||
|
*/
|
||||||
|
public class ListFilesTool implements Tool {
|
||||||
|
|
||||||
|
private static final int DEFAULT_DEPTH = 1;
|
||||||
|
private static final int MAX_DEPTH = 5;
|
||||||
|
private static final int MAX_ENTRIES = 500;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "ListFiles";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
List files and directories in a given path. Returns a structured listing \
|
||||||
|
with file types and sizes. Useful for exploring project structure. \
|
||||||
|
By default lists the immediate contents (depth=1).""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory path to list (default: working directory)"
|
||||||
|
},
|
||||||
|
"depth": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Recursion depth (default: 1, max: 5)"
|
||||||
|
},
|
||||||
|
"includeHidden": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to include hidden files/dirs (default: false)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReadOnly() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String pathStr = (String) input.getOrDefault("path", ".");
|
||||||
|
int depth = Math.min(
|
||||||
|
input.containsKey("depth") ? ((Number) input.get("depth")).intValue() : DEFAULT_DEPTH,
|
||||||
|
MAX_DEPTH);
|
||||||
|
boolean includeHidden = Boolean.TRUE.equals(input.get("includeHidden"));
|
||||||
|
|
||||||
|
Path baseDir = context.getWorkDir().resolve(pathStr).normalize();
|
||||||
|
|
||||||
|
if (!Files.isDirectory(baseDir)) {
|
||||||
|
return "Error: Not a directory: " + baseDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<String> entries = new ArrayList<>();
|
||||||
|
listRecursive(baseDir, baseDir, depth, includeHidden, entries);
|
||||||
|
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return "Directory is empty: " + pathStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Directory: ").append(baseDir).append("\n");
|
||||||
|
sb.append("Entries: ").append(entries.size());
|
||||||
|
if (entries.size() >= MAX_ENTRIES) {
|
||||||
|
sb.append(" (truncated)");
|
||||||
|
}
|
||||||
|
sb.append("\n\n");
|
||||||
|
|
||||||
|
for (String entry : entries) {
|
||||||
|
sb.append(entry).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString().stripTrailing();
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error listing directory: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归列出目录内容,带缩进和类型标记
|
||||||
|
*/
|
||||||
|
private void listRecursive(Path baseDir, Path currentDir, int remainingDepth,
|
||||||
|
boolean includeHidden, List<String> entries) throws IOException {
|
||||||
|
if (remainingDepth < 0 || entries.size() >= MAX_ENTRIES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.list(currentDir).sorted()) {
|
||||||
|
List<Path> paths = stream.toList();
|
||||||
|
for (Path path : paths) {
|
||||||
|
if (entries.size() >= MAX_ENTRIES) break;
|
||||||
|
|
||||||
|
String fileName = path.getFileName().toString();
|
||||||
|
|
||||||
|
// 跳过隐藏文件
|
||||||
|
if (!includeHidden && fileName.startsWith(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过常见忽略目录
|
||||||
|
if (Files.isDirectory(path) && isIgnoredDir(fileName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path relative = baseDir.relativize(path);
|
||||||
|
String relStr = relative.toString().replace('\\', '/');
|
||||||
|
boolean isDir = Files.isDirectory(path);
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
entries.add("📁 " + relStr + "/");
|
||||||
|
// 递归进入子目录
|
||||||
|
if (remainingDepth > 1) {
|
||||||
|
listRecursive(baseDir, path, remainingDepth - 1, includeHidden, entries);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
long size = Files.size(path);
|
||||||
|
entries.add("📄 " + relStr + " (" + formatSize(size) + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否为应该忽略的目录 */
|
||||||
|
private boolean isIgnoredDir(String name) {
|
||||||
|
return name.equals("node_modules") || name.equals("target")
|
||||||
|
|| name.equals("build") || name.equals(".git")
|
||||||
|
|| name.equals("__pycache__") || name.equals(".idea")
|
||||||
|
|| name.equals(".vscode");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 友好的文件大小格式化 */
|
||||||
|
private String formatSize(long bytes) {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
|
||||||
|
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
return "📁 Listing " + input.getOrDefault("path", ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jupyter Notebook 编辑工具 —— 对应 claude-code/src/tools/NotebookEditTool.ts。
|
||||||
|
* <p>
|
||||||
|
* 操作 .ipynb 文件中的单元格(cell),支持:
|
||||||
|
* <ul>
|
||||||
|
* <li>插入新单元格(code / markdown)</li>
|
||||||
|
* <li>替换已有单元格内容</li>
|
||||||
|
* <li>删除单元格</li>
|
||||||
|
* <li>移动单元格位置</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* Notebook 格式遵循 nbformat 4.x 规范。
|
||||||
|
*/
|
||||||
|
public class NotebookEditTool implements Tool {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "NotebookEdit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Edit Jupyter Notebook (.ipynb) cells. Supports insert, replace, delete, \
|
||||||
|
and move operations on notebook cells. Works with nbformat 4.x notebooks. \
|
||||||
|
Use this to modify code cells, add markdown documentation, or restructure notebooks.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the .ipynb notebook file"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["insert", "replace", "delete", "move", "read"],
|
||||||
|
"description": "The operation to perform on the notebook"
|
||||||
|
},
|
||||||
|
"cellIndex": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Target cell index (0-based)"
|
||||||
|
},
|
||||||
|
"cellType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["code", "markdown", "raw"],
|
||||||
|
"description": "Type of cell (for insert/replace, default: code)"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cell content/source code (for insert/replace)"
|
||||||
|
},
|
||||||
|
"targetIndex": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Target position for move operation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path", "operation"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String pathStr = (String) input.get("path");
|
||||||
|
String operation = (String) input.get("operation");
|
||||||
|
|
||||||
|
if (pathStr == null || operation == null) {
|
||||||
|
return "Error: 'path' and 'operation' are required";
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filePath = context.getWorkDir().resolve(pathStr).normalize();
|
||||||
|
|
||||||
|
return switch (operation) {
|
||||||
|
case "read" -> readNotebook(filePath);
|
||||||
|
case "insert" -> insertCell(filePath, input);
|
||||||
|
case "replace" -> replaceCell(filePath, input);
|
||||||
|
case "delete" -> deleteCell(filePath, input);
|
||||||
|
case "move" -> moveCell(filePath, input);
|
||||||
|
default -> "Error: Unknown operation '" + operation + "'. Use: read, insert, replace, delete, move";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 notebook 结构概览 */
|
||||||
|
private String readNotebook(Path filePath) {
|
||||||
|
try {
|
||||||
|
JsonNode root = readNotebookJson(filePath);
|
||||||
|
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||||
|
if (cells == null) {
|
||||||
|
return "Error: Invalid notebook format (no 'cells' array)";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("📓 Notebook: ").append(filePath.getFileName()).append("\n");
|
||||||
|
sb.append("Cells: ").append(cells.size()).append("\n\n");
|
||||||
|
|
||||||
|
for (int i = 0; i < cells.size(); i++) {
|
||||||
|
JsonNode cell = cells.get(i);
|
||||||
|
String cellType = cell.has("cell_type") ? cell.get("cell_type").asText() : "unknown";
|
||||||
|
String source = extractSource(cell);
|
||||||
|
|
||||||
|
sb.append("--- Cell ").append(i).append(" [").append(cellType).append("] ---\n");
|
||||||
|
// 截断过长的内容
|
||||||
|
if (source.length() > 200) {
|
||||||
|
sb.append(source, 0, 200).append("...(truncated)\n");
|
||||||
|
} else {
|
||||||
|
sb.append(source).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString().stripTrailing();
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error reading notebook: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在指定位置插入新单元格 */
|
||||||
|
private String insertCell(Path filePath, Map<String, Object> input) {
|
||||||
|
int cellIndex = input.containsKey("cellIndex") ? ((Number) input.get("cellIndex")).intValue() : -1;
|
||||||
|
String cellType = (String) input.getOrDefault("cellType", "code");
|
||||||
|
String source = (String) input.get("source");
|
||||||
|
|
||||||
|
if (source == null) {
|
||||||
|
return "Error: 'source' is required for insert operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||||
|
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||||
|
if (cells == null) {
|
||||||
|
return "Error: Invalid notebook format";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新单元格
|
||||||
|
ObjectNode newCell = createCell(cellType, source);
|
||||||
|
|
||||||
|
// 插入位置:-1 或超出范围则追加到末尾
|
||||||
|
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||||
|
cells.add(newCell);
|
||||||
|
cellIndex = cells.size() - 1;
|
||||||
|
} else {
|
||||||
|
cells.insert(cellIndex, newCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeNotebookJson(filePath, root);
|
||||||
|
|
||||||
|
return "✅ Inserted " + cellType + " cell at index " + cellIndex;
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 替换指定单元格内容 */
|
||||||
|
private String replaceCell(Path filePath, Map<String, Object> input) {
|
||||||
|
if (!input.containsKey("cellIndex")) {
|
||||||
|
return "Error: 'cellIndex' is required for replace operation";
|
||||||
|
}
|
||||||
|
int cellIndex = ((Number) input.get("cellIndex")).intValue();
|
||||||
|
String cellType = (String) input.get("cellType"); // null 表示保持原类型
|
||||||
|
String source = (String) input.get("source");
|
||||||
|
|
||||||
|
if (source == null) {
|
||||||
|
return "Error: 'source' is required for replace operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||||
|
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||||
|
|
||||||
|
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||||
|
return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cellType == null) {
|
||||||
|
cellType = cells.get(cellIndex).get("cell_type").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode newCell = createCell(cellType, source);
|
||||||
|
cells.set(cellIndex, newCell);
|
||||||
|
|
||||||
|
writeNotebookJson(filePath, root);
|
||||||
|
|
||||||
|
return "✅ Replaced cell " + cellIndex + " [" + cellType + "]";
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除指定单元格 */
|
||||||
|
private String deleteCell(Path filePath, Map<String, Object> input) {
|
||||||
|
if (!input.containsKey("cellIndex")) {
|
||||||
|
return "Error: 'cellIndex' is required for delete operation";
|
||||||
|
}
|
||||||
|
int cellIndex = ((Number) input.get("cellIndex")).intValue();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||||
|
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||||
|
|
||||||
|
if (cellIndex < 0 || cellIndex >= cells.size()) {
|
||||||
|
return "Error: Cell index " + cellIndex + " out of range (0-" + (cells.size() - 1) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
String cellType = cells.get(cellIndex).get("cell_type").asText();
|
||||||
|
cells.remove(cellIndex);
|
||||||
|
|
||||||
|
writeNotebookJson(filePath, root);
|
||||||
|
|
||||||
|
return "🗑️ Deleted cell " + cellIndex + " [" + cellType + "]. Remaining: " + cells.size() + " cells";
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移动单元格到新位置 */
|
||||||
|
private String moveCell(Path filePath, Map<String, Object> input) {
|
||||||
|
if (!input.containsKey("cellIndex") || !input.containsKey("targetIndex")) {
|
||||||
|
return "Error: 'cellIndex' and 'targetIndex' are required for move operation";
|
||||||
|
}
|
||||||
|
int fromIndex = ((Number) input.get("cellIndex")).intValue();
|
||||||
|
int toIndex = ((Number) input.get("targetIndex")).intValue();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ObjectNode root = (ObjectNode) readNotebookJson(filePath);
|
||||||
|
ArrayNode cells = (ArrayNode) root.get("cells");
|
||||||
|
int size = cells.size();
|
||||||
|
|
||||||
|
if (fromIndex < 0 || fromIndex >= size) {
|
||||||
|
return "Error: Source index " + fromIndex + " out of range";
|
||||||
|
}
|
||||||
|
if (toIndex < 0 || toIndex >= size) {
|
||||||
|
return "Error: Target index " + toIndex + " out of range";
|
||||||
|
}
|
||||||
|
if (fromIndex == toIndex) {
|
||||||
|
return "Cell is already at index " + toIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取出单元格
|
||||||
|
JsonNode cell = cells.remove(fromIndex);
|
||||||
|
// 插入到新位置
|
||||||
|
cells.insert(toIndex, cell);
|
||||||
|
|
||||||
|
writeNotebookJson(filePath, root);
|
||||||
|
|
||||||
|
return "↕️ Moved cell from " + fromIndex + " to " + toIndex;
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "Error: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 辅助方法 ====================
|
||||||
|
|
||||||
|
/** 读取 notebook JSON */
|
||||||
|
private JsonNode readNotebookJson(Path filePath) throws IOException {
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
throw new IOException("File not found: " + filePath);
|
||||||
|
}
|
||||||
|
String content = Files.readString(filePath, StandardCharsets.UTF_8);
|
||||||
|
return MAPPER.readTree(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入 notebook JSON(保持格式化) */
|
||||||
|
private void writeNotebookJson(Path filePath, JsonNode root) throws IOException {
|
||||||
|
String json = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root);
|
||||||
|
Files.writeString(filePath, json, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 nbformat 4.x 单元格节点 */
|
||||||
|
private ObjectNode createCell(String cellType, String source) {
|
||||||
|
ObjectNode cell = MAPPER.createObjectNode();
|
||||||
|
cell.put("cell_type", cellType);
|
||||||
|
|
||||||
|
// source 是行数组
|
||||||
|
ArrayNode sourceArray = MAPPER.createArrayNode();
|
||||||
|
String[] lines = source.split("\n", -1);
|
||||||
|
for (int i = 0; i < lines.length; i++) {
|
||||||
|
if (i < lines.length - 1) {
|
||||||
|
sourceArray.add(lines[i] + "\n");
|
||||||
|
} else {
|
||||||
|
sourceArray.add(lines[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.set("source", sourceArray);
|
||||||
|
|
||||||
|
// metadata 空对象
|
||||||
|
cell.set("metadata", MAPPER.createObjectNode());
|
||||||
|
|
||||||
|
// code 单元格需要额外字段
|
||||||
|
if ("code".equals(cellType)) {
|
||||||
|
cell.putNull("execution_count");
|
||||||
|
cell.set("outputs", MAPPER.createArrayNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从单元格节点提取 source 文本 */
|
||||||
|
private String extractSource(JsonNode cell) {
|
||||||
|
JsonNode source = cell.get("source");
|
||||||
|
if (source == null) return "";
|
||||||
|
|
||||||
|
if (source.isArray()) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (JsonNode line : source) {
|
||||||
|
sb.append(line.asText());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
return source.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
String op = (String) input.getOrDefault("operation", "editing");
|
||||||
|
String path = (String) input.getOrDefault("path", "notebook");
|
||||||
|
return "📓 Notebook " + op + ": " + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待办任务工具 —— 对应 claude-code/src/tools/TodoWriteTool。
|
||||||
|
* <p>
|
||||||
|
* 管理 AI 工作过程中的待办事项列表,支持创建、更新、完成和删除任务。
|
||||||
|
* 任务存储在内存中(ToolContext 的共享状态中),生命周期与会话一致。
|
||||||
|
*/
|
||||||
|
public class TodoWriteTool implements Tool {
|
||||||
|
|
||||||
|
private static final String TODOS_KEY = "__todos__";
|
||||||
|
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "TodoWrite";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Manage a todo list for tracking tasks during the conversation. \
|
||||||
|
Supports operations: add, update, complete, delete, list. \
|
||||||
|
Use this to track multi-step tasks, record progress, and organize work.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"operation": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["add", "update", "complete", "delete", "list"],
|
||||||
|
"description": "The operation to perform"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task ID (required for update/complete/delete)"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task title (required for add)"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task description (optional)"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pending", "in_progress", "done", "blocked"],
|
||||||
|
"description": "Task status (for update)"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["high", "medium", "low"],
|
||||||
|
"description": "Task priority (default: medium)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["operation"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String operation = (String) input.get("operation");
|
||||||
|
if (operation == null) {
|
||||||
|
return "Error: 'operation' is required";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 ToolContext 获取或初始化 todo 列表
|
||||||
|
Map<String, TodoItem> todos = context.getOrDefault(TODOS_KEY, null);
|
||||||
|
if (todos == null) {
|
||||||
|
todos = new ConcurrentHashMap<>();
|
||||||
|
context.set(TODOS_KEY, todos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (operation) {
|
||||||
|
case "add" -> addTodo(input, todos);
|
||||||
|
case "update" -> updateTodo(input, todos);
|
||||||
|
case "complete" -> completeTodo(input, todos);
|
||||||
|
case "delete" -> deleteTodo(input, todos);
|
||||||
|
case "list" -> listTodos(todos);
|
||||||
|
default -> "Error: Unknown operation '" + operation + "'. Use: add, update, complete, delete, list";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String addTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||||
|
String title = (String) input.get("title");
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
return "Error: 'title' is required for add operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
String id = generateId();
|
||||||
|
String description = (String) input.getOrDefault("description", "");
|
||||||
|
String priority = (String) input.getOrDefault("priority", "medium");
|
||||||
|
|
||||||
|
TodoItem item = new TodoItem(id, title, description, "pending", priority, LocalDateTime.now());
|
||||||
|
todos.put(id, item);
|
||||||
|
|
||||||
|
return "✅ Task added:\n" + formatItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String updateTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||||
|
String id = (String) input.get("id");
|
||||||
|
if (id == null) {
|
||||||
|
return "Error: 'id' is required for update operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
TodoItem item = todos.get(id);
|
||||||
|
if (item == null) {
|
||||||
|
return "Error: Task not found: " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
String title = (String) input.getOrDefault("title", item.title());
|
||||||
|
String description = (String) input.getOrDefault("description", item.description());
|
||||||
|
String status = (String) input.getOrDefault("status", item.status());
|
||||||
|
String priority = (String) input.getOrDefault("priority", item.priority());
|
||||||
|
|
||||||
|
TodoItem updated = new TodoItem(id, title, description, status, priority, item.createdAt());
|
||||||
|
todos.put(id, updated);
|
||||||
|
|
||||||
|
return "✏️ Task updated:\n" + formatItem(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String completeTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||||
|
String id = (String) input.get("id");
|
||||||
|
if (id == null) {
|
||||||
|
return "Error: 'id' is required for complete operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
TodoItem item = todos.get(id);
|
||||||
|
if (item == null) {
|
||||||
|
return "Error: Task not found: " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
TodoItem completed = new TodoItem(id, item.title(), item.description(), "done", item.priority(), item.createdAt());
|
||||||
|
todos.put(id, completed);
|
||||||
|
|
||||||
|
return "✅ Task completed: " + item.title();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deleteTodo(Map<String, Object> input, Map<String, TodoItem> todos) {
|
||||||
|
String id = (String) input.get("id");
|
||||||
|
if (id == null) {
|
||||||
|
return "Error: 'id' is required for delete operation";
|
||||||
|
}
|
||||||
|
|
||||||
|
TodoItem removed = todos.remove(id);
|
||||||
|
if (removed == null) {
|
||||||
|
return "Error: Task not found: " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "🗑️ Task deleted: " + removed.title();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String listTodos(Map<String, TodoItem> todos) {
|
||||||
|
if (todos.isEmpty()) {
|
||||||
|
return "📋 No tasks. Use 'add' operation to create one.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按状态分组,优先级排序
|
||||||
|
Map<String, List<TodoItem>> byStatus = todos.values().stream()
|
||||||
|
.sorted(Comparator.comparingInt(this::priorityOrder))
|
||||||
|
.collect(Collectors.groupingBy(TodoItem::status, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("📋 Task List (").append(todos.size()).append(" tasks)\n");
|
||||||
|
sb.append("━".repeat(40)).append("\n");
|
||||||
|
|
||||||
|
for (Map.Entry<String, List<TodoItem>> entry : byStatus.entrySet()) {
|
||||||
|
String statusIcon = statusIcon(entry.getKey());
|
||||||
|
sb.append("\n").append(statusIcon).append(" ").append(entry.getKey().toUpperCase()).append(":\n");
|
||||||
|
for (TodoItem item : entry.getValue()) {
|
||||||
|
sb.append(formatItem(item)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString().stripTrailing();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatItem(TodoItem item) {
|
||||||
|
String priorityIcon = switch (item.priority()) {
|
||||||
|
case "high" -> "🔴";
|
||||||
|
case "medium" -> "🟡";
|
||||||
|
case "low" -> "🟢";
|
||||||
|
default -> "⚪";
|
||||||
|
};
|
||||||
|
return String.format(" %s [%s] %s - %s (%s)",
|
||||||
|
priorityIcon, item.id(), item.title(),
|
||||||
|
item.description().isEmpty() ? "(no description)" : item.description(),
|
||||||
|
item.createdAt().format(FMT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int priorityOrder(TodoItem item) {
|
||||||
|
return switch (item.priority()) {
|
||||||
|
case "high" -> 0;
|
||||||
|
case "medium" -> 1;
|
||||||
|
case "low" -> 2;
|
||||||
|
default -> 3;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String statusIcon(String status) {
|
||||||
|
return switch (status) {
|
||||||
|
case "pending" -> "⏳";
|
||||||
|
case "in_progress" -> "🔄";
|
||||||
|
case "done" -> "✅";
|
||||||
|
case "blocked" -> "🚫";
|
||||||
|
default -> "❓";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成短 ID(4 位十六进制) */
|
||||||
|
private String generateId() {
|
||||||
|
return UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不可变任务数据记录 */
|
||||||
|
record TodoItem(String id, String title, String description, String status,
|
||||||
|
String priority, LocalDateTime createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
String op = (String) input.getOrDefault("operation", "managing");
|
||||||
|
return "📋 Todo: " + op;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.claudecode.tool.impl;
|
||||||
|
|
||||||
|
import com.claudecode.tool.Tool;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网页获取工具 —— 对应 claude-code/src/tools/WebFetchTool。
|
||||||
|
* <p>
|
||||||
|
* 使用 HTTP GET 获取指定 URL 的内容,自动将 HTML 简化为纯文本。
|
||||||
|
* 支持大小限制、超时控制和基本的 HTML→文本转换。
|
||||||
|
*/
|
||||||
|
public class WebFetchTool implements Tool {
|
||||||
|
|
||||||
|
/** 最大响应体大小:100KB */
|
||||||
|
private static final int MAX_BODY_SIZE = 100 * 1024;
|
||||||
|
|
||||||
|
/** HTTP 请求超时 */
|
||||||
|
private static final Duration TIMEOUT = Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
/** User-Agent 标识 */
|
||||||
|
private static final String USER_AGENT = "ClaudeCode-Java/0.1 (WebFetchTool)";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "WebFetch";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return """
|
||||||
|
Fetch the content of a URL. Returns the page content as text. \
|
||||||
|
HTML pages are automatically simplified to readable text. \
|
||||||
|
Useful for reading documentation, API responses, or web pages. \
|
||||||
|
Has a 100KB size limit and 30s timeout.""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inputSchema() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL to fetch (must start with http:// or https://)"
|
||||||
|
},
|
||||||
|
"maxLength": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of characters to return (default: 50000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
}""";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReadOnly() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(Map<String, Object> input, ToolContext context) {
|
||||||
|
String url = (String) input.get("url");
|
||||||
|
int maxLength = input.containsKey("maxLength")
|
||||||
|
? ((Number) input.get("maxLength")).intValue()
|
||||||
|
: 50000;
|
||||||
|
|
||||||
|
// URL 校验
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
return "Error: URL is required";
|
||||||
|
}
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
return "Error: URL must start with http:// or https://";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(url);
|
||||||
|
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(TIMEOUT)
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(uri)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/json,text/plain,*/*")
|
||||||
|
.timeout(TIMEOUT)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
int statusCode = response.statusCode();
|
||||||
|
String body = response.body();
|
||||||
|
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
return "Error: HTTP " + statusCode + "\n" + truncate(body, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查大小限制
|
||||||
|
if (body.length() > MAX_BODY_SIZE) {
|
||||||
|
body = body.substring(0, MAX_BODY_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据内容类型处理
|
||||||
|
String contentType = response.headers().firstValue("Content-Type").orElse("text/plain");
|
||||||
|
|
||||||
|
String result;
|
||||||
|
if (contentType.contains("text/html") || contentType.contains("application/xhtml")) {
|
||||||
|
result = htmlToText(body);
|
||||||
|
} else {
|
||||||
|
result = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断到最大长度
|
||||||
|
result = truncate(result, maxLength);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("URL: ").append(url).append("\n");
|
||||||
|
sb.append("Status: ").append(statusCode).append("\n");
|
||||||
|
sb.append("Content-Type: ").append(contentType).append("\n");
|
||||||
|
sb.append("---\n");
|
||||||
|
sb.append(result);
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return "Error: Invalid URL: " + e.getMessage();
|
||||||
|
} catch (java.net.http.HttpTimeoutException e) {
|
||||||
|
return "Error: Request timed out after " + TIMEOUT.toSeconds() + " seconds";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "Error fetching URL: " + e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的 HTML → 纯文本转换。
|
||||||
|
* 移除脚本/样式块,转换常见标签为文本格式。
|
||||||
|
*/
|
||||||
|
private String htmlToText(String html) {
|
||||||
|
// 移除 script 和 style 块
|
||||||
|
String text = html.replaceAll("(?is)<script[^>]*>.*?</script>", "");
|
||||||
|
text = text.replaceAll("(?is)<style[^>]*>.*?</style>", "");
|
||||||
|
|
||||||
|
// 移除 HTML 注释
|
||||||
|
text = text.replaceAll("(?s)<!--.*?-->", "");
|
||||||
|
|
||||||
|
// 将块级元素转为换行
|
||||||
|
text = text.replaceAll("(?i)<br\\s*/?>", "\n");
|
||||||
|
text = text.replaceAll("(?i)</(p|div|h[1-6]|li|tr|blockquote|pre)>", "\n");
|
||||||
|
text = text.replaceAll("(?i)<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>", "\n");
|
||||||
|
|
||||||
|
// 将链接转为 [text](url) 格式
|
||||||
|
Pattern linkPattern = Pattern.compile("<a[^>]*href=[\"']([^\"']*)[\"'][^>]*>(.*?)</a>", Pattern.CASE_INSENSITIVE);
|
||||||
|
Matcher linkMatcher = linkPattern.matcher(text);
|
||||||
|
text = linkMatcher.replaceAll("[$2]($1)");
|
||||||
|
|
||||||
|
// 移除所有剩余 HTML 标签
|
||||||
|
text = text.replaceAll("<[^>]+>", "");
|
||||||
|
|
||||||
|
// 解码常见 HTML 实体
|
||||||
|
text = text.replace("&", "&");
|
||||||
|
text = text.replace("<", "<");
|
||||||
|
text = text.replace(">", ">");
|
||||||
|
text = text.replace(""", "\"");
|
||||||
|
text = text.replace("'", "'");
|
||||||
|
text = text.replace(" ", " ");
|
||||||
|
// 数字实体
|
||||||
|
java.util.regex.Pattern numEntity = java.util.regex.Pattern.compile("&#(\\d+);");
|
||||||
|
java.util.regex.Matcher numMatcher = numEntity.matcher(text);
|
||||||
|
text = numMatcher.replaceAll(mr -> {
|
||||||
|
try {
|
||||||
|
return String.valueOf((char) Integer.parseInt(mr.group(1)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return mr.group();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 压缩多余空行(3个以上连续空行压缩为2个)
|
||||||
|
text = text.replaceAll("\\n{3,}", "\n\n");
|
||||||
|
// 压缩行内多余空格
|
||||||
|
text = text.replaceAll("[ \\t]+", " ");
|
||||||
|
|
||||||
|
return text.strip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 截断文本到指定长度 */
|
||||||
|
private String truncate(String text, int maxLength) {
|
||||||
|
if (text.length() <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength) + "\n...[truncated at " + maxLength + " chars]";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String activityDescription(Map<String, Object> input) {
|
||||||
|
String url = (String) input.getOrDefault("url", "");
|
||||||
|
// 截断过长的 URL
|
||||||
|
if (url.length() > 50) {
|
||||||
|
url = url.substring(0, 47) + "...";
|
||||||
|
}
|
||||||
|
return "🌐 Fetching " + url;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user