v3.0.0 init

This commit is contained in:
ageerle
2026-02-06 03:00:23 +08:00
parent eb2e8f3ff8
commit 7b8cfe02a1
1524 changed files with 53132 additions and 58866 deletions

View File

@@ -1,7 +1,7 @@
package com.example.demo;
import com.example.demo.config.AppProperties;
import com.example.demo.util.BrowserUtil;
import com.example.demo.utils.BrowserUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -9,7 +9,6 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
@@ -21,7 +20,6 @@ import org.springframework.core.env.Environment;
*/
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
@EnableAspectJAutoProxy
public class CopilotApplication {
private static final Logger logger = LoggerFactory.getLogger(CopilotApplication.class);
@@ -33,7 +31,7 @@ public class CopilotApplication {
private Environment environment;
public static void main(String[] args) {
SpringApplication.run(CopilotApplication.class, args);
SpringApplication.run(CopilotApplication.class, args);
}
/**

View File

@@ -1,104 +0,0 @@
package com.example.demo.config;
import com.example.demo.service.ToolExecutionLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 自定义工具执行监听器
* 提供中文日志和详细的文件操作信息记录
* <p>
* 注意Spring AI 1.0.0使用@Tool注解来定义工具不需要ToolCallbackProvider接口
* 这个类主要用于工具执行的日志记录和监控
*/
@Component
public class CustomToolExecutionMonitor {
private static final Logger logger = LoggerFactory.getLogger(CustomToolExecutionMonitor.class);
@Autowired
private ToolExecutionLogger executionLogger;
/**
* 记录工具执行开始
*/
public long logToolStart(String toolName, String description, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
long callId = executionLogger.logToolStart(toolName, description,
String.format("参数: %s | 文件信息: %s", parameters, fileInfo));
logger.debug("🚀 [Spring AI] 开始执行工具: {} | 文件/目录: {}", toolName, fileInfo);
return callId;
}
/**
* 记录工具执行成功
*/
public void logToolSuccess(long callId, String toolName, String result, long executionTime, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
logger.debug("✅ [Spring AI] 工具执行成功: {} | 耗时: {}ms | 文件/目录: {}",
toolName, executionTime, fileInfo);
executionLogger.logToolSuccess(callId, toolName, result, executionTime);
}
/**
* 记录工具执行失败
*/
public void logToolError(long callId, String toolName, String errorMessage, long executionTime, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
logger.error("❌ [Spring AI] 工具执行失败: {} | 耗时: {}ms | 文件/目录: {} | 错误: {}",
toolName, executionTime, fileInfo, errorMessage);
executionLogger.logToolError(callId, toolName, errorMessage, executionTime);
}
/**
* 提取文件信息用于日志记录
*/
private String extractFileInfo(String toolName, String arguments) {
try {
switch (toolName) {
case "readFile":
case "read_file":
return extractPathFromArgs(arguments, "absolutePath", "filePath");
case "writeFile":
case "write_file":
return extractPathFromArgs(arguments, "filePath");
case "editFile":
case "edit_file":
return extractPathFromArgs(arguments, "filePath");
case "listDirectory":
return extractPathFromArgs(arguments, "directoryPath", "path");
case "analyzeProject":
case "analyze_project":
return extractPathFromArgs(arguments, "projectPath");
case "scaffoldProject":
case "scaffold_project":
return extractPathFromArgs(arguments, "projectPath");
case "smartEdit":
case "smart_edit":
return extractPathFromArgs(arguments, "projectPath");
default:
return "未知文件路径";
}
} catch (Exception e) {
return "解析文件路径失败: " + e.getMessage();
}
}
/**
* 从参数中提取路径
*/
private String extractPathFromArgs(String arguments, String... pathKeys) {
for (String key : pathKeys) {
String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]+)\"";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(arguments);
if (m.find()) {
return m.group(1);
}
}
return "未找到路径参数";
}
}

View File

