mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-04-16 21:33:40 +00:00
v3.0.0 init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 "未找到路径参数";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清除对话历史
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.demo.util;
|
||||
package com.example.demo.utils;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
Reference in New Issue
Block a user