实现内容: - pom.xml: JDK25 + Spring AI 2.0.0-M4 + JLine3 + Picocli - core/AgentLoop: 基于ChatModel的显式工具循环(非ChatClient) - tool/: Tool接口 + ToolRegistry + ToolCallbackAdapter(适配Spring AI) - tool/impl/: BashTool, FileReadTool, FileWriteTool, FileEditTool, GlobTool, GrepTool - command/: SlashCommand接口 + CommandRegistry + /help, /clear, /exit - console/: AnsiStyle, BannerPrinter, ToolStatusRenderer, ThinkingRenderer, SpinnerAnimation, MarkdownRenderer - context/: SystemPromptBuilder, ClaudeMdLoader(多级CLAUDE.md加载) - repl/ReplSession: REPL主循环(Scanner降级方案) - config/AppConfig: Spring Bean装配 - application.yml: Anthropic/OpenAI双模型配置 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
2a565d314d
commit
d67f41358d
@ -0,0 +1,18 @@ |
||||
# Maven |
||||
target/ |
||||
*.class |
||||
|
||||
# IDE |
||||
.idea/ |
||||
*.iml |
||||
.vscode/ |
||||
.settings/ |
||||
.project |
||||
.classpath |
||||
|
||||
# OS |
||||
.DS_Store |
||||
Thumbs.db |
||||
|
||||
# Logs |
||||
*.log |
||||
@ -0,0 +1,116 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" |
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
||||
<modelVersion>4.0.0</modelVersion> |
||||
|
||||
<parent> |
||||
<groupId>org.springframework.boot</groupId> |
||||
<artifactId>spring-boot-starter-parent</artifactId> |
||||
<version>3.5.3</version> |
||||
<relativePath/> |
||||
</parent> |
||||
|
||||
<groupId>com.claudecode</groupId> |
||||
<artifactId>claude-code-java</artifactId> |
||||
<version>0.1.0-SNAPSHOT</version> |
||||
<name>claude-code-java</name> |
||||
<description>Claude Code 的 Java Spring AI 重写实现</description> |
||||
<packaging>jar</packaging> |
||||
|
||||
<properties> |
||||
<java.version>25</java.version> |
||||
<spring-ai.version>2.0.0-M4</spring-ai.version> |
||||
<jline.version>3.28.0</jline.version> |
||||
<picocli.version>4.7.6</picocli.version> |
||||
</properties> |
||||
|
||||
<dependencyManagement> |
||||
<dependencies> |
||||
<dependency> |
||||
<groupId>org.springframework.ai</groupId> |
||||
<artifactId>spring-ai-bom</artifactId> |
||||
<version>${spring-ai.version}</version> |
||||
<type>pom</type> |
||||
<scope>import</scope> |
||||
</dependency> |
||||
</dependencies> |
||||
</dependencyManagement> |
||||
|
||||
<dependencies> |
||||
<!-- Spring Boot Web(用于 HTTP 功能) --> |
||||
<dependency> |
||||
<groupId>org.springframework.boot</groupId> |
||||
<artifactId>spring-boot-starter-web</artifactId> |
||||
</dependency> |
||||
|
||||
<!-- Spring AI Anthropic(Claude 模型调用) --> |
||||
<dependency> |
||||
<groupId>org.springframework.ai</groupId> |
||||
<artifactId>spring-ai-starter-model-anthropic</artifactId> |
||||
</dependency> |
||||
|
||||
<!-- Spring AI OpenAI(兼容 OpenAI 格式的 API) --> |
||||
<dependency> |
||||
<groupId>org.springframework.ai</groupId> |
||||
<artifactId>spring-ai-starter-model-openai</artifactId> |
||||
</dependency> |
||||
|
||||
<!-- JLine 3: 终端交互(行编辑、历史、Tab补全、ANSI样式) --> |
||||
<dependency> |
||||
<groupId>org.jline</groupId> |
||||
<artifactId>jline</artifactId> |
||||
<version>${jline.version}</version> |
||||
</dependency> |
||||
|
||||
<!-- Picocli: CLI 命令解析 --> |
||||
<dependency> |
||||
<groupId>info.picocli</groupId> |
||||
<artifactId>picocli</artifactId> |
||||
<version>${picocli.version}</version> |
||||
</dependency> |
||||
|
||||
<!-- Jackson(JSON 处理) --> |
||||
<dependency> |
||||
<groupId>com.fasterxml.jackson.core</groupId> |
||||
<artifactId>jackson-databind</artifactId> |
||||
</dependency> |
||||
|
||||
<!-- Test --> |
||||
<dependency> |
||||
<groupId>org.springframework.boot</groupId> |
||||
<artifactId>spring-boot-starter-test</artifactId> |
||||
<scope>test</scope> |
||||
</dependency> |
||||
</dependencies> |
||||
|
||||
<build> |
||||
<plugins> |
||||
<plugin> |
||||
<groupId>org.springframework.boot</groupId> |
||||
<artifactId>spring-boot-maven-plugin</artifactId> |
||||
</plugin> |
||||
<plugin> |
||||
<groupId>org.apache.maven.plugins</groupId> |
||||
<artifactId>maven-compiler-plugin</artifactId> |
||||
<configuration> |
||||
<release>${java.version}</release> |
||||
<compilerArgs> |
||||
<arg>--enable-preview</arg> |
||||
</compilerArgs> |
||||
</configuration> |
||||
</plugin> |
||||
</plugins> |
||||
</build> |
||||
|
||||
<repositories> |
||||
<repository> |
||||
<id>spring-milestones</id> |
||||
<name>Spring Milestones</name> |
||||
<url>https://repo.spring.io/milestone</url> |
||||
<snapshots> |
||||
<enabled>false</enabled> |
||||
</snapshots> |
||||
</repository> |
||||
</repositories> |
||||
</project> |
||||
@ -0,0 +1,18 @@ |
||||
package com.claudecode; |
||||
|
||||
import org.springframework.boot.SpringApplication; |
||||
import org.springframework.boot.autoconfigure.SpringBootApplication; |
||||
|
||||
/** |
||||
* Claude Code Java 版主入口。 |
||||
* <p> |
||||
* 对应 claude-code/src/entrypoints/cli.tsx |
||||
* 以 Spring Boot 应用启动,但关闭 Web 服务器(纯 CLI 模式)。 |
||||
*/ |
||||
@SpringBootApplication |
||||
public class ClaudeCodeApplication { |
||||
|
||||
public static void main(String[] args) { |
||||
SpringApplication.run(ClaudeCodeApplication.class, args); |
||||
} |
||||
} |
||||
@ -0,0 +1,30 @@ |
||||
package com.claudecode.cli; |
||||
|
||||
import com.claudecode.repl.ReplSession; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.boot.CommandLineRunner; |
||||
import org.springframework.stereotype.Component; |
||||
|
||||
/** |
||||
* 启动编排器 —— 对应 claude-code/src/main.tsx 的初始化逻辑。 |
||||
* <p> |
||||
* 在 Spring Boot 启动完成后执行,初始化并启动 REPL 会话。 |
||||
*/ |
||||
@Component |
||||
public class ClaudeCodeRunner implements CommandLineRunner { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClaudeCodeRunner.class); |
||||
|
||||
private final ReplSession replSession; |
||||
|
||||
public ClaudeCodeRunner(ReplSession replSession) { |
||||
this.replSession = replSession; |
||||
} |
||||
|
||||
@Override |
||||
public void run(String... args) { |
||||
log.info("Claude Code (Java) 启动中..."); |
||||
replSession.start(); |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@ |
||||
package com.claudecode.command; |
||||
|
||||
import com.claudecode.core.AgentLoop; |
||||
import com.claudecode.tool.ToolRegistry; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* 命令执行上下文。 |
||||
*/ |
||||
public record CommandContext( |
||||
AgentLoop agentLoop, |
||||
ToolRegistry toolRegistry, |
||||
PrintStream out, |
||||
Runnable exitCallback |
||||
) { |
||||
} |
||||
@ -0,0 +1,66 @@ |
||||
package com.claudecode.command; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.util.*; |
||||
|
||||
/** |
||||
* 命令注册中心 —— 对应 claude-code/src/commands.ts 中的命令集合管理。 |
||||
*/ |
||||
public class CommandRegistry { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CommandRegistry.class); |
||||
|
||||
private final Map<String, SlashCommand> commands = new LinkedHashMap<>(); |
||||
|
||||
/** 注册命令(包括别名) */ |
||||
public void register(SlashCommand command) { |
||||
commands.put(command.name().toLowerCase(), command); |
||||
for (String alias : command.aliases()) { |
||||
commands.put(alias.toLowerCase(), command); |
||||
} |
||||
log.debug("注册命令: /{}", command.name()); |
||||
} |
||||
|
||||
/** 批量注册 */ |
||||
public void registerAll(SlashCommand... cmds) { |
||||
for (SlashCommand cmd : cmds) { |
||||
register(cmd); |
||||
} |
||||
} |
||||
|
||||
/** 解析并执行命令 */ |
||||
public Optional<String> dispatch(String input, CommandContext context) { |
||||
if (!input.startsWith("/")) { |
||||
return Optional.empty(); |
||||
} |
||||
|
||||
String stripped = input.substring(1).strip(); |
||||
String[] parts = stripped.split("\\s+", 2); |
||||
String cmdName = parts[0].toLowerCase(); |
||||
String args = parts.length > 1 ? parts[1] : ""; |
||||
|
||||
SlashCommand cmd = commands.get(cmdName); |
||||
if (cmd == null) { |
||||
return Optional.of("Unknown command: /" + cmdName + ". Type /help for available commands."); |
||||
} |
||||
|
||||
return Optional.of(cmd.execute(args, context)); |
||||
} |
||||
|
||||
/** 判断输入是否是斜杠命令 */ |
||||
public boolean isCommand(String input) { |
||||
return input != null && input.startsWith("/"); |
||||
} |
||||
|
||||
/** 获取所有唯一命令(用于 /help) */ |
||||
public List<SlashCommand> getCommands() { |
||||
return commands.values().stream().distinct().toList(); |
||||
} |
||||
|
||||
/** 获取命令名称(用于 Tab 补全) */ |
||||
public Set<String> getCommandNames() { |
||||
return Set.copyOf(commands.keySet()); |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@ |
||||
package com.claudecode.command; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* 斜杠命令接口 —— 对应 claude-code/src/commands.ts 中的 Command 类型。 |
||||
* <p> |
||||
* 用于处理以 / 开头的用户输入命令。 |
||||
*/ |
||||
public interface SlashCommand { |
||||
|
||||
/** 命令名称(不含 / 前缀) */ |
||||
String name(); |
||||
|
||||
/** 命令描述 */ |
||||
String description(); |
||||
|
||||
/** 命令别名列表 */ |
||||
default List<String> aliases() { |
||||
return List.of(); |
||||
} |
||||
|
||||
/** |
||||
* 执行命令。 |
||||
* |
||||
* @param args 命令参数(/ 后的文本去掉命令名后的部分) |
||||
* @param context 命令执行上下文 |
||||
* @return 命令输出文本 |
||||
*/ |
||||
String execute(String args, CommandContext context); |
||||
} |
||||
@ -0,0 +1,29 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
|
||||
/** |
||||
* /clear 命令 —— 清除对话历史。 |
||||
*/ |
||||
public class ClearCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "clear"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Clear conversation history"; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
if (context.agentLoop() != null) { |
||||
context.agentLoop().reset(); |
||||
} |
||||
return AnsiStyle.green(" ✓ Conversation history cleared."); |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* /exit 命令 —— 退出应用。 |
||||
*/ |
||||
public class ExitCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "exit"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Exit the application"; |
||||
} |
||||
|
||||
@Override |
||||
public List<String> aliases() { |
||||
return List.of("quit", "q"); |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
if (context.exitCallback() != null) { |
||||
context.exitCallback().run(); |
||||
} |
||||
return "Goodbye!"; |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@ |
||||
package com.claudecode.command.impl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.SlashCommand; |
||||
import com.claudecode.console.AnsiStyle; |
||||
|
||||
import java.util.List; |
||||
|
||||
/** |
||||
* /help 命令 —— 对应 claude-code/src/commands/help.ts。 |
||||
*/ |
||||
public class HelpCommand implements SlashCommand { |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "help"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return "Show available commands"; |
||||
} |
||||
|
||||
@Override |
||||
public List<String> aliases() { |
||||
return List.of("?"); |
||||
} |
||||
|
||||
@Override |
||||
public String execute(String args, CommandContext context) { |
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append(AnsiStyle.bold("\n Available Commands:\n\n")); |
||||
|
||||
for (SlashCommand cmd : context.toolRegistry() != null |
||||
? List.<SlashCommand>of() // 这里后续会获取注册的命令
|
||||
: List.<SlashCommand>of()) { |
||||
sb.append(String.format(" %s%-12s%s %s%n", |
||||
AnsiStyle.CYAN, "/" + cmd.name(), AnsiStyle.RESET, cmd.description())); |
||||
} |
||||
|
||||
// 硬编码展示(后续重构为动态)
|
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/help", AnsiStyle.RESET, "Show available commands")); |
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/clear", AnsiStyle.RESET, "Clear conversation history")); |
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/compact", AnsiStyle.RESET, "Compact conversation context")); |
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/cost", AnsiStyle.RESET, "Show token usage and cost")); |
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/model", AnsiStyle.RESET, "Show or switch AI model")); |
||||
sb.append(String.format(" %s%-12s%s %s%n", AnsiStyle.CYAN, "/exit", AnsiStyle.RESET, "Exit the application")); |
||||
|
||||
sb.append("\n"); |
||||
sb.append(AnsiStyle.dim(" Tips: Press Tab for command completion, Ctrl+D to exit\n")); |
||||
|
||||
return sb.toString(); |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
package com.claudecode.config; |
||||
|
||||
import com.claudecode.command.CommandRegistry; |
||||
import com.claudecode.command.impl.ClearCommand; |
||||
import com.claudecode.command.impl.ExitCommand; |
||||
import com.claudecode.command.impl.HelpCommand; |
||||
import com.claudecode.context.ClaudeMdLoader; |
||||
import com.claudecode.context.SystemPromptBuilder; |
||||
import com.claudecode.core.AgentLoop; |
||||
import com.claudecode.repl.ReplSession; |
||||
import com.claudecode.tool.ToolContext; |
||||
import com.claudecode.tool.ToolRegistry; |
||||
import com.claudecode.tool.impl.*; |
||||
import org.springframework.ai.chat.model.ChatModel; |
||||
import org.springframework.context.annotation.Bean; |
||||
import org.springframework.context.annotation.Configuration; |
||||
|
||||
import java.nio.file.Path; |
||||
|
||||
/** |
||||
* 应用配置类 —— Spring Bean 装配。 |
||||
* <p> |
||||
* 集中管理所有组件的创建和依赖注入。 |
||||
*/ |
||||
@Configuration |
||||
public class AppConfig { |
||||
|
||||
@Bean |
||||
public ToolContext toolContext() { |
||||
return ToolContext.defaultContext(); |
||||
} |
||||
|
||||
@Bean |
||||
public ToolRegistry toolRegistry() { |
||||
ToolRegistry registry = new ToolRegistry(); |
||||
registry.registerAll( |
||||
new BashTool(), |
||||
new FileReadTool(), |
||||
new FileWriteTool(), |
||||
new FileEditTool(), |
||||
new GlobTool(), |
||||
new GrepTool() |
||||
); |
||||
return registry; |
||||
} |
||||
|
||||
@Bean |
||||
public CommandRegistry commandRegistry() { |
||||
CommandRegistry registry = new CommandRegistry(); |
||||
registry.registerAll( |
||||
new HelpCommand(), |
||||
new ClearCommand(), |
||||
new ExitCommand() |
||||
); |
||||
return registry; |
||||
} |
||||
|
||||
@Bean |
||||
public String systemPrompt() { |
||||
Path projectDir = Path.of(System.getProperty("user.dir")); |
||||
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir); |
||||
String claudeMd = loader.load(); |
||||
|
||||
return new SystemPromptBuilder() |
||||
.claudeMd(claudeMd) |
||||
.build(); |
||||
} |
||||
|
||||
@Bean |
||||
public AgentLoop agentLoop(ChatModel chatModel, ToolRegistry toolRegistry, |
||||
ToolContext toolContext, String systemPrompt) { |
||||
return new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); |
||||
} |
||||
|
||||
@Bean |
||||
public ReplSession replSession(AgentLoop agentLoop, ToolRegistry toolRegistry, |
||||
CommandRegistry commandRegistry) { |
||||
return new ReplSession(agentLoop, toolRegistry, commandRegistry); |
||||
} |
||||
} |
||||
@ -0,0 +1,108 @@ |
||||
package com.claudecode.console; |
||||
|
||||
/** |
||||
* ANSI 终端样式工具类 —— 对应 claude-code/src/utils/terminal.ts 的颜色输出功能。 |
||||
* <p> |
||||
* 提供终端颜色和样式的便捷方法。 |
||||
*/ |
||||
public final class AnsiStyle { |
||||
|
||||
private AnsiStyle() {} |
||||
|
||||
// 重置
|
||||
public static final String RESET = "\033[0m"; |
||||
|
||||
// 基本样式
|
||||
public static final String BOLD = "\033[1m"; |
||||
public static final String DIM = "\033[2m"; |
||||
public static final String ITALIC = "\033[3m"; |
||||
public static final String UNDERLINE = "\033[4m"; |
||||
|
||||
// 前景色
|
||||
public static final String BLACK = "\033[30m"; |
||||
public static final String RED = "\033[31m"; |
||||
public static final String GREEN = "\033[32m"; |
||||
public static final String YELLOW = "\033[33m"; |
||||
public static final String BLUE = "\033[34m"; |
||||
public static final String MAGENTA = "\033[35m"; |
||||
public static final String CYAN = "\033[36m"; |
||||
public static final String WHITE = "\033[37m"; |
||||
|
||||
// 亮色前景
|
||||
public static final String BRIGHT_BLACK = "\033[90m"; |
||||
public static final String BRIGHT_RED = "\033[91m"; |
||||
public static final String BRIGHT_GREEN = "\033[92m"; |
||||
public static final String BRIGHT_YELLOW = "\033[93m"; |
||||
public static final String BRIGHT_BLUE = "\033[94m"; |
||||
public static final String BRIGHT_MAGENTA = "\033[95m"; |
||||
public static final String BRIGHT_CYAN = "\033[96m"; |
||||
public static final String BRIGHT_WHITE = "\033[97m"; |
||||
|
||||
// 背景色
|
||||
public static final String BG_RED = "\033[41m"; |
||||
public static final String BG_GREEN = "\033[42m"; |
||||
public static final String BG_YELLOW = "\033[43m"; |
||||
public static final String BG_BLUE = "\033[44m"; |
||||
|
||||
// ---- 便捷方法 ----
|
||||
|
||||
public static String bold(String text) { |
||||
return BOLD + text + RESET; |
||||
} |
||||
|
||||
public static String dim(String text) { |
||||
return DIM + text + RESET; |
||||
} |
||||
|
||||
public static String italic(String text) { |
||||
return ITALIC + text + RESET; |
||||
} |
||||
|
||||
public static String red(String text) { |
||||
return RED + text + RESET; |
||||
} |
||||
|
||||
public static String green(String text) { |
||||
return GREEN + text + RESET; |
||||
} |
||||
|
||||
public static String yellow(String text) { |
||||
return YELLOW + text + RESET; |
||||
} |
||||
|
||||
public static String blue(String text) { |
||||
return BLUE + text + RESET; |
||||
} |
||||
|
||||
public static String cyan(String text) { |
||||
return CYAN + text + RESET; |
||||
} |
||||
|
||||
public static String magenta(String text) { |
||||
return MAGENTA + text + RESET; |
||||
} |
||||
|
||||
public static String brightBlack(String text) { |
||||
return BRIGHT_BLACK + text + RESET; |
||||
} |
||||
|
||||
/** 带颜色的前缀标签 */ |
||||
public static String tag(String label, String color) { |
||||
return color + BOLD + "[" + label + "]" + RESET; |
||||
} |
||||
|
||||
/** 带背景色的标签 */ |
||||
public static String badge(String label, String bgColor) { |
||||
return bgColor + WHITE + BOLD + " " + label + " " + RESET; |
||||
} |
||||
|
||||
/** 清除当前行 */ |
||||
public static String clearLine() { |
||||
return "\033[2K\r"; |
||||
} |
||||
|
||||
/** 光标上移 n 行 */ |
||||
public static String cursorUp(int n) { |
||||
return "\033[" + n + "A"; |
||||
} |
||||
} |
||||
@ -0,0 +1,51 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* Banner 打印器 —— 对应 claude-code/src/components/Banner.tsx。 |
||||
* <p> |
||||
* 在启动时打印 ASCII Art Logo 和版本信息。 |
||||
*/ |
||||
public class BannerPrinter { |
||||
|
||||
private static final String VERSION = "0.1.0-SNAPSHOT"; |
||||
|
||||
/** |
||||
* 打印 claude-code-java 启动 banner。 |
||||
*/ |
||||
public static void print(PrintStream out) { |
||||
String banner = """ |
||||
%s |
||||
██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗ |
||||
██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝ |
||||
██║ ██║ ███████║██║ ██║██║ ██║█████╗ |
||||
██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝ |
||||
╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗ |
||||
╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ |
||||
%s ██████╗ ██████╗ ██████╗ ███████╗ |
||||
██╔════╝██╔═══██╗██╔══██╗██╔════╝ |
||||
██║ ██║ ██║██║ ██║█████╗ |
||||
██║ ██║ ██║██║ ██║██╔══╝ |
||||
╚██████╗╚██████╔╝██████╔╝███████╗ |
||||
╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ |
||||
%s |
||||
""".formatted(AnsiStyle.BRIGHT_CYAN, AnsiStyle.BRIGHT_MAGENTA, AnsiStyle.RESET); |
||||
|
||||
out.println(banner); |
||||
out.println(AnsiStyle.bold(" Claude Code (Java)") + AnsiStyle.dim(" v" + VERSION)); |
||||
out.println(AnsiStyle.dim(" Powered by Spring AI • Type /help for commands")); |
||||
out.println(); |
||||
} |
||||
|
||||
/** |
||||
* 精简版 banner(用于窄终端)。 |
||||
*/ |
||||
public static void printCompact(PrintStream out) { |
||||
out.println(); |
||||
out.println(AnsiStyle.BRIGHT_CYAN + AnsiStyle.BOLD + " ◆ Claude Code (Java)" + AnsiStyle.RESET |
||||
+ AnsiStyle.dim(" v" + VERSION)); |
||||
out.println(AnsiStyle.dim(" Type /help for commands • Ctrl+D to exit")); |
||||
out.println(); |
||||
} |
||||
} |
||||
@ -0,0 +1,79 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* Markdown 简易渲染器 —— 对应 claude-code/src/renderers/markdown.ts。 |
||||
* <p> |
||||
* 将 AI 回复中的 Markdown 格式转换为终端 ANSI 样式输出。 |
||||
* 这是一个简化版,支持常见格式。 |
||||
*/ |
||||
public class MarkdownRenderer { |
||||
|
||||
private final PrintStream out; |
||||
|
||||
public MarkdownRenderer(PrintStream out) { |
||||
this.out = out; |
||||
} |
||||
|
||||
/** 渲染 Markdown 文本 */ |
||||
public void render(String markdown) { |
||||
if (markdown == null || markdown.isBlank()) return; |
||||
|
||||
boolean inCodeBlock = false; |
||||
String codeBlockLang = ""; |
||||
|
||||
for (String line : markdown.lines().toList()) { |
||||
// 代码块
|
||||
if (line.stripLeading().startsWith("```")) { |
||||
if (!inCodeBlock) { |
||||
codeBlockLang = line.stripLeading().substring(3).strip(); |
||||
inCodeBlock = true; |
||||
out.println(AnsiStyle.dim(" ┌─" + (codeBlockLang.isEmpty() ? "code" : codeBlockLang) + "─")); |
||||
continue; |
||||
} else { |
||||
inCodeBlock = false; |
||||
out.println(AnsiStyle.dim(" └─────")); |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
if (inCodeBlock) { |
||||
out.println(AnsiStyle.BRIGHT_GREEN + " │ " + line + AnsiStyle.RESET); |
||||
continue; |
||||
} |
||||
|
||||
// 标题
|
||||
if (line.startsWith("### ")) { |
||||
out.println(AnsiStyle.bold(AnsiStyle.CYAN + " " + line.substring(4)) + AnsiStyle.RESET); |
||||
} else if (line.startsWith("## ")) { |
||||
out.println(AnsiStyle.bold(AnsiStyle.BLUE + " " + line.substring(3)) + AnsiStyle.RESET); |
||||
} else if (line.startsWith("# ")) { |
||||
out.println(AnsiStyle.bold(AnsiStyle.MAGENTA + " " + line.substring(2)) + AnsiStyle.RESET); |
||||
} |
||||
// 列表项
|
||||
else if (line.stripLeading().startsWith("- ") || line.stripLeading().startsWith("* ")) { |
||||
out.println(" " + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(line.stripLeading().substring(2))); |
||||
} |
||||
// 分隔线
|
||||
else if (line.strip().matches("^-{3,}$") || line.strip().matches("^\\*{3,}$")) { |
||||
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||
} |
||||
// 普通文本
|
||||
else { |
||||
out.println(" " + renderInline(line)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** 行内格式渲染 */ |
||||
private String renderInline(String text) { |
||||
// 粗体 **text**
|
||||
text = text.replaceAll("\\*\\*(.+?)\\*\\*", AnsiStyle.BOLD + "$1" + AnsiStyle.RESET); |
||||
// 行内代码 `text`
|
||||
text = text.replaceAll("`(.+?)`", AnsiStyle.BRIGHT_GREEN + "$1" + AnsiStyle.RESET); |
||||
// 斜体 *text*
|
||||
text = text.replaceAll("\\*(.+?)\\*", AnsiStyle.ITALIC + "$1" + AnsiStyle.RESET); |
||||
return text; |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* 加载动画(Spinner)—— 对应 claude-code/src/components/Spinner.tsx。 |
||||
* <p> |
||||
* 在等待 AI 响应时显示旋转动画。 |
||||
*/ |
||||
public class SpinnerAnimation { |
||||
|
||||
private static final String[] FRAMES = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}; |
||||
private static final int INTERVAL_MS = 80; |
||||
|
||||
private final PrintStream out; |
||||
private volatile boolean running; |
||||
private Thread thread; |
||||
private String message = "Thinking"; |
||||
|
||||
public SpinnerAnimation(PrintStream out) { |
||||
this.out = out; |
||||
} |
||||
|
||||
/** 启动 spinner */ |
||||
public void start(String message) { |
||||
if (running) return; |
||||
this.message = message; |
||||
this.running = true; |
||||
|
||||
thread = Thread.ofVirtual().name("spinner").start(() -> { |
||||
int idx = 0; |
||||
while (running) { |
||||
out.print(AnsiStyle.clearLine()); |
||||
out.print(AnsiStyle.CYAN + " " + FRAMES[idx % FRAMES.length] |
||||
+ " " + AnsiStyle.RESET + AnsiStyle.dim(this.message)); |
||||
out.flush(); |
||||
idx++; |
||||
try { |
||||
Thread.sleep(INTERVAL_MS); |
||||
} catch (InterruptedException e) { |
||||
Thread.currentThread().interrupt(); |
||||
break; |
||||
} |
||||
} |
||||
// 清除 spinner 行
|
||||
out.print(AnsiStyle.clearLine()); |
||||
out.flush(); |
||||
}); |
||||
} |
||||
|
||||
/** 停止 spinner */ |
||||
public void stop() { |
||||
running = false; |
||||
if (thread != null) { |
||||
thread.interrupt(); |
||||
try { |
||||
thread.join(200); |
||||
} catch (InterruptedException e) { |
||||
Thread.currentThread().interrupt(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** 更新消息 */ |
||||
public void updateMessage(String newMessage) { |
||||
this.message = newMessage; |
||||
} |
||||
|
||||
public boolean isRunning() { |
||||
return running; |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* Thinking 内容渲染器 —— 对应 claude-code/src/components/Thinking.tsx。 |
||||
* <p> |
||||
* 显示 AI 模型的思考过程(extended thinking)。 |
||||
*/ |
||||
public class ThinkingRenderer { |
||||
|
||||
private final PrintStream out; |
||||
|
||||
public ThinkingRenderer(PrintStream out) { |
||||
this.out = out; |
||||
} |
||||
|
||||
/** 渲染 thinking 内容块 */ |
||||
public void render(String thinkingContent) { |
||||
if (thinkingContent == null || thinkingContent.isBlank()) { |
||||
return; |
||||
} |
||||
|
||||
out.println(); |
||||
out.println(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET); |
||||
|
||||
// 显示 thinking 内容(缩进并用暗色)
|
||||
for (String line : thinkingContent.lines().toList()) { |
||||
out.println(AnsiStyle.DIM + " │ " + line + AnsiStyle.RESET); |
||||
} |
||||
out.println(); |
||||
} |
||||
|
||||
/** 渲染 thinking 开始标记 */ |
||||
public void renderStart() { |
||||
out.print(AnsiStyle.DIM + AnsiStyle.ITALIC + " 💭 Thinking..." + AnsiStyle.RESET); |
||||
} |
||||
|
||||
/** 渲染 thinking 结束标记 */ |
||||
public void renderEnd() { |
||||
out.println(AnsiStyle.clearLine()); |
||||
} |
||||
} |
||||
@ -0,0 +1,94 @@ |
||||
package com.claudecode.console; |
||||
|
||||
import java.io.PrintStream; |
||||
|
||||
/** |
||||
* 工具调用状态渲染器 —— 对应 claude-code/src/components/ToolStatus.tsx。 |
||||
* <p> |
||||
* 在终端中显示工具调用的进度和结果。 |
||||
*/ |
||||
public class ToolStatusRenderer { |
||||
|
||||
private final PrintStream out; |
||||
|
||||
public ToolStatusRenderer(PrintStream out) { |
||||
this.out = out; |
||||
} |
||||
|
||||
/** 渲染工具调用开始 */ |
||||
public void renderStart(String toolName, String args) { |
||||
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||
out.print(AnsiStyle.YELLOW + " ⚙ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET); |
||||
out.println(AnsiStyle.dim(" running...")); |
||||
// 如果有简短参数,显示
|
||||
if (args != null && !args.isBlank()) { |
||||
String summary = extractSummary(toolName, args); |
||||
if (summary != null) { |
||||
out.println(AnsiStyle.dim(" " + summary)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** 渲染工具调用完成 */ |
||||
public void renderEnd(String toolName, String result) { |
||||
// 截断长结果
|
||||
String display = result; |
||||
if (display != null && display.length() > 500) { |
||||
display = display.substring(0, 497) + "..."; |
||||
} |
||||
|
||||
out.println(AnsiStyle.GREEN + " ✓ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET |
||||
+ AnsiStyle.dim(" done")); |
||||
if (display != null && !display.isBlank()) { |
||||
// 缩进输出每一行
|
||||
for (String line : display.lines().toList()) { |
||||
out.println(AnsiStyle.dim(" " + line)); |
||||
} |
||||
} |
||||
out.println(AnsiStyle.dim(" ─────────────────────────────────────────")); |
||||
} |
||||
|
||||
/** 渲染工具错误 */ |
||||
public void renderError(String toolName, String error) { |
||||
out.println(AnsiStyle.RED + " ✗ " + AnsiStyle.BOLD + toolName + AnsiStyle.RESET |
||||
+ AnsiStyle.red(" error")); |
||||
if (error != null) { |
||||
out.println(AnsiStyle.red(" " + error)); |
||||
} |
||||
} |
||||
|
||||
/** 从 JSON 参数中提取人类可读的摘要 */ |
||||
private String extractSummary(String toolName, String args) { |
||||
try { |
||||
// 简单提取关键字段
|
||||
if (args.contains("\"command\"")) { |
||||
int start = args.indexOf("\"command\""); |
||||
int valStart = args.indexOf("\"", start + 10) + 1; |
||||
int valEnd = args.indexOf("\"", valStart); |
||||
if (valStart > 0 && valEnd > valStart) { |
||||
String cmd = args.substring(valStart, Math.min(valEnd, valStart + 80)); |
||||
return "$ " + cmd; |
||||
} |
||||
} |
||||
if (args.contains("\"file_path\"")) { |
||||
int start = args.indexOf("\"file_path\""); |
||||
int valStart = args.indexOf("\"", start + 12) + 1; |
||||
int valEnd = args.indexOf("\"", valStart); |
||||
if (valStart > 0 && valEnd > valStart) { |
||||
return args.substring(valStart, valEnd); |
||||
} |
||||
} |
||||
if (args.contains("\"pattern\"")) { |
||||
int start = args.indexOf("\"pattern\""); |
||||
int valStart = args.indexOf("\"", start + 10) + 1; |
||||
int valEnd = args.indexOf("\"", valStart); |
||||
if (valStart > 0 && valEnd > valStart) { |
||||
return "pattern: " + args.substring(valStart, valEnd); |
||||
} |
||||
} |
||||
} catch (Exception e) { |
||||
// 忽略解析错误
|
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
@ -0,0 +1,91 @@ |
||||
package com.claudecode.context; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
|
||||
/** |
||||
* CLAUDE.md 加载器 —— 对应 claude-code/src/context.ts 中的 CLAUDE.md 加载逻辑。 |
||||
* <p> |
||||
* 按优先级从低到高加载: |
||||
* <ol> |
||||
* <li>系统级: /etc/claude-code/CLAUDE.md (Unix) 或默认模板</li> |
||||
* <li>用户级: ~/.claude/CLAUDE.md</li> |
||||
* <li>项目级: ./CLAUDE.md 或 ./.claude/CLAUDE.md</li> |
||||
* <li>本地级: ./CLAUDE.local.md</li> |
||||
* </ol> |
||||
*/ |
||||
public class ClaudeMdLoader { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClaudeMdLoader.class); |
||||
|
||||
private final Path projectDir; |
||||
|
||||
public ClaudeMdLoader(Path projectDir) { |
||||
this.projectDir = projectDir; |
||||
} |
||||
|
||||
/** |
||||
* 加载并合并所有 CLAUDE.md 内容。 |
||||
*/ |
||||
public String load() { |
||||
List<String> sections = new ArrayList<>(); |
||||
|
||||
// 1. 用户级
|
||||
Path userMd = Path.of(System.getProperty("user.home"), ".claude", "CLAUDE.md"); |
||||
loadFile(userMd, "user").ifPresent(sections::add); |
||||
|
||||
// 2. 项目级 —— 优先检查 .claude/CLAUDE.md,然后 CLAUDE.md
|
||||
Path projectClaudeDir = projectDir.resolve(".claude").resolve("CLAUDE.md"); |
||||
Path projectRoot = projectDir.resolve("CLAUDE.md"); |
||||
if (Files.exists(projectClaudeDir)) { |
||||
loadFile(projectClaudeDir, "project").ifPresent(sections::add); |
||||
} else { |
||||
loadFile(projectRoot, "project").ifPresent(sections::add); |
||||
} |
||||
|
||||
// 3. 本地级
|
||||
Path localMd = projectDir.resolve("CLAUDE.local.md"); |
||||
loadFile(localMd, "local").ifPresent(sections::add); |
||||
|
||||
// 4. 加载 .claude/rules/*.md 目录
|
||||
Path rulesDir = projectDir.resolve(".claude").resolve("rules"); |
||||
if (Files.isDirectory(rulesDir)) { |
||||
try (var stream = Files.list(rulesDir)) { |
||||
stream.filter(p -> p.toString().endsWith(".md")) |
||||
.sorted() |
||||
.forEach(p -> loadFile(p, "rule").ifPresent(sections::add)); |
||||
} catch (IOException e) { |
||||
log.debug("加载规则目录失败: {}", e.getMessage()); |
||||
} |
||||
} |
||||
|
||||
if (sections.isEmpty()) { |
||||
return ""; |
||||
} |
||||
|
||||
return String.join("\n\n---\n\n", sections); |
||||
} |
||||
|
||||
private java.util.Optional<String> loadFile(Path path, String level) { |
||||
if (!Files.exists(path) || !Files.isRegularFile(path)) { |
||||
return java.util.Optional.empty(); |
||||
} |
||||
try { |
||||
String content = Files.readString(path, StandardCharsets.UTF_8).strip(); |
||||
if (!content.isEmpty()) { |
||||
log.debug("已加载 {} 级 CLAUDE.md: {}", level, path); |
||||
return java.util.Optional.of(content); |
||||
} |
||||
} catch (IOException e) { |
||||
log.warn("读取 {} 失败: {}", path, e.getMessage()); |
||||
} |
||||
return java.util.Optional.empty(); |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@ |
||||
package com.claudecode.context; |
||||
|
||||
/** |
||||
* 系统提示词构建器 —— 对应 claude-code/src/prompts.ts。 |
||||
* <p> |
||||
* 组装完整的系统提示词,包括核心指令、环境信息、工具说明等。 |
||||
*/ |
||||
public class SystemPromptBuilder { |
||||
|
||||
private String workDir; |
||||
private String osName; |
||||
private String userName; |
||||
private String claudeMdContent; |
||||
private String customInstructions; |
||||
|
||||
public SystemPromptBuilder() { |
||||
this.workDir = System.getProperty("user.dir"); |
||||
this.osName = System.getProperty("os.name"); |
||||
this.userName = System.getProperty("user.name"); |
||||
} |
||||
|
||||
public SystemPromptBuilder workDir(String workDir) { |
||||
this.workDir = workDir; |
||||
return this; |
||||
} |
||||
|
||||
public SystemPromptBuilder claudeMd(String content) { |
||||
this.claudeMdContent = content; |
||||
return this; |
||||
} |
||||
|
||||
public SystemPromptBuilder customInstructions(String instructions) { |
||||
this.customInstructions = instructions; |
||||
return this; |
||||
} |
||||
|
||||
/** |
||||
* 构建完整的系统提示词。 |
||||
*/ |
||||
public String build() { |
||||
StringBuilder sb = new StringBuilder(); |
||||
|
||||
// 核心角色定义
|
||||
sb.append(""" |
||||
You are Claude, an AI assistant made by Anthropic, operating as a CLI coding agent. |
||||
You are an interactive CLI tool that helps users with software engineering tasks. |
||||
Use the provided tools to help the user with their request. |
||||
|
||||
"""); |
||||
|
||||
// 环境信息
|
||||
sb.append("# Environment\n"); |
||||
sb.append("- Working directory: ").append(workDir).append("\n"); |
||||
sb.append("- OS: ").append(osName).append("\n"); |
||||
sb.append("- User: ").append(userName).append("\n"); |
||||
sb.append("\n"); |
||||
|
||||
// 行为准则
|
||||
sb.append(""" |
||||
# Guidelines |
||||
- Be concise in responses, but thorough in implementation |
||||
- Always verify changes work before considering a task done |
||||
- Use tools to explore the codebase before making changes |
||||
- When writing code, follow existing patterns and conventions |
||||
- Ask for clarification when requirements are ambiguous |
||||
|
||||
"""); |
||||
|
||||
// CLAUDE.md 内容
|
||||
if (claudeMdContent != null && !claudeMdContent.isBlank()) { |
||||
sb.append("# Project Instructions (CLAUDE.md)\n"); |
||||
sb.append(claudeMdContent).append("\n\n"); |
||||
} |
||||
|
||||
// 自定义指令
|
||||
if (customInstructions != null && !customInstructions.isBlank()) { |
||||
sb.append("# Custom Instructions\n"); |
||||
sb.append(customInstructions).append("\n\n"); |
||||
} |
||||
|
||||
return sb.toString(); |
||||
} |
||||
} |
||||
@ -0,0 +1,182 @@ |
||||
package com.claudecode.core; |
||||
|
||||
import com.claudecode.tool.ToolCallbackAdapter; |
||||
import com.claudecode.tool.ToolContext; |
||||
import com.claudecode.tool.ToolRegistry; |
||||
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.chat.model.ChatModel; |
||||
import org.springframework.ai.chat.model.ChatResponse; |
||||
import org.springframework.ai.chat.messages.*; |
||||
import org.springframework.ai.chat.prompt.ChatOptions; |
||||
import org.springframework.ai.chat.prompt.Prompt; |
||||
import org.springframework.ai.model.tool.ToolCallingChatOptions; |
||||
import org.springframework.ai.tool.ToolCallback; |
||||
|
||||
import java.util.*; |
||||
import java.util.function.Consumer; |
||||
|
||||
/** |
||||
* Agent 循环 —— 对应 claude-code/src/core/query.ts 的 agent loop。 |
||||
* <p> |
||||
* 使用 ChatModel(非 ChatClient)的显式循环,完整控制每一轮: |
||||
* <ol> |
||||
* <li>构建 Prompt(消息历史 + 系统提示 + 工具定义)</li> |
||||
* <li>调用 ChatModel.call()</li> |
||||
* <li>检查工具调用 → 执行工具 → 结果回传</li> |
||||
* <li>循环直到无工具调用或达到最大迭代</li> |
||||
* </ol> |
||||
*/ |
||||
public class AgentLoop { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentLoop.class); |
||||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
||||
/** 单轮最大迭代次数,防止无限循环 */ |
||||
private static final int MAX_ITERATIONS = 50; |
||||
|
||||
private final ChatModel chatModel; |
||||
private final ToolRegistry toolRegistry; |
||||
private final ToolContext toolContext; |
||||
private final String systemPrompt; |
||||
|
||||
/** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */ |
||||
private final List<Message> messageHistory = new ArrayList<>(); |
||||
|
||||
/** 工具调用事件回调:在每次工具调用前/后通知 UI */ |
||||
private Consumer<ToolEvent> onToolEvent; |
||||
|
||||
/** 助手文本回调:在每次助手回复时通知 UI */ |
||||
private Consumer<String> onAssistantMessage; |
||||
|
||||
public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, |
||||
ToolContext toolContext, String systemPrompt) { |
||||
this.chatModel = chatModel; |
||||
this.toolRegistry = toolRegistry; |
||||
this.toolContext = toolContext; |
||||
this.systemPrompt = systemPrompt; |
||||
// 添加系统提示词到消息历史
|
||||
this.messageHistory.add(new SystemMessage(systemPrompt)); |
||||
} |
||||
|
||||
public void setOnToolEvent(Consumer<ToolEvent> onToolEvent) { |
||||
this.onToolEvent = onToolEvent; |
||||
} |
||||
|
||||
public void setOnAssistantMessage(Consumer<String> onAssistantMessage) { |
||||
this.onAssistantMessage = onAssistantMessage; |
||||
} |
||||
|
||||
/** |
||||
* 执行一轮用户输入的完整 Agent 循环。 |
||||
* |
||||
* @param userInput 用户输入文本 |
||||
* @return 最终助手回复文本 |
||||
*/ |
||||
public String run(String userInput) { |
||||
messageHistory.add(new UserMessage(userInput)); |
||||
|
||||
List<ToolCallback> callbacks = toolRegistry.toCallbacks(toolContext); |
||||
ChatOptions options = ToolCallingChatOptions.builder() |
||||
.toolCallbacks(callbacks) |
||||
.internalToolExecutionEnabled(false) |
||||
.build(); |
||||
|
||||
int iteration = 0; |
||||
String lastAssistantText = ""; |
||||
|
||||
while (iteration < MAX_ITERATIONS) { |
||||
iteration++; |
||||
log.debug("Agent 循环 第{}轮", iteration); |
||||
|
||||
Prompt prompt = new Prompt(List.copyOf(messageHistory), options); |
||||
ChatResponse response = chatModel.call(prompt); |
||||
|
||||
AssistantMessage assistant = response.getResult().getOutput(); |
||||
messageHistory.add(assistant); |
||||
|
||||
// 提取并通知助手文本
|
||||
String text = assistant.getText(); |
||||
if (text != null && !text.isBlank()) { |
||||
lastAssistantText = text; |
||||
if (onAssistantMessage != null) { |
||||
onAssistantMessage.accept(text); |
||||
} |
||||
} |
||||
|
||||
// 检查是否有工具调用
|
||||
if (!assistant.hasToolCalls()) { |
||||
log.debug("无工具调用,循环结束(共{}轮)", iteration); |
||||
break; |
||||
} |
||||
|
||||
// 逐个执行工具调用
|
||||
List<ToolResponseMessage.ToolResponse> toolResponses = new ArrayList<>(); |
||||
for (AssistantMessage.ToolCall toolCall : assistant.getToolCalls()) { |
||||
String toolName = toolCall.name(); |
||||
String toolArgs = toolCall.arguments(); |
||||
String callId = toolCall.id(); |
||||
|
||||
// 通知 UI 工具调用开始
|
||||
if (onToolEvent != null) { |
||||
onToolEvent.accept(new ToolEvent(toolName, ToolEvent.Phase.START, toolArgs, null)); |
||||
} |
||||
|
||||
// 查找并执行工具
|
||||
String result; |
||||
ToolCallbackAdapter adapter = findCallbackByName(callbacks, toolName); |
||||
if (adapter != null) { |
||||
result = adapter.call(toolArgs); |
||||
} else { |
||||
result = "Error: Unknown tool '" + toolName + "'"; |
||||
log.warn("未知工具: {}", toolName); |
||||
} |
||||
|
||||
// 通知 UI 工具调用完成
|
||||
if (onToolEvent != null) { |
||||
onToolEvent.accept(new ToolEvent(toolName, ToolEvent.Phase.END, toolArgs, result)); |
||||
} |
||||
|
||||
toolResponses.add(new ToolResponseMessage.ToolResponse(callId, toolName, result)); |
||||
} |
||||
|
||||
// 将工具结果加入消息历史
|
||||
messageHistory.add(ToolResponseMessage.builder().responses(toolResponses).build()); |
||||
} |
||||
|
||||
if (iteration >= MAX_ITERATIONS) { |
||||
log.warn("Agent 循环已达最大迭代次数 {},强制终止", MAX_ITERATIONS); |
||||
lastAssistantText += "\n\n[WARNING: 达到最大循环次数限制]"; |
||||
} |
||||
|
||||
return lastAssistantText; |
||||
} |
||||
|
||||
/** 从 ToolCallback 列表中查找匹配名称的适配器 */ |
||||
private ToolCallbackAdapter findCallbackByName(List<ToolCallback> callbacks, String name) { |
||||
for (ToolCallback cb : callbacks) { |
||||
if (cb instanceof ToolCallbackAdapter adapter && adapter.getTool().name().equals(name)) { |
||||
return adapter; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/** 获取消息历史(用于上下文压缩等场景) */ |
||||
public List<Message> getMessageHistory() { |
||||
return Collections.unmodifiableList(messageHistory); |
||||
} |
||||
|
||||
/** 重置历史(保留系统提示词) */ |
||||
public void reset() { |
||||
messageHistory.clear(); |
||||
messageHistory.add(new SystemMessage(systemPrompt)); |
||||
} |
||||
|
||||
/** 工具事件,用于 UI 展示 */ |
||||
public record ToolEvent(String toolName, Phase phase, String arguments, String result) { |
||||
public enum Phase { START, END } |
||||
} |
||||
} |
||||
@ -0,0 +1,125 @@ |
||||
package com.claudecode.repl; |
||||
|
||||
import com.claudecode.command.CommandContext; |
||||
import com.claudecode.command.CommandRegistry; |
||||
import com.claudecode.console.*; |
||||
import com.claudecode.core.AgentLoop; |
||||
import com.claudecode.tool.ToolRegistry; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
|
||||
import java.io.PrintStream; |
||||
import java.util.Scanner; |
||||
|
||||
/** |
||||
* REPL 会话管理器 —— 对应 claude-code/src/REPL.tsx。 |
||||
* <p> |
||||
* 管理用户输入循环、命令分发、Agent 调用和输出渲染。 |
||||
* 当前版本使用 Scanner 作为输入方式(Phase 2 会升级到 JLine)。 |
||||
*/ |
||||
public class ReplSession { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ReplSession.class); |
||||
|
||||
private final AgentLoop agentLoop; |
||||
private final ToolRegistry toolRegistry; |
||||
private final CommandRegistry commandRegistry; |
||||
private final PrintStream out; |
||||
private final ToolStatusRenderer toolStatusRenderer; |
||||
private final MarkdownRenderer markdownRenderer; |
||||
private final SpinnerAnimation spinner; |
||||
|
||||
private volatile boolean running = true; |
||||
|
||||
public ReplSession(AgentLoop agentLoop, |
||||
ToolRegistry toolRegistry, |
||||
CommandRegistry commandRegistry) { |
||||
this.agentLoop = agentLoop; |
||||
this.toolRegistry = toolRegistry; |
||||
this.commandRegistry = commandRegistry; |
||||
this.out = System.out; |
||||
this.toolStatusRenderer = new ToolStatusRenderer(out); |
||||
this.markdownRenderer = new MarkdownRenderer(out); |
||||
this.spinner = new SpinnerAnimation(out); |
||||
|
||||
// 注册 AgentLoop 事件回调
|
||||
agentLoop.setOnToolEvent(event -> { |
||||
switch (event.phase()) { |
||||
case START -> { |
||||
spinner.stop(); |
||||
toolStatusRenderer.renderStart(event.toolName(), event.arguments()); |
||||
} |
||||
case END -> { |
||||
toolStatusRenderer.renderEnd(event.toolName(), event.result()); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
agentLoop.setOnAssistantMessage(text -> { |
||||
// 助手文本在 agent 循环结束后由 REPL 统一渲染
|
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* 启动 REPL 主循环。 |
||||
*/ |
||||
public void start() { |
||||
BannerPrinter.printCompact(out); |
||||
out.println(AnsiStyle.dim(" Working directory: " + System.getProperty("user.dir"))); |
||||
out.println(AnsiStyle.dim(" Tools: " + toolRegistry.size() + " registered")); |
||||
out.println(); |
||||
|
||||
Scanner scanner = new Scanner(System.in); |
||||
CommandContext cmdContext = new CommandContext(agentLoop, toolRegistry, out, () -> running = false); |
||||
|
||||
while (running) { |
||||
// 输入提示符
|
||||
out.print(AnsiStyle.BOLD + AnsiStyle.BRIGHT_CYAN + " ❯ " + AnsiStyle.RESET); |
||||
out.flush(); |
||||
|
||||
String input; |
||||
try { |
||||
if (!scanner.hasNextLine()) { |
||||
break; // EOF (Ctrl+D)
|
||||
} |
||||
input = scanner.nextLine().strip(); |
||||
} catch (Exception e) { |
||||
break; |
||||
} |
||||
|
||||
if (input.isEmpty()) { |
||||
continue; |
||||
} |
||||
|
||||
// 检查斜杠命令
|
||||
if (commandRegistry.isCommand(input)) { |
||||
var result = commandRegistry.dispatch(input, cmdContext); |
||||
result.ifPresent(out::println); |
||||
out.println(); |
||||
continue; |
||||
} |
||||
|
||||
// 调用 Agent 循环
|
||||
try { |
||||
spinner.start("Thinking..."); |
||||
String response = agentLoop.run(input); |
||||
spinner.stop(); |
||||
|
||||
out.println(); |
||||
markdownRenderer.render(response); |
||||
out.println(); |
||||
} catch (Exception e) { |
||||
spinner.stop(); |
||||
out.println(AnsiStyle.red("\n ✗ Error: " + e.getMessage())); |
||||
log.error("Agent 循环异常", e); |
||||
out.println(); |
||||
} |
||||
} |
||||
|
||||
out.println(AnsiStyle.dim("\n Goodbye! 👋\n")); |
||||
} |
||||
|
||||
public void stop() { |
||||
running = false; |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
/** |
||||
* 工具权限检查结果。 |
||||
* <p> |
||||
* 对应 claude-code 中 Tool.checkPermissions() 的返回值。 |
||||
*/ |
||||
public record PermissionResult(boolean allowed, String message) { |
||||
|
||||
/** 放行 */ |
||||
public static final PermissionResult ALLOW = new PermissionResult(true, null); |
||||
|
||||
/** 拒绝,附带原因 */ |
||||
public static PermissionResult deny(String reason) { |
||||
return new PermissionResult(false, reason); |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* 工具协议接口 —— 对应 claude-code/src/Tool.ts 中的 Tool 类型定义。 |
||||
* <p> |
||||
* 每个工具是一个完整的协议实现,包含: |
||||
* <ul> |
||||
* <li>工具定义(name、description、inputSchema)—— 告知 LLM 如何调用</li> |
||||
* <li>执行逻辑(execute)—— 实际运行</li> |
||||
* <li>权限检查(checkPermission)—— 安全前置检查</li> |
||||
* <li>特性门控(isEnabled)—— 条件注册</li> |
||||
* <li>活动描述(activityDescription)—— 人类可读的进度</li> |
||||
* </ul> |
||||
*/ |
||||
public interface Tool { |
||||
|
||||
/** 工具唯一名称标识 */ |
||||
String name(); |
||||
|
||||
/** 给 LLM 看的工具描述 */ |
||||
String description(); |
||||
|
||||
/** |
||||
* 输入参数的 JSON Schema 定义。 |
||||
* <p> |
||||
* 示例: |
||||
* <pre>{@code |
||||
* { |
||||
* "type": "object", |
||||
* "properties": { |
||||
* "command": { "type": "string", "description": "Shell command to execute" } |
||||
* }, |
||||
* "required": ["command"] |
||||
* } |
||||
* }</pre> |
||||
*/ |
||||
String inputSchema(); |
||||
|
||||
/** |
||||
* 执行工具。 |
||||
* |
||||
* @param input JSON 解析后的输入参数 |
||||
* @param context 执行上下文(工作目录、会话状态等) |
||||
* @return 执行结果文本 |
||||
*/ |
||||
String execute(Map<String, Object> input, ToolContext context); |
||||
|
||||
/** |
||||
* 权限前置检查,在 execute 之前调用。 |
||||
* 默认放行。 |
||||
*/ |
||||
default PermissionResult checkPermission(Map<String, Object> input, ToolContext context) { |
||||
return PermissionResult.ALLOW; |
||||
} |
||||
|
||||
/** 工具是否启用(特性门控),返回 false 则不注册 */ |
||||
default boolean isEnabled() { |
||||
return true; |
||||
} |
||||
|
||||
/** 是否为只读操作 */ |
||||
default boolean isReadOnly() { |
||||
return false; |
||||
} |
||||
|
||||
/** 人类可读的活动描述,用于 UI 显示执行进度 */ |
||||
default String activityDescription(Map<String, Object> input) { |
||||
return "Running " + name() + "..."; |
||||
} |
||||
} |
||||
@ -0,0 +1,72 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.tool.ToolCallback; |
||||
import org.springframework.ai.tool.definition.DefaultToolDefinition; |
||||
import org.springframework.ai.tool.definition.ToolDefinition; |
||||
|
||||
import java.util.Map; |
||||
|
||||
/** |
||||
* Tool → Spring AI ToolCallback 适配器。 |
||||
* <p> |
||||
* 将自定义 Tool 协议适配为 Spring AI 的 ToolCallback 接口, |
||||
* 在调用时处理 JSON 解析、权限检查和异常捕获。 |
||||
* <p> |
||||
* 对应 claude-code-learn 中的 AgentToolCallback。 |
||||
*/ |
||||
public class ToolCallbackAdapter implements ToolCallback { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ToolCallbackAdapter.class); |
||||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||
|
||||
private final Tool tool; |
||||
private final ToolDefinition toolDefinition; |
||||
private final ToolContext context; |
||||
|
||||
public ToolCallbackAdapter(Tool tool, ToolContext context) { |
||||
this.tool = tool; |
||||
this.context = context; |
||||
this.toolDefinition = DefaultToolDefinition.builder() |
||||
.name(tool.name()) |
||||
.description(tool.description()) |
||||
.inputSchema(tool.inputSchema()) |
||||
.build(); |
||||
} |
||||
|
||||
@Override |
||||
public ToolDefinition getToolDefinition() { |
||||
return toolDefinition; |
||||
} |
||||
|
||||
@Override |
||||
@SuppressWarnings("unchecked") |
||||
public String call(String jsonInput) { |
||||
try { |
||||
Map<String, Object> input = MAPPER.readValue(jsonInput, Map.class); |
||||
|
||||
// 权限前置检查
|
||||
PermissionResult perm = tool.checkPermission(input, context); |
||||
if (!perm.allowed()) { |
||||
log.warn("[{}] 权限拒绝: {}", tool.name(), perm.message()); |
||||
return "Permission denied: " + perm.message(); |
||||
} |
||||
|
||||
log.debug("[{}] {}", tool.name(), tool.activityDescription(input)); |
||||
return tool.execute(input, context); |
||||
} catch (JsonProcessingException e) { |
||||
log.warn("[{}] JSON 解析失败: {}", tool.name(), e.getMessage()); |
||||
return "Error: Invalid JSON input: " + e.getMessage(); |
||||
} catch (Exception e) { |
||||
log.warn("[{}] 执行异常: {}", tool.name(), e.getMessage()); |
||||
return "Error: " + e.getMessage(); |
||||
} |
||||
} |
||||
|
||||
public Tool getTool() { |
||||
return tool; |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import java.nio.file.Path; |
||||
import java.util.concurrent.ConcurrentHashMap; |
||||
|
||||
/** |
||||
* 工具执行上下文 —— 对应 claude-code 中 ToolUseContext。 |
||||
* <p> |
||||
* 提供工具执行时所需的环境信息和共享状态。 |
||||
*/ |
||||
public class ToolContext { |
||||
|
||||
private final Path workDir; |
||||
private final String model; |
||||
private final ConcurrentHashMap<String, Object> state; |
||||
|
||||
public ToolContext(Path workDir, String model) { |
||||
this.workDir = workDir; |
||||
this.model = model; |
||||
this.state = new ConcurrentHashMap<>(); |
||||
} |
||||
|
||||
public static ToolContext defaultContext() { |
||||
return new ToolContext(Path.of(System.getProperty("user.dir")), "claude-sonnet-4-20250514"); |
||||
} |
||||
|
||||
public Path getWorkDir() { |
||||
return workDir; |
||||
} |
||||
|
||||
public String getModel() { |
||||
return model; |
||||
} |
||||
|
||||
/** 获取共享状态值 */ |
||||
@SuppressWarnings("unchecked") |
||||
public <T> T get(String key) { |
||||
return (T) state.get(key); |
||||
} |
||||
|
||||
/** 设置共享状态值 */ |
||||
public void set(String key, Object value) { |
||||
state.put(key, value); |
||||
} |
||||
|
||||
@SuppressWarnings("unchecked") |
||||
public <T> T getOrDefault(String key, T defaultValue) { |
||||
return (T) state.getOrDefault(key, defaultValue); |
||||
} |
||||
|
||||
public boolean has(String key) { |
||||
return state.containsKey(key); |
||||
} |
||||
} |
||||
@ -0,0 +1,69 @@ |
||||
package com.claudecode.tool; |
||||
|
||||
import org.slf4j.Logger; |
||||
import org.slf4j.LoggerFactory; |
||||
import org.springframework.ai.tool.ToolCallback; |
||||
|
||||
import java.util.*; |
||||
|
||||
/** |
||||
* 工具注册中心 —— 对应 claude-code/src/tools.ts 中的工具集合管理。 |
||||
* <p> |
||||
* 管理 Tool 的注册、查找和到 Spring AI ToolCallback 的转换。 |
||||
*/ |
||||
public class ToolRegistry { |
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ToolRegistry.class); |
||||
|
||||
private final Map<String, Tool> tools = new LinkedHashMap<>(); |
||||
|
||||
/** |
||||
* 注册工具。若工具 isEnabled() 返回 false 则跳过。 |
||||
*/ |
||||
public void register(Tool tool) { |
||||
if (!tool.isEnabled()) { |
||||
log.debug("工具 [{}] 未启用,跳过注册", tool.name()); |
||||
return; |
||||
} |
||||
if (tools.containsKey(tool.name())) { |
||||
log.warn("工具 [{}] 已注册,将被覆盖", tool.name()); |
||||
} |
||||
tools.put(tool.name(), tool); |
||||
log.debug("注册工具: [{}]", tool.name()); |
||||
} |
||||
|
||||
/** 批量注册 */ |
||||
public void registerAll(Tool... toolArray) { |
||||
for (Tool t : toolArray) { |
||||
register(t); |
||||
} |
||||
} |
||||
|
||||
/** 按名称查找 */ |
||||
public Optional<Tool> findByName(String name) { |
||||
return Optional.ofNullable(tools.get(name)); |
||||
} |
||||
|
||||
/** 获取所有已注册工具 */ |
||||
public List<Tool> getTools() { |
||||
return List.copyOf(tools.values()); |
||||
} |
||||
|
||||
/** 获取所有工具名称 */ |
||||
public Set<String> getToolNames() { |
||||
return Set.copyOf(tools.keySet()); |
||||
} |
||||
|
||||
/** 转换为 Spring AI ToolCallback 列表 */ |
||||
public List<ToolCallback> toCallbacks(ToolContext context) { |
||||
List<ToolCallback> callbacks = new ArrayList<>(); |
||||
for (Tool tool : tools.values()) { |
||||
callbacks.add(new ToolCallbackAdapter(tool, context)); |
||||
} |
||||
return callbacks; |
||||
} |
||||
|
||||
public int size() { |
||||
return tools.size(); |
||||
} |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.Tool; |
||||
import com.claudecode.tool.ToolContext; |
||||
|
||||
import java.io.BufferedReader; |
||||
import java.io.InputStreamReader; |
||||
import java.nio.file.Path; |
||||
import java.util.Map; |
||||
import java.util.concurrent.TimeUnit; |
||||
|
||||
/** |
||||
* Bash 工具 —— 对应 claude-code/src/tools/bash/BashTool.ts。 |
||||
* <p> |
||||
* 在指定工作目录中执行 shell 命令,返回 stdout/stderr 输出。 |
||||
*/ |
||||
public class BashTool implements Tool { |
||||
|
||||
/** 默认超时(秒) */ |
||||
private static final int DEFAULT_TIMEOUT = 120; |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "Bash"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return """ |
||||
Execute a bash command in the working directory. \ |
||||
Use this for file operations, running scripts, installing packages, \ |
||||
or any system command. Commands run in a subprocess with timeout protection."""; |
||||
} |
||||
|
||||
@Override |
||||
public String inputSchema() { |
||||
return """ |
||||
{ |
||||
"type": "object", |
||||
"properties": { |
||||
"command": { |
||||
"type": "string", |
||||
"description": "The shell command to execute" |
||||
}, |
||||
"timeout": { |
||||
"type": "integer", |
||||
"description": "Timeout in seconds (default: 120)" |
||||
} |
||||
}, |
||||
"required": ["command"] |
||||
}"""; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(Map<String, Object> input, ToolContext context) { |
||||
String command = (String) input.get("command"); |
||||
int timeout = input.containsKey("timeout") |
||||
? ((Number) input.get("timeout")).intValue() |
||||
: DEFAULT_TIMEOUT; |
||||
Path workDir = context.getWorkDir(); |
||||
|
||||
try { |
||||
// 根据操作系统选择 shell
|
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||
ProcessBuilder pb; |
||||
if (isWindows) { |
||||
pb = new ProcessBuilder("cmd", "/c", command); |
||||
} else { |
||||
pb = new ProcessBuilder("bash", "-c", command); |
||||
} |
||||
|
||||
pb.directory(workDir.toFile()); |
||||
pb.redirectErrorStream(true); |
||||
|
||||
Process process = pb.start(); |
||||
StringBuilder output = new StringBuilder(); |
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { |
||||
String line; |
||||
while ((line = reader.readLine()) != null) { |
||||
output.append(line).append("\n"); |
||||
} |
||||
} |
||||
|
||||
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); |
||||
if (!finished) { |
||||
process.destroyForcibly(); |
||||
return output + "\n[ERROR: Command timed out after " + timeout + " seconds]"; |
||||
} |
||||
|
||||
int exitCode = process.exitValue(); |
||||
String result = output.toString().stripTrailing(); |
||||
|
||||
if (exitCode != 0) { |
||||
return result + "\n[Exit code: " + exitCode + "]"; |
||||
} |
||||
return result; |
||||
|
||||
} catch (Exception e) { |
||||
return "Error executing command: " + e.getMessage(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String activityDescription(Map<String, Object> input) { |
||||
String cmd = (String) input.getOrDefault("command", ""); |
||||
// 截断过长的命令
|
||||
if (cmd.length() > 60) { |
||||
cmd = cmd.substring(0, 57) + "..."; |
||||
} |
||||
return "⚡ " + cmd; |
||||
} |
||||
} |
||||
@ -0,0 +1,123 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.Tool; |
||||
import com.claudecode.tool.ToolContext; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.Map; |
||||
import java.util.stream.Collectors; |
||||
|
||||
/** |
||||
* 文件读取工具 —— 对应 claude-code/src/tools/read/ReadFileTool.ts。 |
||||
* <p> |
||||
* 读取文件内容,支持行号范围过滤。 |
||||
*/ |
||||
public class FileReadTool implements Tool { |
||||
|
||||
/** 单次读取最大行数 */ |
||||
private static final int MAX_LINES = 2000; |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "Read"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return """ |
||||
Read the contents of a file. Use line_start and line_end to read specific line ranges. \ |
||||
For large files, read in chunks. Supports text files only."""; |
||||
} |
||||
|
||||
@Override |
||||
public String inputSchema() { |
||||
return """ |
||||
{ |
||||
"type": "object", |
||||
"properties": { |
||||
"file_path": { |
||||
"type": "string", |
||||
"description": "Absolute or relative path to the file" |
||||
}, |
||||
"line_start": { |
||||
"type": "integer", |
||||
"description": "Starting line number (1-based, inclusive)" |
||||
}, |
||||
"line_end": { |
||||
"type": "integer", |
||||
"description": "Ending line number (1-based, inclusive)" |
||||
} |
||||
}, |
||||
"required": ["file_path"] |
||||
}"""; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isReadOnly() { |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(Map<String, Object> input, ToolContext context) { |
||||
String filePath = (String) input.get("file_path"); |
||||
Path path = context.getWorkDir().resolve(filePath).normalize(); |
||||
|
||||
if (!Files.exists(path)) { |
||||
return "Error: File not found: " + path; |
||||
} |
||||
if (!Files.isRegularFile(path)) { |
||||
return "Error: Not a regular file: " + path; |
||||
} |
||||
|
||||
try { |
||||
var allLines = Files.readAllLines(path, StandardCharsets.UTF_8); |
||||
int total = allLines.size(); |
||||
|
||||
int start = 1; |
||||
int end = total; |
||||
|
||||
if (input.containsKey("line_start")) { |
||||
start = ((Number) input.get("line_start")).intValue(); |
||||
} |
||||
if (input.containsKey("line_end")) { |
||||
end = ((Number) input.get("line_end")).intValue(); |
||||
} |
||||
|
||||
// 参数校验
|
||||
start = Math.max(1, start); |
||||
end = Math.min(total, end); |
||||
|
||||
if (start > end) { |
||||
return "Error: line_start (" + start + ") > line_end (" + end + ")"; |
||||
} |
||||
|
||||
// 限制行数
|
||||
if (end - start + 1 > MAX_LINES) { |
||||
end = start + MAX_LINES - 1; |
||||
} |
||||
|
||||
// 构建带行号的输出
|
||||
StringBuilder sb = new StringBuilder(); |
||||
for (int i = start - 1; i < end; i++) { |
||||
sb.append(String.format("%4d | %s%n", i + 1, allLines.get(i))); |
||||
} |
||||
|
||||
if (end < total) { |
||||
sb.append(String.format("... (%d more lines)%n", total - end)); |
||||
} |
||||
|
||||
return sb.toString().stripTrailing(); |
||||
|
||||
} catch (IOException e) { |
||||
return "Error reading file: " + e.getMessage(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String activityDescription(Map<String, Object> input) { |
||||
return "📄 Reading " + input.getOrDefault("file_path", "file"); |
||||
} |
||||
} |
||||
@ -0,0 +1,127 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.Tool; |
||||
import com.claudecode.tool.ToolContext; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.file.*; |
||||
import java.nio.file.attribute.BasicFileAttributes; |
||||
import java.util.*; |
||||
import java.util.stream.Collectors; |
||||
|
||||
/** |
||||
* Glob 文件搜索工具 —— 对应 claude-code/src/tools/glob/GlobTool.ts。 |
||||
* <p> |
||||
* 根据 glob 模式搜索文件。 |
||||
*/ |
||||
public class GlobTool implements Tool { |
||||
|
||||
private static final int MAX_RESULTS = 200; |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "Glob"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return """ |
||||
Find files matching a glob pattern. Returns a list of matching file paths \ |
||||
relative to the working directory. Useful for finding files by name or extension."""; |
||||
} |
||||
|
||||
@Override |
||||
public String inputSchema() { |
||||
return """ |
||||
{ |
||||
"type": "object", |
||||
"properties": { |
||||
"pattern": { |
||||
"type": "string", |
||||
"description": "Glob pattern (e.g., '**/*.java', 'src/**/*.ts')" |
||||
}, |
||||
"path": { |
||||
"type": "string", |
||||
"description": "Directory to search in (default: working directory)" |
||||
} |
||||
}, |
||||
"required": ["pattern"] |
||||
}"""; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isReadOnly() { |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(Map<String, Object> input, ToolContext context) { |
||||
String pattern = (String) input.get("pattern"); |
||||
String searchPath = (String) input.getOrDefault("path", "."); |
||||
Path baseDir = context.getWorkDir().resolve(searchPath).normalize(); |
||||
|
||||
if (!Files.isDirectory(baseDir)) { |
||||
return "Error: Directory not found: " + baseDir; |
||||
} |
||||
|
||||
try { |
||||
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern); |
||||
List<String> matches = new ArrayList<>(); |
||||
|
||||
Files.walkFileTree(baseDir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), 20, new SimpleFileVisitor<>() { |
||||
@Override |
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { |
||||
Path relative = baseDir.relativize(file); |
||||
// 将路径分隔符统一为 /(跨平台兼容)
|
||||
String relStr = relative.toString().replace('\\', '/'); |
||||
if (matcher.matches(Path.of(relStr))) { |
||||
matches.add(relStr); |
||||
} |
||||
if (matches.size() >= MAX_RESULTS) { |
||||
return FileVisitResult.TERMINATE; |
||||
} |
||||
return FileVisitResult.CONTINUE; |
||||
} |
||||
|
||||
@Override |
||||
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { |
||||
String name = dir.getFileName() != null ? dir.getFileName().toString() : ""; |
||||
// 跳过隐藏目录和常见忽略目录
|
||||
if (name.startsWith(".") || name.equals("node_modules") || name.equals("target") || name.equals("build")) { |
||||
return FileVisitResult.SKIP_SUBTREE; |
||||
} |
||||
return FileVisitResult.CONTINUE; |
||||
} |
||||
|
||||
@Override |
||||
public FileVisitResult visitFileFailed(Path file, IOException exc) { |
||||
return FileVisitResult.CONTINUE; |
||||
} |
||||
}); |
||||
|
||||
Collections.sort(matches); |
||||
|
||||
if (matches.isEmpty()) { |
||||
return "No files matching pattern: " + pattern; |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
sb.append("Found ").append(matches.size()).append(" file(s):\n"); |
||||
for (String m : matches) { |
||||
sb.append(" ").append(m).append("\n"); |
||||
} |
||||
if (matches.size() >= MAX_RESULTS) { |
||||
sb.append(" ... (results truncated at ").append(MAX_RESULTS).append(")\n"); |
||||
} |
||||
return sb.toString().stripTrailing(); |
||||
|
||||
} catch (IOException e) { |
||||
return "Error searching files: " + e.getMessage(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String activityDescription(Map<String, Object> input) { |
||||
return "🔍 Searching " + input.getOrDefault("pattern", "files"); |
||||
} |
||||
} |
||||
@ -0,0 +1,169 @@ |
||||
package com.claudecode.tool.impl; |
||||
|
||||
import com.claudecode.tool.Tool; |
||||
import com.claudecode.tool.ToolContext; |
||||
|
||||
import java.io.BufferedReader; |
||||
import java.io.IOException; |
||||
import java.io.InputStreamReader; |
||||
import java.nio.file.Path; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.concurrent.TimeUnit; |
||||
|
||||
/** |
||||
* Grep 搜索工具 —— 对应 claude-code/src/tools/grep/GrepTool.ts。 |
||||
* <p> |
||||
* 在文件中搜索文本模式(正则),优先使用 ripgrep(rg),降级为系统 grep。 |
||||
*/ |
||||
public class GrepTool implements Tool { |
||||
|
||||
private static final int MAX_RESULTS = 100; |
||||
|
||||
@Override |
||||
public String name() { |
||||
return "Grep"; |
||||
} |
||||
|
||||
@Override |
||||
public String description() { |
||||
return """ |
||||
Search for a pattern in file contents using regex. Returns matching lines with \ |
||||
file paths and line numbers. Uses ripgrep (rg) if available, falls back to grep."""; |
||||
} |
||||
|
||||
@Override |
||||
public String inputSchema() { |
||||
return """ |
||||
{ |
||||
"type": "object", |
||||
"properties": { |
||||
"pattern": { |
||||
"type": "string", |
||||
"description": "Regular expression pattern to search for" |
||||
}, |
||||
"path": { |
||||
"type": "string", |
||||
"description": "Directory or file to search in (default: working directory)" |
||||
}, |
||||
"include": { |
||||
"type": "string", |
||||
"description": "File glob pattern to include (e.g., '*.java')" |
||||
} |
||||
}, |
||||
"required": ["pattern"] |
||||
}"""; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isReadOnly() { |
||||
return true; |
||||
} |
||||
|
||||
@Override |
||||
public String execute(Map<String, Object> input, ToolContext context) { |
||||
String pattern = (String) input.get("pattern"); |
||||
String searchPath = (String) input.getOrDefault("path", "."); |
||||
String include = (String) input.getOrDefault("include", null); |
||||
Path baseDir = context.getWorkDir().resolve(searchPath).normalize(); |
||||
|
||||
try { |
||||
List<String> cmd = buildCommand(pattern, baseDir.toString(), include); |
||||
ProcessBuilder pb = new ProcessBuilder(cmd); |
||||
pb.directory(context.getWorkDir().toFile()); |
||||
pb.redirectErrorStream(true); |
||||
|
||||
Process process = pb.start(); |
||||
List<String> lines = new ArrayList<>(); |
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { |
||||
String line; |
||||
while ((line = reader.readLine()) != null && lines.size() < MAX_RESULTS) { |
||||
lines.add(line); |
||||
} |
||||
} |
||||
|
||||
process.waitFor(30, TimeUnit.SECONDS); |
||||
|
||||
if (lines.isEmpty()) { |
||||
return "No matches found for pattern: " + pattern; |
||||
} |
||||
|
||||
StringBuilder sb = new StringBuilder(); |
||||
for (String line : lines) { |
||||
sb.append(line).append("\n"); |
||||
} |
||||
if (lines.size() >= MAX_RESULTS) { |
||||
sb.append("... (results truncated at ").append(MAX_RESULTS).append(")\n"); |
||||
} |
||||
return sb.toString().stripTrailing(); |
||||
|
||||
} catch (Exception e) { |
||||
return "Error searching: " + e.getMessage(); |
||||
} |
||||
} |
||||
|
||||
/** 构建搜索命令(优先 rg,降级 grep/findstr) */ |
||||
private List<String> buildCommand(String pattern, String path, String include) { |
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||
List<String> cmd = new ArrayList<>(); |
||||
|
||||
// 尝试 ripgrep
|
||||
if (isCommandAvailable("rg")) { |
||||
cmd.add("rg"); |
||||
cmd.add("--no-heading"); |
||||
cmd.add("--line-number"); |
||||
cmd.add("--color=never"); |
||||
cmd.add("--max-count=100"); |
||||
if (include != null) { |
||||
cmd.add("--glob=" + include); |
||||
} |
||||
cmd.add(pattern); |
||||
cmd.add(path); |
||||
} else if (isWindows) { |
||||
// Windows 降级到 findstr(功能有限)
|
||||
cmd.add("findstr"); |
||||
cmd.add("/s"); |
||||
cmd.add("/n"); |
||||
cmd.add("/r"); |
||||
cmd.add(pattern); |
||||
if (include != null) { |
||||
cmd.add(path + "\\" + include); |
||||
} else { |
||||
cmd.add(path + "\\*"); |
||||
} |
||||
} else { |
||||
cmd.add("grep"); |
||||
cmd.add("-rn"); |
||||
cmd.add("--color=never"); |
||||
if (include != null) { |
||||
cmd.add("--include=" + include); |
||||
} |
||||
cmd.add(pattern); |
||||
cmd.add(path); |
||||
} |
||||
|
||||
return cmd; |
||||
} |
||||
|
||||
private boolean isCommandAvailable(String command) { |
||||
try { |
||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); |
||||
Process p; |
||||
if (isWindows) { |
||||
p = new ProcessBuilder("where", command).start(); |
||||
} else { |
||||
p = new ProcessBuilder("which", command).start(); |
||||
} |
||||
return p.waitFor(5, TimeUnit.SECONDS) && p.exitValue() == 0; |
||||
} catch (Exception e) { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String activityDescription(Map<String, Object> input) { |
||||
return "🔍 Searching for '" + input.getOrDefault("pattern", "...") + "'"; |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@ |
||||
spring: |
||||
# 主配置:使用 Anthropic 原生 API |
||||
ai: |
||||
anthropic: |
||||
api-key: ${ANTHROPIC_API_KEY:} |
||||
chat: |
||||
options: |
||||
model: ${AI_MODEL:claude-sonnet-4-20250514} |
||||
max-tokens: ${AI_MAX_TOKENS:8096} |
||||
temperature: 0.7 |
||||
# 备选:兼容 OpenAI 格式的 API(如自建代理) |
||||
openai: |
||||
api-key: ${AI_API_KEY:} |
||||
base-url: ${AI_BASE_URL:https://api.openai.com} |
||||
chat: |
||||
options: |
||||
model: ${AI_OPENAI_MODEL:} |
||||
|
||||
# 不启动 Web 服务器(纯 CLI 模式) |
||||
main: |
||||
web-application-type: none |
||||
|
||||
# 虚拟线程 |
||||
threads: |
||||
virtual: |
||||
enabled: true |
||||
|
||||
# 日志 |
||||
logging: |
||||
level: |
||||
com.claudecode: INFO |
||||
org.springframework.ai: WARN |
||||
org.springframework.boot.autoconfigure: WARN |
||||
Loading…
Reference in new issue