@@ -1,12 +1,14 @@
package com.example.demo.config;
import com.example.demo.schema.SchemaValidator;
import com.example.demo.tools.*;
import org.springaicommunity.agent.tools.FileSystemTools;
import org.springaicommunity.agent.tools.ShellTools;
import org.springaicommunity.agent.tools.SkillsTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import java.util.List;
@@ -16,80 +18,22 @@ import java.util.List;
@Configuration
public class SpringAIConfiguration {
@Value("${agent.skills.dirs:Unknown}") List<Resource> agentSkillsDirs;
@Bean
public ChatClient chatClient(ChatModel chatModel,
FileOperationTools fileOperationTools,
SmartEditTool smartEditTool,
AnalyzeProjectTool analyzeProjectTool,
ProjectScaffoldTool projectScaffoldTool,
AppProperties appProperties) {
public ChatClient chatClient(ChatModel chatModel, AppProperties appProperties) {
// 动态获取工作目录路径
String workspaceDir = appProperties.getWorkspace().getRootDirectory();
ChatClient.Builder chatClientBuilder = ChatClient.builder(chatModel);
return ChatClient.builder(chatModel)
.defaultSystem("""
You are an expert software development assistant with access to file system tools.
You excel at creating complete, well-structured projects through systematic execution of multiple related tasks.
# CORE BEHAVIOR:
- When given a complex task (like "create a web project"), break it down into ALL necessary steps
- Execute MULTIPLE tool calls in sequence to complete the entire task
- Don't stop after just one file - create the complete project structure
- Always verify your work by reading files after creating them
- Continue working until the ENTIRE task is complete
# TASK EXECUTION STRATEGY:
1. **Plan First**: Mentally outline all files and directories needed
2. **Execute Systematically**: Use tools in logical sequence to build the complete solution
3. **Verify Progress**: Read files after creation to ensure correctness
4. **Continue Until Complete**: Don't stop until the entire requested project/task is finished
5. **Signal Continuation**: Use phrases like "Next, I will...", "Now I'll...", "Let me..." to indicate ongoing work
# AVAILABLE TOOLS:
- readFile: Read file contents (supports pagination)
- writeFile: Create or overwrite files
- editFile: Edit files by replacing specific text
- listDirectory: List directory contents (supports recursive)
- analyzeProject: Analyze existing projects to understand structure and dependencies
- smartEdit: Intelligently edit projects based on natural language descriptions
- scaffoldProject: Create new projects with standard structure and templates
# CRITICAL RULES:
- ALWAYS use absolute paths starting with the workspace directory: """ + workspaceDir + """
- Use proper path separators for the current operating system
- For complex requests, execute 5-15 tool calls to create a complete solution
- Use continuation phrases to signal you have more work to do
- If creating a project, make it production-ready with proper structure
- Continue working until you've delivered a complete, functional result
- Only say "completed" or "finished" when the ENTIRE task is truly done
- The tools will show both full paths and relative paths - this helps users locate files
- Always mention the full path when describing what you've created
# PATH EXAMPLES:
- Correct absolute path format:+ workspaceDir + + file separator + filename
- Always ensure paths are within the workspace directory
- Use the system's native path separators
# CONTINUATION SIGNALS:
Use these phrases when you have more work to do:
- "Next, I will create..."
- "Now I'll add..."
- "Let me now..."
- "Moving on to..."
- "I'll proceed to..."
Remember: Your goal is to deliver COMPLETE solutions through continuous execution!
""")
.defaultTools(fileOperationTools, smartEditTool, analyzeProjectTool, projectScaffoldTool)
return chatClientBuilder
.defaultSystem("Always use the available skills to assist the user in their requests.")
// Skills tool callbacks
.defaultToolCallbacks(SkillsTool.builder().addSkillsResources(agentSkillsDirs).build())
// Built-in tools
.defaultTools(
// FileSystemTools.builder().build(),
ShellTools.builder().build()
)
.build();
}
/**
* 为所有工具注入Schema验证器
*/
@Autowired
public void configureTools(List<BaseTool<?>> tools, SchemaValidator schemaValidator) {
tools.forEach(tool -> tool.setSchemaValidator(schemaValidator));
}
}

View File

@@ -1,313 +0,0 @@
package com.example.demo.config;
import com.example.demo.service.LogStreamService;
import com.example.demo.service.ToolExecutionLogger;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 工具调用日志切面
* 拦截 Spring AI 的工具调用并提供中文日志
*/
@Aspect
@Component
public class ToolCallLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(ToolCallLoggingAspect.class);
@Autowired
private ToolExecutionLogger executionLogger;
@Autowired
private LogStreamService logStreamService;
/**
* 拦截使用@Tool注解的方法执行
*/
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object interceptToolAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getSignature().getDeclaringType().getSimpleName();
// 详细的参数信息
String parametersInfo = formatMethodParameters(args);
String fileInfo = extractFileInfoFromMethodArgs(methodName, args);
logger.debug("🚀 [Spring AI @Tool] 执行工具: {}.{} | 参数: {} | 文件/目录: {}",
className, methodName, parametersInfo, fileInfo);
// 获取当前任务ID (从线程本地变量或其他方式)
String taskId = getCurrentTaskId();
// 推送工具开始执行事件
if (taskId != null) {
String startMessage = generateStartMessage(methodName, fileInfo);
logStreamService.pushToolStart(taskId, methodName, fileInfo, startMessage);
}
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
logger.debug("✅ [Spring AI @Tool] 工具执行成功: {}.{} | 耗时: {}ms | 文件/目录: {} | 参数: {}",
className, methodName, executionTime, fileInfo, parametersInfo);
// 推送工具执行成功事件
if (taskId != null) {
String successMessage = generateSuccessMessage(methodName, fileInfo, result, executionTime);
logStreamService.pushToolSuccess(taskId, methodName, fileInfo, successMessage, executionTime);
}
return result;
} catch (Throwable e) {
long executionTime = System.currentTimeMillis() - startTime;
logger.error("❌ [Spring AI @Tool] 工具执行失败: {}.{} | 耗时: {}ms | 文件/目录: {} | 参数: {} | 错误: {}",
className, methodName, executionTime, fileInfo, parametersInfo, e.getMessage());
// 推送工具执行失败事件
if (taskId != null) {
String errorMessage = generateErrorMessage(methodName, fileInfo, e.getMessage());
logStreamService.pushToolError(taskId, methodName, fileInfo, errorMessage, executionTime);
}
throw e;
}
}
/**
* 格式化方法参数为可读字符串
*/
private String formatMethodParameters(Object[] args) {
if (args == null || args.length == 0) {
return "无参数";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < args.length; i++) {
if (i > 0) sb.append(", ");
Object arg = args[i];
if (arg == null) {
sb.append("null");
} else if (arg instanceof String) {
String str = (String) arg;
// 如果字符串太长,截断显示
if (str.length() > 100) {
sb.append("\"").append(str.substring(0, 100)).append("...\"");
} else {
sb.append("\"").append(str).append("\"");
}
} else {
sb.append(arg.toString());
}
}
return sb.toString();
}
/**
* 从方法参数中直接提取文件信息
*/
private String extractFileInfoFromMethodArgs(String methodName, Object[] args) {
if (args == null || args.length == 0) {
return "无参数";
}
try {
switch (methodName) {
case "readFile":
// readFile(String absolutePath, Integer offset, Integer limit)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "writeFile":
// writeFile(String filePath, String content)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "editFile":
// editFile(String filePath, String oldText, String newText)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "listDirectory":
// listDirectory(String directoryPath, Boolean recursive)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "analyzeProject":
// analyzeProject(String projectPath, ...)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "scaffoldProject":
// scaffoldProject(String projectName, String projectType, String projectPath, ...)
return args.length > 2 && args[2] != null ? args[2].toString() : "未指定路径";
case "smartEdit":
// smartEdit(String projectPath, ...)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
default:
// 对于未知方法,尝试从第一个参数中提取路径
if (args.length > 0 && args[0] != null) {
String firstArg = args[0].toString();
if (firstArg.contains("/") || firstArg.contains("\\")) {
return firstArg;
}
}
return "未识别的工具类型";
}
} catch (Exception e) {
return "解析参数失败: " + e.getMessage();
}
}
/**
* 从参数字符串中提取文件信息(备用方法)
*/
private String extractFileInfoFromArgs(String toolName, String arguments) {
try {
switch (toolName) {
case "readFile":
case "read_file":
return extractPathFromString(arguments, "absolutePath", "filePath");
case "writeFile":
case "write_file":
case "editFile":
case "edit_file":
return extractPathFromString(arguments, "filePath");
case "listDirectory":
return extractPathFromString(arguments, "directoryPath", "path");
case "analyzeProject":
case "analyze_project":
case "scaffoldProject":
case "scaffold_project":
case "smartEdit":
case "smart_edit":
return extractPathFromString(arguments, "projectPath");
default:
return "未指定文件路径";
}
} catch (Exception e) {
return "解析文件路径失败";
}
}
/**
* 从字符串中提取路径
*/
private String extractPathFromString(String text, String... pathKeys) {
for (String key : pathKeys) {
// JSON 格式
Pattern jsonPattern = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]+)\"");
Matcher jsonMatcher = jsonPattern.matcher(text);
if (jsonMatcher.find()) {
return jsonMatcher.group(1);
}
// 键值对格式
Pattern kvPattern = Pattern.compile(key + "=([^,\\s\\]]+)");
Matcher kvMatcher = kvPattern.matcher(text);
if (kvMatcher.find()) {
return kvMatcher.group(1);
}
}
return "未找到路径";
}
/**
* 获取当前任务ID
* 从线程本地变量或请求上下文中获取
*/
private String getCurrentTaskId() {
// 这里需要从某个地方获取当前任务ID
// 可以从ThreadLocal、RequestAttributes或其他方式获取
try {
// 临时实现:从线程名或其他方式获取
return TaskContextHolder.getCurrentTaskId();
} catch (Exception e) {
logger.debug("无法获取当前任务ID: {}", e.getMessage());
return null;
}
}
/**
* 生成工具开始执行消息
*/
private String generateStartMessage(String toolName, String fileInfo) {
switch (toolName) {
case "readFile":
return "正在读取文件: " + getFileName(fileInfo);
case "writeFile":
return "正在写入文件: " + getFileName(fileInfo);
case "editFile":
return "正在编辑文件: " + getFileName(fileInfo);
case "listDirectory":
return "正在列出目录: " + fileInfo;
case "analyzeProject":
return "正在分析项目: " + fileInfo;
case "scaffoldProject":
return "正在创建项目脚手架: " + fileInfo;
case "smartEdit":
return "正在智能编辑项目: " + fileInfo;
default:
return "正在执行工具: " + toolName;
}
}
/**
* 生成工具执行成功消息
*/
private String generateSuccessMessage(String toolName, String fileInfo, Object result, long executionTime) {
String fileName = getFileName(fileInfo);
switch (toolName) {
case "readFile":
return String.format("已读取文件 %s (耗时 %dms)", fileName, executionTime);
case "writeFile":
return String.format("已写入文件 %s (耗时 %dms)", fileName, executionTime);
case "editFile":
return String.format("已编辑文件 %s (耗时 %dms)", fileName, executionTime);
case "listDirectory":
return String.format("已列出目录 %s (耗时 %dms)", fileInfo, executionTime);
case "analyzeProject":
return String.format("已分析项目 %s (耗时 %dms)", fileInfo, executionTime);
case "scaffoldProject":
return String.format("已创建项目脚手架 %s (耗时 %dms)", fileInfo, executionTime);
case "smartEdit":
return String.format("已智能编辑项目 %s (耗时 %dms)", fileInfo, executionTime);
default:
return String.format("工具 %s 执行成功 (耗时 %dms)", toolName, executionTime);
}
}
/**
* 生成工具执行失败消息
*/
private String generateErrorMessage(String toolName, String fileInfo, String errorMsg) {
String fileName = getFileName(fileInfo);
return String.format("工具 %s 执行失败: %s (文件: %s)", toolName, errorMsg, fileName);
}
/**
* 从文件路径中提取文件名
*/
private String getFileName(String filePath) {
if (filePath == null || filePath.isEmpty()) {
return "未知文件";
}
// 处理Windows和Unix路径
int lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
if (lastSlash >= 0 && lastSlash < filePath.length() - 1) {
return filePath.substring(lastSlash + 1);
}
return filePath;
}
}

View File

@@ -16,7 +16,6 @@ import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 聊天控制器
@@ -43,84 +42,11 @@ public class ChatController {
}
/**
* 发送消息给AI - 支持连续工具调用
* 流式聊天 - 直接返回流式数据
*/
// 在现有ChatController中修改sendMessage方法
@PostMapping("/message")
public Mono<ChatResponseDto> sendMessage(@RequestBody ChatRequestDto request) {
return Mono.fromCallable(() -> {
try {
logger.info("💬 ========== 新的聊天请求 ==========");
logger.info("📝 用户消息: {}", request.getMessage());
logger.info("🕐 请求时间: {}", java.time.LocalDateTime.now());
// 智能判断是否需要工具调用
boolean needsToolExecution = continuousConversationService.isLikelyToNeedTools(request.getMessage());
logger.info("🔍 工具需求分析: {}", needsToolExecution ? "可能需要工具" : "简单对话");
if (needsToolExecution) {
// 需要工具调用的复杂任务 - 使用异步模式
String taskId = continuousConversationService.startTask(request.getMessage());
logger.info("🆔 任务ID: {}", taskId);
// 记录任务开始
executionLogger.logToolStatistics(); // 显示当前统计
// 异步执行连续对话
CompletableFuture.runAsync(() -> {
try {
logger.info("🚀 开始异步执行连续对话任务: {}", taskId);
continuousConversationService.executeContinuousConversation(
taskId, request.getMessage(), conversationHistory
);
logger.info("✅ 连续对话任务完成: {}", taskId);
} catch (Exception e) {
logger.error("❌ 异步对话执行错误: {}", e.getMessage(), e);
}
});
// 返回异步任务响应
ChatResponseDto responseDto = new ChatResponseDto();
responseDto.setTaskId(taskId);
responseDto.setMessage("任务已启动,正在处理中...");
responseDto.setSuccess(true);
responseDto.setAsyncTask(true);
logger.info("📤 返回响应: taskId={}, 异步任务已启动", taskId);
return responseDto;
} else {
// 简单对话 - 使用流式模式
logger.info("🔄 执行流式对话处理");
// 返回流式响应标识,让前端建立流式连接
ChatResponseDto responseDto = new ChatResponseDto();
responseDto.setMessage("开始流式对话...");
responseDto.setSuccess(true);
responseDto.setAsyncTask(false); // 关键设置为false表示不是工具任务
responseDto.setStreamResponse(true); // 新增:标识为流式响应
responseDto.setTotalTurns(1);
logger.info("📤 返回流式响应标识");
return responseDto;
}
} catch (Exception e) {
logger.error("Error processing chat message", e);
ChatResponseDto errorResponse = new ChatResponseDto();
errorResponse.setMessage("Error: " + e.getMessage());
errorResponse.setSuccess(false);
return errorResponse;
}
});
}
/**
* 流式聊天 - 真正的流式实现
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@PostMapping(value = "/message", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamMessage(@RequestBody ChatRequestDto request) {
logger.info("🌊 开始流式对话: {}", request.getMessage());
logger.info("📨 开始流式聊天: {}", request.getMessage());
return Flux.create(sink -> {
try {
@@ -137,27 +63,29 @@ public class ChatController {
contentStream
.doOnNext(content -> {
logger.debug("📨 流式内容片段: {}", content);
// 发送SSE格式的数据
sink.next("data: " + content + "\n\n");
// 发送内容片段SSE格式会自动添加 "data: " 前缀)
sink.next(content);
})
.doOnComplete(() -> {
logger.info("✅ 流式对话完成");
sink.next("data: [DONE]\n\n");
logger.info("✅ 流式聊天完成");
// 发送完成标记
sink.next("[DONE]");
sink.complete();
})
.doOnError(error -> {
logger.error("❌ 流式对话错误: {}", error.getMessage());
logger.error("❌ 流式聊天错误: {}", error.getMessage());
sink.error(error);
})
.subscribe();
} catch (Exception e) {
logger.error("❌ 流式对话启动失败: {}", e.getMessage());
logger.error("❌ 流式聊天启动失败: {}", e.getMessage());
sink.error(e);
}
});
}
/**
* 清除对话历史
*/

View File

@@ -1,125 +0,0 @@
package com.example.demo.tools;
import com.example.demo.schema.JsonSchema;
import com.example.demo.schema.SchemaValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.CompletableFuture;
/**
* Base abstract class for tools
* All tools should inherit from this class
*/
public abstract class BaseTool<P> {
protected final Logger logger = LoggerFactory.getLogger(getClass());
protected final String name;
protected final String displayName;
protected final String description;
protected final JsonSchema parameterSchema;
protected final boolean isOutputMarkdown;
protected final boolean canUpdateOutput;
protected SchemaValidator schemaValidator;
public BaseTool(String name, String displayName, String description, JsonSchema parameterSchema) {
this(name, displayName, description, parameterSchema, true, false);
}
public BaseTool(String name, String displayName, String description, JsonSchema parameterSchema,
boolean isOutputMarkdown, boolean canUpdateOutput) {
this.name = name;
this.displayName = displayName;
this.description = description;
this.parameterSchema = parameterSchema;
this.isOutputMarkdown = isOutputMarkdown;
this.canUpdateOutput = canUpdateOutput;
}
/**
* Set Schema validator (through dependency injection)
*/
public void setSchemaValidator(SchemaValidator schemaValidator) {
this.schemaValidator = schemaValidator;
}
/**
* Validate tool parameters
*
* @param params Parameter object
* @return Validation error message, null means validation passed
*/
public String validateToolParams(P params) {
if (schemaValidator == null || parameterSchema == null) {
logger.warn("Schema validator or parameter schema is null, skipping validation");
return null;
}
try {
return schemaValidator.validate(parameterSchema, params);
} catch (Exception e) {
logger.error("Parameter validation failed", e);
return "Parameter validation error: " + e.getMessage();
}
}
/**
* Confirm whether user approval is needed for execution
*
* @param params Parameter object
* @return Confirmation details, null means no confirmation needed
*/
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(P params) {
return CompletableFuture.completedFuture(null); // Default no confirmation needed
}
/**
* Execute tool
*
* @param params Parameter object
* @return Execution result
*/
public abstract CompletableFuture<ToolResult> execute(P params);
/**
* Get tool description (for AI understanding)
*
* @param params Parameter object
* @return Description information
*/
public String getDescription(P params) {
return description;
}
// Getters
public String getName() {
return name;
}
public String getDisplayName() {
return displayName;
}
public String getDescription() {
return description;
}
public JsonSchema getParameterSchema() {
return parameterSchema;
}
public boolean isOutputMarkdown() {
return isOutputMarkdown;
}
public boolean canUpdateOutput() {
return canUpdateOutput;
}
@Override
public String toString() {
return String.format("Tool{name='%s', displayName='%s'}", name, displayName);
}
}

View File

@@ -1,466 +0,0 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.difflib.DiffUtils;
import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* File editing tool
* Supports file editing based on string replacement, automatically shows differences
*/
@Component
public class EditFileTool extends BaseTool<EditFileTool.EditFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public EditFileTool(AppProperties appProperties) {
super(
"edit_file",
"EditFile",
"Edits a file by replacing specified text with new text. " +
"Shows a diff of the changes before applying them. " +
"Supports both exact string matching and line-based editing. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("file_path", JsonSchema.string(
"MUST be an absolute path to the file to edit. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("old_str", JsonSchema.string(
"The exact string to find and replace. Must match exactly including whitespace and newlines."
))
.addProperty("new_str", JsonSchema.string(
"The new string to replace the old string with. Can be empty to delete the old string."
))
.addProperty("start_line", JsonSchema.integer(
"Optional: 1-based line number where the old_str starts. Helps with disambiguation."
).minimum(1))
.addProperty("end_line", JsonSchema.integer(
"Optional: 1-based line number where the old_str ends. Must be >= start_line."
).minimum(1))
.required("file_path", "old_str", "new_str");
}
@Override
public String validateToolParams(EditFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.filePath == null || params.filePath.trim().isEmpty()) {
return "File path cannot be empty";
}
if (params.oldStr == null) {
return "Old string cannot be null";
}
if (params.newStr == null) {
return "New string cannot be null";
}
Path filePath = Paths.get(params.filePath);
// Validate if it's an absolute path
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
}
// 验证行号
if (params.startLine != null && params.endLine != null) {
if (params.endLine < params.startLine) {
return "End line must be >= start line";
}
}
return null;
}
@Override
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(EditFileParams params) {
// Decide whether confirmation is needed based on configuration
if (appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.AUTO_EDIT ||
appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.YOLO) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
if (!Files.exists(filePath)) {
return null; // 文件不存在,无法预览差异
}
String currentContent = Files.readString(filePath, StandardCharsets.UTF_8);
String newContent = performEdit(currentContent, params);
if (newContent == null) {
return null; // Edit failed, cannot preview differences
}
// 生成差异显示
String diff = generateDiff(filePath.getFileName().toString(), currentContent, newContent);
String title = "Confirm Edit: " + getRelativePath(filePath);
return ToolConfirmationDetails.edit(title, filePath.getFileName().toString(), diff);
} catch (IOException e) {
logger.warn("Could not read file for edit preview: " + params.filePath, e);
return null;
}
});
}
/**
* Edit file tool method for Spring AI integration
*/
@Tool(name = "edit_file", description = "Edits a file by replacing specified text with new text")
public String editFile(String filePath, String oldStr, String newStr, Integer startLine, Integer endLine) {
long callId = executionLogger.logToolStart("edit_file", "编辑文件内容",
String.format("文件=%s, 替换文本长度=%d->%d, 行号范围=%s-%s",
filePath, oldStr != null ? oldStr.length() : 0,
newStr != null ? newStr.length() : 0, startLine, endLine));
long startTime = System.currentTimeMillis();
try {
EditFileParams params = new EditFileParams();
params.setFilePath(filePath);
params.setOldStr(oldStr);
params.setNewStr(newStr);
params.setStartLine(startLine);
params.setEndLine(endLine);
executionLogger.logToolStep(callId, "edit_file", "参数验证", "验证文件路径和替换内容");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "edit_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
String editDetails = startLine != null && endLine != null ?
String.format("行号范围编辑: %d-%d行", startLine, endLine) : "字符串替换编辑";
executionLogger.logFileOperation(callId, "编辑文件", filePath, editDetails);
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "edit_file", "文件编辑成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "edit_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "edit_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in edit file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(EditFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
// Check if file exists
if (!Files.exists(filePath)) {
return ToolResult.error("File not found: " + params.filePath);
}
// Check if it's a file
if (!Files.isRegularFile(filePath)) {
return ToolResult.error("Path is not a regular file: " + params.filePath);
}
// 读取原始内容
String originalContent = Files.readString(filePath, StandardCharsets.UTF_8);
// 执行编辑
String newContent = performEdit(originalContent, params);
if (newContent == null) {
return ToolResult.error("Could not find the specified text to replace in file: " + params.filePath);
}
// 创建备份
if (shouldCreateBackup()) {
createBackup(filePath, originalContent);
}
// Write new content
Files.writeString(filePath, newContent, StandardCharsets.UTF_8);
// Generate differences and results
String diff = generateDiff(filePath.getFileName().toString(), originalContent, newContent);
String relativePath = getRelativePath(filePath);
String successMessage = String.format("Successfully edited file: %s", params.filePath);
return ToolResult.success(successMessage, new FileDiff(diff, filePath.getFileName().toString()));
} catch (IOException e) {
logger.error("Error editing file: " + params.filePath, e);
return ToolResult.error("Error editing file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error editing file: " + params.filePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private String performEdit(String content, EditFileParams params) {
// If line numbers are specified, use line numbers to assist in finding
if (params.startLine != null && params.endLine != null) {
return performEditWithLineNumbers(content, params);
} else {
return performSimpleEdit(content, params);
}
}
private String performSimpleEdit(String content, EditFileParams params) {
// Simple string replacement
if (!content.contains(params.oldStr)) {
return null; // Cannot find string to replace
}
// Only replace the first match to avoid unexpected multiple replacements
int index = content.indexOf(params.oldStr);
if (index == -1) {
return null;
}
return content.substring(0, index) + params.newStr + content.substring(index + params.oldStr.length());
}
private String performEditWithLineNumbers(String content, EditFileParams params) {
String[] lines = content.split("\n", -1); // -1 preserve trailing empty lines
// Validate line number range
if (params.startLine > lines.length || params.endLine > lines.length) {
return null; // Line number out of range
}
// Extract content from specified line range
StringBuilder targetContent = new StringBuilder();
for (int i = params.startLine - 1; i < params.endLine; i++) {
if (i > params.startLine - 1) {
targetContent.append("\n");
}
targetContent.append(lines[i]);
}
// 检查是否匹配
if (!targetContent.toString().equals(params.oldStr)) {
return null; // 指定行范围的内容与old_str不匹配
}
// 执行替换
StringBuilder result = new StringBuilder();
// 添加前面的行
for (int i = 0; i < params.startLine - 1; i++) {
if (i > 0) result.append("\n");
result.append(lines[i]);
}
// 添加新内容
if (params.startLine > 1) result.append("\n");
result.append(params.newStr);
// 添加后面的行
for (int i = params.endLine; i < lines.length; i++) {
result.append("\n");
result.append(lines[i]);
}
return result.toString();
}
private String generateDiff(String fileName, String oldContent, String newContent) {
try {
List<String> oldLines = Arrays.asList(oldContent.split("\n"));
List<String> newLines = Arrays.asList(newContent.split("\n"));
Patch<String> patch = DiffUtils.diff(oldLines, newLines);
List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(
fileName + " (Original)",
fileName + " (Edited)",
oldLines,
patch,
3 // context lines
);
return String.join("\n", unifiedDiff);
} catch (Exception e) {
logger.warn("Could not generate diff", e);
return "Diff generation failed: " + e.getMessage();
}
}
private void createBackup(Path filePath, String content) throws IOException {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String backupFileName = filePath.getFileName().toString() + ".backup." + timestamp;
Path backupPath = filePath.getParent().resolve(backupFileName);
Files.writeString(backupPath, content, StandardCharsets.UTF_8);
logger.info("Created backup: {}", backupPath);
}
private boolean shouldCreateBackup() {
return true; // 总是创建备份
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 编辑文件参数
*/
public static class EditFileParams {
@JsonProperty("file_path")
private String filePath;
@JsonProperty("old_str")
private String oldStr;
@JsonProperty("new_str")
private String newStr;
@JsonProperty("start_line")
private Integer startLine;
@JsonProperty("end_line")
private Integer endLine;
// 构造器
public EditFileParams() {
}
public EditFileParams(String filePath, String oldStr, String newStr) {
this.filePath = filePath;
this.oldStr = oldStr;
this.newStr = newStr;
}
// Getters and Setters
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getOldStr() {
return oldStr;
}
public void setOldStr(String oldStr) {
this.oldStr = oldStr;
}
public String getNewStr() {
return newStr;
}
public void setNewStr(String newStr) {
this.newStr = newStr;
}
public Integer getStartLine() {
return startLine;
}
public void setStartLine(Integer startLine) {
this.startLine = startLine;
}
public Integer getEndLine() {
return endLine;
}
public void setEndLine(Integer endLine) {
this.endLine = endLine;
}
@Override
public String toString() {
return String.format("EditFileParams{path='%s', oldStrLength=%d, newStrLength=%d, lines=%s-%s}",
filePath,
oldStr != null ? oldStr.length() : 0,
newStr != null ? newStr.length() : 0,
startLine, endLine);
}
}
}

View File

@@ -1,409 +0,0 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.utils.PathUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 文件操作工具类 - 使用Spring AI 1.0.0 @Tool注解
*/
@Component
public class FileOperationTools {
private static final Logger logger = LoggerFactory.getLogger(FileOperationTools.class);
private final String rootDirectory;
private final AppProperties appProperties;
// 在构造函数中
public FileOperationTools(AppProperties appProperties) {
this.appProperties = appProperties;
// 使用规范化的路径
this.rootDirectory = PathUtils.normalizePath(appProperties.getWorkspace().getRootDirectory());
}
@Tool(description = "Read the content of a file from the local filesystem. Supports pagination for large files.")
public String readFile(
@ToolParam(description = "The absolute path to the file to read. Must be within the workspace directory.")
String absolutePath,
@ToolParam(description = "Optional: For text files, the 0-based line number to start reading from.", required = false)
Integer offset,
@ToolParam(description = "Optional: For text files, the number of lines to read from the offset.", required = false)
Integer limit) {
long startTime = System.currentTimeMillis();
try {
logger.debug("Starting readFile operation for: {}", absolutePath);
// 验证路径
String validationError = validatePath(absolutePath);
if (validationError != null) {
return "Error: " + validationError;
}
Path filePath = Paths.get(absolutePath);
// 检查文件是否存在
if (!Files.exists(filePath)) {
return "Error: File not found: " + absolutePath;
}
// 检查是否为文件
if (!Files.isRegularFile(filePath)) {
return "Error: Path is not a regular file: " + absolutePath;
}
// 检查文件大小
long fileSize = Files.size(filePath);
if (fileSize > appProperties.getWorkspace().getMaxFileSize()) {
return "Error: File too large: " + fileSize + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
// 检查文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return "Error: File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions();
}
// 读取文件
if (offset != null && limit != null) {
return readFileWithPagination(filePath, offset, limit);
} else {
return readFullFile(filePath);
}
} catch (IOException e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Error reading file: {} (duration: {}ms)", absolutePath, duration, e);
return String.format("❌ Error reading file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
absolutePath, duration, e.getMessage());
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Unexpected error reading file: {} (duration: {}ms)", absolutePath, duration, e);
return String.format("❌ Unexpected error reading file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
absolutePath, duration, e.getMessage());
} finally {
long duration = System.currentTimeMillis() - startTime;
logger.debug("Completed readFile operation for: {} (duration: {}ms)", absolutePath, duration);
}
}
@Tool(description = "Write content to a file. Creates new file or overwrites existing file.")
public String writeFile(
@ToolParam(description = "The absolute path to the file to write. Must be within the workspace directory.")
String filePath,
@ToolParam(description = "The content to write to the file")
String content) {
long startTime = System.currentTimeMillis();
try {
logger.debug("Starting writeFile operation for: {}", filePath);
// 验证路径
String validationError = validatePath(filePath);
if (validationError != null) {
return "Error: " + validationError;
}
// 验证内容大小
byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
if (contentBytes.length > appProperties.getWorkspace().getMaxFileSize()) {
return "Error: Content too large: " + contentBytes.length + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
Path path = Paths.get(filePath);
boolean isNewFile = !Files.exists(path);
// 确保父目录存在
Files.createDirectories(path.getParent());
// 写入文件
Files.writeString(path, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
long lineCount = content.lines().count();
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
if (isNewFile) {
return String.format("Successfully created file:\n📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes",
absolutePath, relativePath, lineCount, contentBytes.length);
} else {
return String.format("Successfully wrote to file:\n📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes",
absolutePath, relativePath, lineCount, contentBytes.length);
}
} catch (IOException e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Error writing file: {} (duration: {}ms)", filePath, duration, e);
return String.format("❌ Error writing file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
filePath, duration, e.getMessage());
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Unexpected error writing file: {} (duration: {}ms)", filePath, duration, e);
return String.format("❌ Unexpected error writing file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
filePath, duration, e.getMessage());
} finally {
long duration = System.currentTimeMillis() - startTime;
logger.debug("Completed writeFile operation for: {} (duration: {}ms)", filePath, duration);
}
}
@Tool(description = "Edit a file by replacing specific text content.")
public String editFile(
@ToolParam(description = "The absolute path to the file to edit. Must be within the workspace directory.")
String filePath,
@ToolParam(description = "The text to find and replace in the file")
String oldText,
@ToolParam(description = "The new text to replace the old text with")
String newText) {
try {
// 验证路径
String validationError = validatePath(filePath);
if (validationError != null) {
return "Error: " + validationError;
}
Path path = Paths.get(filePath);
// 检查文件是否存在
if (!Files.exists(path)) {
return "Error: File not found: " + filePath;
}
// 检查是否为文件
if (!Files.isRegularFile(path)) {
return "Error: Path is not a regular file: " + filePath;
}
// 读取原始内容
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
// 执行替换
if (!originalContent.contains(oldText)) {
return "Error: Could not find the specified text to replace in file: " + filePath;
}
String newContent = originalContent.replace(oldText, newText);
// 写入新内容
Files.writeString(path, newContent, StandardCharsets.UTF_8);
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
return String.format("Successfully edited file:\n📁 Full path: %s\n📂 Relative path: %s\n✏ Replaced text successfully",
absolutePath, relativePath);
} catch (IOException e) {
logger.error("Error editing file: " + filePath, e);
return "Error editing file: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error editing file: " + filePath, e);
return "Unexpected error: " + e.getMessage();
}
}
@Tool(description = "List the contents of a directory.")
public String listDirectory(
@ToolParam(description = "The absolute path to the directory to list. Must be within the workspace directory.")
String directoryPath,
@ToolParam(description = "Whether to list contents recursively", required = false)
Boolean recursive) {
try {
// 验证路径
String validationError = validatePath(directoryPath);
if (validationError != null) {
return "Error: " + validationError;
}
Path path = Paths.get(directoryPath);
// 检查目录是否存在
if (!Files.exists(path)) {
return "Error: Directory not found: " + directoryPath;
}
// 检查是否为目录
if (!Files.isDirectory(path)) {
return "Error: Path is not a directory: " + directoryPath;
}
boolean isRecursive = recursive != null && recursive;
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
if (isRecursive) {
return listDirectoryRecursive(path, absolutePath, relativePath);
} else {
return listDirectorySimple(path, absolutePath, relativePath);
}
} catch (IOException e) {
logger.error("Error listing directory: " + directoryPath, e);
return "Error listing directory: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error listing directory: " + directoryPath, e);
return "Unexpected error: " + e.getMessage();
}
}
// 辅助方法
private String validatePath(String path) {
if (path == null || path.trim().isEmpty()) {
return "Path cannot be empty";
}
Path filePath = Paths.get(path);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "Path must be absolute: " + path;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "Path must be within the workspace directory (" + rootDirectory + "): " + path;
}
return null;
}
private boolean isWithinWorkspace(Path path) {
try {
Path workspacePath = Paths.get(rootDirectory).toRealPath();
Path targetPath = path.toRealPath();
return targetPath.startsWith(workspacePath);
} catch (IOException e) {
// 如果路径不存在,检查其父目录
try {
Path workspacePath = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = path.normalize();
return normalizedPath.startsWith(workspacePath.normalize());
} catch (IOException ex) {
return false;
}
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path path) {
try {
Path workspacePath = Paths.get(rootDirectory);
return workspacePath.relativize(path).toString();
} catch (Exception e) {
return path.toString();
}
}
private String readFullFile(Path filePath) throws IOException {
String content = Files.readString(filePath, StandardCharsets.UTF_8);
String absolutePath = filePath.toAbsolutePath().toString();
String relativePath = getRelativePath(filePath);
long lineCount = content.lines().count();
return String.format("📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes\n\n📄 Content:\n%s",
absolutePath, relativePath, lineCount, content.getBytes(StandardCharsets.UTF_8).length, content);
}
private String readFileWithPagination(Path filePath, int offset, int limit) throws IOException {
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);
if (offset >= allLines.size()) {
return "Error: Offset " + offset + " is beyond file length (" + allLines.size() + " lines)";
}
int endIndex = Math.min(offset + limit, allLines.size());
List<String> selectedLines = allLines.subList(offset, endIndex);
String content = String.join("\n", selectedLines);
String absolutePath = filePath.toAbsolutePath().toString();
String relativePath = getRelativePath(filePath);
return String.format("📁 Full path: %s\n📂 Relative path: %s\n📊 Showing lines %d-%d of %d total\n\n📄 Content:\n%s",
absolutePath, relativePath, offset + 1, endIndex, allLines.size(), content);
}
private String listDirectorySimple(Path path, String absolutePath, String relativePath) throws IOException {
StringBuilder result = new StringBuilder();
result.append("📁 Full path: ").append(absolutePath).append("\n");
result.append("📂 Relative path: ").append(relativePath).append("\n\n");
result.append("📋 Directory contents:\n");
try (Stream<Path> entries = Files.list(path)) {
List<Path> sortedEntries = entries.sorted().collect(Collectors.toList());
for (Path entry : sortedEntries) {
String name = entry.getFileName().toString();
String entryAbsolutePath = entry.toAbsolutePath().toString();
if (Files.isDirectory(entry)) {
result.append("📁 [DIR] ").append(name).append("/\n");
result.append(" └─ ").append(entryAbsolutePath).append("\n");
} else {
long size = Files.size(entry);
result.append("📄 [FILE] ").append(name).append(" (").append(size).append(" bytes)\n");
result.append(" └─ ").append(entryAbsolutePath).append("\n");
}
}
}
return result.toString();
}
private String listDirectoryRecursive(Path path, String absolutePath, String relativePath) throws IOException {
StringBuilder result = new StringBuilder();
result.append("📁 Full path: ").append(absolutePath).append("\n");
result.append("📂 Relative path: ").append(relativePath).append("\n\n");
result.append("🌳 Directory tree (recursive):\n");
try (Stream<Path> entries = Files.walk(path)) {
entries.sorted()
.forEach(entry -> {
if (!entry.equals(path)) {
String entryAbsolutePath = entry.toAbsolutePath().toString();
String entryRelativePath = getRelativePath(entry);
// 计算缩进级别
int depth = entry.getNameCount() - path.getNameCount();
String indent = " ".repeat(depth);
if (Files.isDirectory(entry)) {
result.append(indent).append("📁 ").append(entryRelativePath).append("/\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
} else {
try {
long size = Files.size(entry);
result.append(indent).append("📄 ").append(entryRelativePath).append(" (").append(size).append(" bytes)\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
} catch (IOException e) {
result.append(indent).append("📄 ").append(entryRelativePath).append(" (size unknown)\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
}
}
}
});
}
return result.toString();
}
}

View File

@@ -1,394 +0,0 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 目录列表工具
* 列出指定目录的文件和子目录,支持递归列表
*/
@Component
public class ListDirectoryTool extends BaseTool<ListDirectoryTool.ListDirectoryParams> {
private final String rootDirectory;
private final AppProperties appProperties;
public ListDirectoryTool(AppProperties appProperties) {
super(
"list_directory",
"ListDirectory",
"Lists files and directories in the specified path. " +
"Supports recursive listing and filtering. " +
"Shows file sizes, modification times, and types. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("path", JsonSchema.string(
"MUST be an absolute path to the directory to list. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("recursive", JsonSchema.bool(
"Optional: Whether to list files recursively in subdirectories. Default: false"
))
.addProperty("max_depth", JsonSchema.integer(
"Optional: Maximum depth for recursive listing. Default: 3, Maximum: 10"
).minimum(1).maximum(10))
.addProperty("show_hidden", JsonSchema.bool(
"Optional: Whether to show hidden files (starting with '.'). Default: false"
))
.required("path");
}
@Override
public String validateToolParams(ListDirectoryParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.path == null || params.path.trim().isEmpty()) {
return "Directory path cannot be empty";
}
Path dirPath = Paths.get(params.path);
// 验证是否为绝对路径
if (!dirPath.isAbsolute()) {
return "Directory path must be absolute: " + params.path;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(dirPath)) {
return "Directory path must be within the workspace directory (" + rootDirectory + "): " + params.path;
}
// 验证最大深度
if (params.maxDepth != null && (params.maxDepth < 1 || params.maxDepth > 10)) {
return "Max depth must be between 1 and 10";
}
return null;
}
@Override
public CompletableFuture<ToolResult> execute(ListDirectoryParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path dirPath = Paths.get(params.path);
// 检查目录是否存在
if (!Files.exists(dirPath)) {
return ToolResult.error("Directory not found: " + params.path);
}
// 检查是否为目录
if (!Files.isDirectory(dirPath)) {
return ToolResult.error("Path is not a directory: " + params.path);
}
// 列出文件和目录
List<FileInfo> fileInfos = listFiles(dirPath, params);
// 生成输出
String content = formatFileList(fileInfos, params);
String relativePath = getRelativePath(dirPath);
String displayMessage = String.format("Listed directory: %s (%d items)",
relativePath, fileInfos.size());
return ToolResult.success(content, displayMessage);
} catch (IOException e) {
logger.error("Error listing directory: " + params.path, e);
return ToolResult.error("Error listing directory: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error listing directory: " + params.path, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private List<FileInfo> listFiles(Path dirPath, ListDirectoryParams params) throws IOException {
List<FileInfo> fileInfos = new ArrayList<>();
if (params.recursive != null && params.recursive) {
int maxDepth = params.maxDepth != null ? params.maxDepth : 3;
listFilesRecursive(dirPath, fileInfos, 0, maxDepth, params);
} else {
listFilesInDirectory(dirPath, fileInfos, params);
}
// 排序:目录在前,然后按名称排序
fileInfos.sort(Comparator
.comparing((FileInfo f) -> !f.isDirectory())
.thenComparing(FileInfo::getName));
return fileInfos;
}
private void listFilesInDirectory(Path dirPath, List<FileInfo> fileInfos, ListDirectoryParams params) throws IOException {
try (Stream<Path> stream = Files.list(dirPath)) {
stream.forEach(path -> {
try {
String fileName = path.getFileName().toString();
// 跳过隐藏文件(除非明确要求显示)
if (!params.showHidden && fileName.startsWith(".")) {
return;
}
FileInfo fileInfo = createFileInfo(path, dirPath);
fileInfos.add(fileInfo);
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
});
}
}
private void listFilesRecursive(Path dirPath, List<FileInfo> fileInfos, int currentDepth, int maxDepth, ListDirectoryParams params) throws IOException {
if (currentDepth >= maxDepth) {
return;
}
try (Stream<Path> stream = Files.list(dirPath)) {
List<Path> paths = stream.collect(Collectors.toList());
for (Path path : paths) {
String fileName = path.getFileName().toString();
// 跳过隐藏文件(除非明确要求显示)
if (!params.showHidden && fileName.startsWith(".")) {
continue;
}
try {
FileInfo fileInfo = createFileInfo(path, Paths.get(params.path));
fileInfos.add(fileInfo);
// 如果是目录,递归列出
if (Files.isDirectory(path)) {
listFilesRecursive(path, fileInfos, currentDepth + 1, maxDepth, params);
}
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
}
}
}
private FileInfo createFileInfo(Path path, Path basePath) throws IOException {
String name = path.getFileName().toString();
boolean isDirectory = Files.isDirectory(path);
long size = isDirectory ? 0 : Files.size(path);
LocalDateTime lastModified = LocalDateTime.ofInstant(
Files.getLastModifiedTime(path).toInstant(),
ZoneId.systemDefault()
);
String relativePath = basePath.relativize(path).toString();
return new FileInfo(name, relativePath, isDirectory, size, lastModified);
}
private String formatFileList(List<FileInfo> fileInfos, ListDirectoryParams params) {
if (fileInfos.isEmpty()) {
return "Directory is empty.";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Directory listing for: %s\n", getRelativePath(Paths.get(params.path))));
sb.append(String.format("Total items: %d\n\n", fileInfos.size()));
// 表头
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
"Type", "Name", "Size", "Modified", "Path"));
sb.append("-".repeat(80)).append("\n");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
for (FileInfo fileInfo : fileInfos) {
String type = fileInfo.isDirectory() ? "DIR" : "FILE";
String sizeStr = fileInfo.isDirectory() ? "-" : formatFileSize(fileInfo.getSize());
String modifiedStr = fileInfo.getLastModified().format(formatter);
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
type,
truncate(fileInfo.getName(), 40),
sizeStr,
modifiedStr,
fileInfo.getRelativePath()
));
}
return sb.toString();
}
private String formatFileSize(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));
}
private String truncate(String str, int maxLength) {
if (str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength - 3) + "...";
}
private boolean isWithinWorkspace(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = dirPath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(dirPath).toString();
} catch (Exception e) {
return dirPath.toString();
}
}
/**
* 文件信息
*/
public static class FileInfo {
private final String name;
private final String relativePath;
private final boolean isDirectory;
private final long size;
private final LocalDateTime lastModified;
public FileInfo(String name, String relativePath, boolean isDirectory, long size, LocalDateTime lastModified) {
this.name = name;
this.relativePath = relativePath;
this.isDirectory = isDirectory;
this.size = size;
this.lastModified = lastModified;
}
// Getters
public String getName() {
return name;
}
public String getRelativePath() {
return relativePath;
}
public boolean isDirectory() {
return isDirectory;
}
public long getSize() {
return size;
}
public LocalDateTime getLastModified() {
return lastModified;
}
}
/**
* 列表目录参数
*/
public static class ListDirectoryParams {
private String path;
private Boolean recursive;
@JsonProperty("max_depth")
private Integer maxDepth;
@JsonProperty("show_hidden")
private Boolean showHidden;
// 构造器
public ListDirectoryParams() {
}
public ListDirectoryParams(String path) {
this.path = path;
}
// Getters and Setters
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public Boolean getRecursive() {
return recursive;
}
public void setRecursive(Boolean recursive) {
this.recursive = recursive;
}
public Integer getMaxDepth() {
return maxDepth;
}
public void setMaxDepth(Integer maxDepth) {
this.maxDepth = maxDepth;
}
public Boolean getShowHidden() {
return showHidden;
}
public void setShowHidden(Boolean showHidden) {
this.showHidden = showHidden;
}
@Override
public String toString() {
return String.format("ListDirectoryParams{path='%s', recursive=%s, maxDepth=%d}",
path, recursive, maxDepth);
}
}
}

View File

@@ -1,325 +0,0 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 文件读取工具
* 支持读取文本文件,可以分页读取大文件
*/
@Component
public class ReadFileTool extends BaseTool<ReadFileTool.ReadFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public ReadFileTool(AppProperties appProperties) {
super(
"read_file",
"ReadFile",
"Reads and returns the content of a specified file from the local filesystem. " +
"Handles text files and supports pagination for large files. " +
"Always use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("absolute_path", JsonSchema.string(
"MUST be an absolute path to the file to read. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("offset", JsonSchema.integer(
"Optional: For text files, the 0-based line number to start reading from. " +
"Requires 'limit' to be set. Use for paginating through large files."
).minimum(0))
.addProperty("limit", JsonSchema.integer(
"Optional: For text files, the number of lines to read from the offset. " +
"Use for paginating through large files."
).minimum(1))
.required("absolute_path");
}
@Override
public String validateToolParams(ReadFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.absolutePath == null || params.absolutePath.trim().isEmpty()) {
return "File path cannot be empty";
}
Path filePath = Paths.get(params.absolutePath);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.absolutePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.absolutePath;
}
// 验证分页参数
if (params.offset != null && params.limit == null) {
return "When 'offset' is specified, 'limit' must also be specified";
}
if (params.offset != null && params.offset < 0) {
return "Offset must be non-negative";
}
if (params.limit != null && params.limit <= 0) {
return "Limit must be positive";
}
return null;
}
/**
* Read file tool method for Spring AI integration
*/
@Tool(name = "read_file", description = "Reads and returns the content of a specified file from the local filesystem")
public String readFile(String absolutePath, Integer offset, Integer limit) {
long callId = executionLogger.logToolStart("read_file", "读取文件内容",
String.format("文件路径=%s, 偏移量=%s, 限制行数=%s", absolutePath, offset, limit));
long startTime = System.currentTimeMillis();
try {
ReadFileParams params = new ReadFileParams();
params.setAbsolutePath(absolutePath);
params.setOffset(offset);
params.setLimit(limit);
executionLogger.logToolStep(callId, "read_file", "参数验证", "验证文件路径和分页参数");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "read_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
executionLogger.logFileOperation(callId, "读取文件", absolutePath,
offset != null ? String.format("分页读取: 偏移=%d, 限制=%d", offset, limit) : "完整读取");
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "read_file", "文件读取成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "read_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "read_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in read file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(ReadFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.absolutePath);
// 检查文件是否存在
if (!Files.exists(filePath)) {
return ToolResult.error("File not found: " + params.absolutePath);
}
// 检查是否为文件
if (!Files.isRegularFile(filePath)) {
return ToolResult.error("Path is not a regular file: " + params.absolutePath);
}
// 检查文件大小
long fileSize = Files.size(filePath);
if (fileSize > appProperties.getWorkspace().getMaxFileSize()) {
return ToolResult.error("File too large: " + fileSize + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes");
}
// 检查文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return ToolResult.error("File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions());
}
// 读取文件
if (params.offset != null && params.limit != null) {
return readFileWithPagination(filePath, params.offset, params.limit);
} else {
return readFullFile(filePath);
}
} catch (IOException e) {
logger.error("Error reading file: " + params.absolutePath, e);
return ToolResult.error("Error reading file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error reading file: " + params.absolutePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private ToolResult readFullFile(Path filePath) throws IOException {
String content = Files.readString(filePath, StandardCharsets.UTF_8);
String relativePath = getRelativePath(filePath);
long lineCount = content.lines().count();
String displayMessage = String.format("Read file: %s (%d lines, %d bytes)",
relativePath, lineCount, content.getBytes(StandardCharsets.UTF_8).length);
return ToolResult.success(content, displayMessage);
}
private ToolResult readFileWithPagination(Path filePath, int offset, int limit) throws IOException {
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);
if (offset >= allLines.size()) {
return ToolResult.error("Offset " + offset + " is beyond file length (" + allLines.size() + " lines)");
}
int endIndex = Math.min(offset + limit, allLines.size());
List<String> selectedLines = allLines.subList(offset, endIndex);
String content = String.join("\n", selectedLines);
String relativePath = getRelativePath(filePath);
String displayMessage = String.format("Read file: %s (lines %d-%d of %d total)",
relativePath, offset + 1, endIndex, allLines.size());
return ToolResult.success(content, displayMessage);
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path resolvedPath = filePath.toRealPath();
return resolvedPath.startsWith(workspaceRoot);
} catch (IOException e) {
// 如果路径不存在,检查其父目录
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException ex) {
logger.warn("Could not resolve workspace path", ex);
return false;
}
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 读取文件参数
*/
public static class ReadFileParams {
@JsonProperty("absolute_path")
private String absolutePath;
private Integer offset;
private Integer limit;
// 构造器
public ReadFileParams() {
}
public ReadFileParams(String absolutePath) {
this.absolutePath = absolutePath;
}
public ReadFileParams(String absolutePath, Integer offset, Integer limit) {
this.absolutePath = absolutePath;
this.offset = offset;
this.limit = limit;
}
// Getters and Setters
public String getAbsolutePath() {
return absolutePath;
}
public void setAbsolutePath(String absolutePath) {
this.absolutePath = absolutePath;
}
public Integer getOffset() {
return offset;
}
public void setOffset(Integer offset) {
this.offset = offset;
}
public Integer getLimit() {
return limit;
}
public void setLimit(Integer limit) {
this.limit = limit;
}
@Override
public String toString() {
return String.format("ReadFileParams{path='%s', offset=%d, limit=%d}",
absolutePath, offset, limit);
}
}
}

View File

@@ -1,132 +0,0 @@
package com.example.demo.tools;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 工具执行结果
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ToolResult {
private final boolean success;
private final String llmContent;
private final Object returnDisplay;
private final String errorMessage;
private ToolResult(boolean success, String llmContent, Object returnDisplay, String errorMessage) {
this.success = success;
this.llmContent = llmContent;
this.returnDisplay = returnDisplay;
this.errorMessage = errorMessage;
}
// 静态工厂方法
public static ToolResult success(String llmContent) {
return new ToolResult(true, llmContent, llmContent, null);
}
public static ToolResult success(String llmContent, Object returnDisplay) {
return new ToolResult(true, llmContent, returnDisplay, null);
}
public static ToolResult error(String errorMessage) {
return new ToolResult(false, "Error: " + errorMessage, "Error: " + errorMessage, errorMessage);
}
// Getters
public boolean isSuccess() {
return success;
}
public String getLlmContent() {
return llmContent;
}
public Object getReturnDisplay() {
return returnDisplay;
}
public String getErrorMessage() {
return errorMessage;
}
@Override
public String toString() {
if (success) {
return "ToolResult{success=true, content='" + llmContent + "'}";
} else {
return "ToolResult{success=false, error='" + errorMessage + "'}";
}
}
}
/**
* 文件差异结果
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
class FileDiff {
private final String fileDiff;
private final String fileName;
public FileDiff(String fileDiff, String fileName) {
this.fileDiff = fileDiff;
this.fileName = fileName;
}
public String getFileDiff() {
return fileDiff;
}
public String getFileName() {
return fileName;
}
@Override
public String toString() {
return "FileDiff{fileName='" + fileName + "'}";
}
}
/**
* 工具确认详情
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
class ToolConfirmationDetails {
private final String type;
private final String title;
private final String description;
private final Object details;
public ToolConfirmationDetails(String type, String title, String description, Object details) {
this.type = type;
this.title = title;
this.description = description;
this.details = details;
}
public static ToolConfirmationDetails edit(String title, String fileName, String fileDiff) {
return new ToolConfirmationDetails("edit", title, "File edit confirmation",
new FileDiff(fileDiff, fileName));
}
public static ToolConfirmationDetails exec(String title, String command) {
return new ToolConfirmationDetails("exec", title, "Command execution confirmation", command);
}
// Getters
public String getType() {
return type;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public Object getDetails() {
return details;
}
}

View File

@@ -1,359 +0,0 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.difflib.DiffUtils;
import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 文件写入工具
* 支持创建新文件或覆盖现有文件,自动显示差异
*/
@Component
public class WriteFileTool extends BaseTool<WriteFileTool.WriteFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public WriteFileTool(AppProperties appProperties) {
super(
"write_file",
"WriteFile",
"Writes content to a file. Creates new files or overwrites existing ones. " +
"Always shows a diff before writing. Automatically creates parent directories if needed. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("file_path", JsonSchema.string(
"MUST be an absolute path to the file to write to. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("content", JsonSchema.string(
"The content to write to the file. Will completely replace existing content if file exists."
))
.required("file_path", "content");
}
@Override
public String validateToolParams(WriteFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.filePath == null || params.filePath.trim().isEmpty()) {
return "File path cannot be empty";
}
if (params.content == null) {
return "Content cannot be null";
}
Path filePath = Paths.get(params.filePath);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
}
// 验证文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return "File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions();
}
// 验证内容大小
byte[] contentBytes = params.content.getBytes(StandardCharsets.UTF_8);
if (contentBytes.length > appProperties.getWorkspace().getMaxFileSize()) {
return "Content too large: " + contentBytes.length + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
return null;
}
@Override
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(WriteFileParams params) {
// 根据配置决定是否需要确认
if (appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.AUTO_EDIT ||
appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.YOLO) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
String currentContent = "";
boolean isNewFile = !Files.exists(filePath);
if (!isNewFile) {
currentContent = Files.readString(filePath, StandardCharsets.UTF_8);
}
// 生成差异显示
String diff = generateDiff(
filePath.getFileName().toString(),
currentContent,
params.content
);
String title = isNewFile ?
"Confirm Create: " + getRelativePath(filePath) :
"Confirm Write: " + getRelativePath(filePath);
return ToolConfirmationDetails.edit(title, filePath.getFileName().toString(), diff);
} catch (IOException e) {
logger.warn("Could not read existing file for diff: " + params.filePath, e);
return null; // 如果无法读取文件,直接执行
}
});
}
/**
* Write file tool method for Spring AI integration
*/
@Tool(name = "write_file", description = "Creates a new file or overwrites an existing file with the specified content")
public String writeFile(String filePath, String content) {
long callId = executionLogger.logToolStart("write_file", "写入文件内容",
String.format("文件路径=%s, 内容长度=%d字符", filePath, content != null ? content.length() : 0));
long startTime = System.currentTimeMillis();
try {
WriteFileParams params = new WriteFileParams();
params.setFilePath(filePath);
params.setContent(content);
executionLogger.logToolStep(callId, "write_file", "参数验证", "验证文件路径和内容");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "write_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
executionLogger.logFileOperation(callId, "写入文件", filePath,
String.format("内容长度: %d字符", content != null ? content.length() : 0));
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "write_file", "文件写入成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "write_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "write_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in write file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(WriteFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
boolean isNewFile = !Files.exists(filePath);
String originalContent = "";
// 读取原始内容(用于备份和差异显示)
if (!isNewFile) {
originalContent = Files.readString(filePath, StandardCharsets.UTF_8);
}
// 创建备份(如果启用)
if (!isNewFile && shouldCreateBackup()) {
createBackup(filePath, originalContent);
}
// 确保父目录存在
Files.createDirectories(filePath.getParent());
// 写入文件
Files.writeString(filePath, params.content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
// 生成结果
String relativePath = getRelativePath(filePath);
long lineCount = params.content.lines().count();
long byteCount = params.content.getBytes(StandardCharsets.UTF_8).length;
if (isNewFile) {
String successMessage = String.format("Successfully created file: %s (%d lines, %d bytes)",
params.filePath, lineCount, byteCount);
String displayMessage = String.format("Created %s (%d lines)", relativePath, lineCount);
return ToolResult.success(successMessage, displayMessage);
} else {
String diff = generateDiff(filePath.getFileName().toString(), originalContent, params.content);
String successMessage = String.format("Successfully wrote to file: %s (%d lines, %d bytes)",
params.filePath, lineCount, byteCount);
return ToolResult.success(successMessage, new FileDiff(diff, filePath.getFileName().toString()));
}
} catch (IOException e) {
logger.error("Error writing file: " + params.filePath, e);
return ToolResult.error("Error writing file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error writing file: " + params.filePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private String generateDiff(String fileName, String oldContent, String newContent) {
try {
List<String> oldLines = Arrays.asList(oldContent.split("\n"));
List<String> newLines = Arrays.asList(newContent.split("\n"));
Patch<String> patch = DiffUtils.diff(oldLines, newLines);
List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(
fileName + " (Original)",
fileName + " (New)",
oldLines,
patch,
3 // context lines
);
return String.join("\n", unifiedDiff);
} catch (Exception e) {
logger.warn("Could not generate diff", e);
return "Diff generation failed: " + e.getMessage();
}
}
private void createBackup(Path filePath, String content) throws IOException {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String backupFileName = filePath.getFileName().toString() + ".backup." + timestamp;
Path backupPath = filePath.getParent().resolve(backupFileName);
Files.writeString(backupPath, content, StandardCharsets.UTF_8);
logger.info("Created backup: {}", backupPath);
}
private boolean shouldCreateBackup() {
// 可以从配置中读取,这里简化为总是创建备份
return true;
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 写入文件参数
*/
public static class WriteFileParams {
@JsonProperty("file_path")
private String filePath;
private String content;
// 构造器
public WriteFileParams() {
}
public WriteFileParams(String filePath, String content) {
this.filePath = filePath;
this.content = content;
}
// Getters and Setters
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
@Override
public String toString() {
return String.format("WriteFileParams{path='%s', contentLength=%d}",
filePath, content != null ? content.length() : 0);
}
}
}

View File

@@ -1,4 +1,4 @@
package com.example.demo.util;
package com.example.demo.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;