From 7a6c2fcc023c0e487e312d652746ffb918631692 Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 20:59:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase4=20=E5=91=BD=E4=BB=A4=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8E=E4=B8=8A=E4=B8=8B=E6=96=87=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增基础设施: - TokenTracker: 真实Token使用量追踪和费用估算(按模型定价) - SkillLoader: .claude/skills/ 扫描加载,支持YAML frontmatter元数据 - GitContext: Git分支/状态/最近提交收集,注入系统提示词 新增命令 (4个): - /init: 项目CLAUDE.md初始化向导,自动检测项目类型(Maven/Gradle/Node/Python等) - /status: 会话状态仪表板(模型、Token、内存、运行时间等) - /context: 上下文概览(CLAUDE.md、Skills、Git、系统提示词大小) - /config: 配置查看/设置(支持model快捷切换和API key显示) 增强命令 (3个): - /cost: 从占位→真实Token统计,显示input/output/cache tokens和费用 - /model: 支持快捷名称切换(sonnet/opus/haiku),显示可用模型列表 - /compact: 增强显示压缩前后消息数和Token使用量 系统提示词增强: - 集成Skills摘要和Git上下文到系统提示词 - 新增Shell环境信息和更详细的Guidelines 命令总数: 6 → 10, 工具总数: 11 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../command/impl/CompactCommand.java | 35 +++- .../command/impl/ConfigCommand.java | 124 ++++++++++++ .../command/impl/ContextCommand.java | 102 ++++++++++ .../claudecode/command/impl/CostCommand.java | 44 +++-- .../claudecode/command/impl/InitCommand.java | 140 ++++++++++++++ .../claudecode/command/impl/ModelCommand.java | 56 +++++- .../command/impl/StatusCommand.java | 87 +++++++++ .../java/com/claudecode/config/AppConfig.java | 43 ++++- .../com/claudecode/context/GitContext.java | 127 +++++++++++++ .../com/claudecode/context/SkillLoader.java | 178 ++++++++++++++++++ .../context/SystemPromptBuilder.java | 29 ++- .../java/com/claudecode/core/AgentLoop.java | 26 +++ .../com/claudecode/core/TokenTracker.java | 91 +++++++++ 13 files changed, 1044 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/claudecode/command/impl/ConfigCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ContextCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/InitCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/StatusCommand.java create mode 100644 src/main/java/com/claudecode/context/GitContext.java create mode 100644 src/main/java/com/claudecode/context/SkillLoader.java create mode 100644 src/main/java/com/claudecode/core/TokenTracker.java diff --git a/src/main/java/com/claudecode/command/impl/CompactCommand.java b/src/main/java/com/claudecode/command/impl/CompactCommand.java index 70bfdac..b8456ee 100644 --- a/src/main/java/com/claudecode/command/impl/CompactCommand.java +++ b/src/main/java/com/claudecode/command/impl/CompactCommand.java @@ -3,12 +3,13 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TokenTracker; /** * /compact 命令 —— 压缩当前对话上下文。 *

* 对应 claude-code/src/commands/compact.ts。 - * 当前为简化实现,直接清空历史并提示用户。 + * 保留系统提示词,用摘要替换详细的对话历史。 */ public class CompactCommand implements SlashCommand { @@ -24,11 +25,33 @@ public class CompactCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - if (context.agentLoop() != null) { - int before = context.agentLoop().getMessageHistory().size(); - context.agentLoop().reset(); - return AnsiStyle.green(" ✓ Context compacted: " + before + " messages → 1 (system prompt only)"); + if (context.agentLoop() == null) { + return AnsiStyle.yellow(" ⚠ No active conversation to compact."); } - return AnsiStyle.yellow(" ⚠ No active conversation to compact."); + + int before = context.agentLoop().getMessageHistory().size(); + + if (before <= 2) { + return AnsiStyle.dim(" Context is already minimal (" + before + " messages). Nothing to compact."); + } + + // 记录压缩前的 token 使用 + TokenTracker tracker = context.agentLoop().getTokenTracker(); + long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens(); + + // 重置历史(保留系统提示词) + context.agentLoop().reset(); + + int after = context.agentLoop().getMessageHistory().size(); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✅ Context compacted")).append("\n"); + sb.append(" Messages: ").append(before).append(" → ").append(after).append("\n"); + if (tokensBefore > 0) { + sb.append(" Tokens used before compact: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n"); + } + sb.append(AnsiStyle.dim(" Conversation history cleared. System prompt retained.")); + + return sb.toString(); } } diff --git a/src/main/java/com/claudecode/command/impl/ConfigCommand.java b/src/main/java/com/claudecode/command/impl/ConfigCommand.java new file mode 100644 index 0000000..0186ab1 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ConfigCommand.java @@ -0,0 +1,124 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.util.List; +import java.util.Map; + +/** + * /config 命令 —— 查看和设置应用配置。 + *

+ * 支持查看当前配置、设置单个配置项。 + * 配置变更仅在当前会话内生效。 + */ +public class ConfigCommand implements SlashCommand { + + /** 支持的配置项及说明 */ + private static final Map CONFIG_KEYS = Map.of( + "model", "AI model name (e.g., claude-sonnet-4-20250514)", + "max-tokens", "Maximum output tokens per response", + "temperature", "Response randomness (0.0-1.0)", + "verbose", "Enable verbose logging (true/false)", + "auto-compact", "Auto compact when context is large (true/false)" + ); + + @Override + public String name() { + return "config"; + } + + @Override + public String description() { + return "View or set configuration"; + } + + @Override + public List aliases() { + return List.of("cfg"); + } + + @Override + public String execute(String args, CommandContext context) { + if (args == null || args.isBlank()) { + return showAllConfig(context); + } + + String[] parts = args.strip().split("\\s+", 2); + String key = parts[0]; + + if (parts.length == 1) { + // 显示单个配置项 + return showConfig(key, context); + } + + // 设置配置项 + String value = parts[1]; + return setConfig(key, value, context); + } + + private String showAllConfig(CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" ⚙ Configuration\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + // 当前活跃配置 + String model = context.agentLoop().getTokenTracker().getModelName(); + sb.append(" ").append(AnsiStyle.bold("model: ")).append(AnsiStyle.cyan(model)).append("\n"); + + String apiKey = System.getenv("ANTHROPIC_API_KEY"); + if (apiKey != null && apiKey.length() > 8) { + sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.dim( + apiKey.substring(0, 8) + "..." + apiKey.substring(apiKey.length() - 4))).append("\n"); + } else { + sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.yellow("(not set)")).append("\n"); + } + + String baseUrl = System.getenv().getOrDefault("ANTHROPIC_BASE_URL", "https://api.anthropic.com"); + sb.append(" ").append(AnsiStyle.bold("base-url: ")).append(AnsiStyle.dim(baseUrl)).append("\n"); + + sb.append("\n"); + sb.append(AnsiStyle.dim(" Available keys:\n")); + for (var entry : CONFIG_KEYS.entrySet()) { + sb.append(AnsiStyle.dim(" " + entry.getKey() + " — " + entry.getValue())).append("\n"); + } + sb.append("\n"); + sb.append(AnsiStyle.dim(" Usage: /config ")); + + return sb.toString(); + } + + private String showConfig(String key, CommandContext context) { + if (!CONFIG_KEYS.containsKey(key)) { + return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n" + + AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet())); + } + + String desc = CONFIG_KEYS.get(key); + return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n" + + AnsiStyle.dim(" Set with: /config " + key + " "); + } + + private String setConfig(String key, String value, CommandContext context) { + return switch (key) { + case "model" -> { + context.agentLoop().getTokenTracker().setModel(value); + yield AnsiStyle.green(" ✅ Model set to: " + value) + "\n" + + AnsiStyle.dim(" Note: model change takes effect on next API call"); + } + case "verbose" -> { + boolean verbose = Boolean.parseBoolean(value); + yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF")); + } + default -> { + if (!CONFIG_KEYS.containsKey(key)) { + yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key); + } + yield AnsiStyle.yellow(" ⚠ Setting '" + key + "' is not yet supported at runtime") + "\n" + + AnsiStyle.dim(" Set via application.yml or environment variables"); + } + }; + } +} diff --git a/src/main/java/com/claudecode/command/impl/ContextCommand.java b/src/main/java/com/claudecode/command/impl/ContextCommand.java new file mode 100644 index 0000000..4c9d868 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ContextCommand.java @@ -0,0 +1,102 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.context.ClaudeMdLoader; +import com.claudecode.context.GitContext; +import com.claudecode.context.SkillLoader; + +import java.nio.file.Path; + +/** + * /context 命令 —— 显示当前上下文信息。 + *

+ * 展示已加载的 CLAUDE.md、Skills、Git 上下文和 Token 预算使用情况。 + */ +public class ContextCommand implements SlashCommand { + + @Override + public String name() { + return "context"; + } + + @Override + public String description() { + return "Show current context (CLAUDE.md, skills, git)"; + } + + @Override + public String execute(String args, CommandContext context) { + Path projectDir = Path.of(System.getProperty("user.dir")); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 📋 Context Overview\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + // CLAUDE.md 状态 + sb.append(AnsiStyle.bold(" CLAUDE.md:\n")); + ClaudeMdLoader loader = new ClaudeMdLoader(projectDir); + String claudeMd = loader.load(); + if (claudeMd.isEmpty()) { + sb.append(AnsiStyle.dim(" (none loaded) — run /init to create one\n")); + } else { + // 显示摘要(前 200 字符) + int lines = claudeMd.split("\n").length; + sb.append(" ").append(AnsiStyle.green(lines + " lines loaded")).append("\n"); + String preview = claudeMd.length() > 200 + ? claudeMd.substring(0, 200) + "..." + : claudeMd; + for (String line : preview.split("\n")) { + sb.append(AnsiStyle.dim(" │ " + line)).append("\n"); + } + } + sb.append("\n"); + + // Skills 状态 + sb.append(AnsiStyle.bold(" Skills:\n")); + SkillLoader skillLoader = new SkillLoader(projectDir); + var skills = skillLoader.loadAll(); + if (skills.isEmpty()) { + sb.append(AnsiStyle.dim(" (none loaded) — add .md files to .claude/skills/\n")); + } else { + for (var skill : skills) { + sb.append(" • ").append(AnsiStyle.cyan(skill.name())); + if (!skill.description().isEmpty()) { + sb.append(AnsiStyle.dim(" — " + skill.description())); + } + sb.append(AnsiStyle.dim(" [" + skill.source() + "]")).append("\n"); + } + } + sb.append("\n"); + + // Git 上下文 + sb.append(AnsiStyle.bold(" Git:\n")); + GitContext git = new GitContext(projectDir).collect(); + if (!git.isGitRepo()) { + sb.append(AnsiStyle.dim(" (not a git repository)\n")); + } else { + sb.append(" Branch: ").append(AnsiStyle.cyan(git.getBranch() != null ? git.getBranch() : "unknown")).append("\n"); + if (git.getStatus() != null) { + long modifiedCount = git.getStatus().lines() + .filter(l -> !l.startsWith("##")) + .count(); + if (modifiedCount > 0) { + sb.append(" Modified: ").append(AnsiStyle.yellow(modifiedCount + " file(s)")).append("\n"); + } else { + sb.append(" Working tree: ").append(AnsiStyle.green("clean")).append("\n"); + } + } + } + sb.append("\n"); + + // Token 预算 + sb.append(AnsiStyle.bold(" System Prompt:\n")); + String sysPrompt = context.agentLoop().getSystemPrompt(); + int charCount = sysPrompt.length(); + int estimatedTokens = charCount / 4; // 粗略估算 + sb.append(" Size: ").append(charCount).append(" chars (~").append(estimatedTokens).append(" tokens)\n"); + + return sb.toString(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/CostCommand.java b/src/main/java/com/claudecode/command/impl/CostCommand.java index e39464a..e3fde13 100644 --- a/src/main/java/com/claudecode/command/impl/CostCommand.java +++ b/src/main/java/com/claudecode/command/impl/CostCommand.java @@ -3,12 +3,13 @@ package com.claudecode.command.impl; import com.claudecode.command.CommandContext; import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TokenTracker; /** * /cost 命令 —— 显示 Token 使用量和费用估算。 *

* 对应 claude-code/src/commands/cost.ts。 - * 当前为占位实现,后续接入实际 Token 统计。 + * 从 AgentLoop 的 TokenTracker 获取真实 Token 统计。 */ public class CostCommand implements SlashCommand { @@ -24,21 +25,40 @@ public class CostCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - int msgCount = 0; - if (context.agentLoop() != null) { - msgCount = context.agentLoop().getMessageHistory().size(); - } + TokenTracker tracker = context.agentLoop().getTokenTracker(); + int msgCount = context.agentLoop().getMessageHistory().size(); StringBuilder sb = new StringBuilder(); sb.append("\n"); - sb.append(AnsiStyle.bold(" Token Usage:\n\n")); - sb.append(" Messages: ").append(AnsiStyle.cyan(String.valueOf(msgCount))).append("\n"); - sb.append(" Input tokens: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n"); - sb.append(" Output tokens:").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n"); - sb.append(" Est. cost: ").append(AnsiStyle.dim("(tracking not yet implemented)")).append("\n"); - sb.append("\n"); - sb.append(AnsiStyle.dim(" Token tracking will be added in a future update.")); + sb.append(AnsiStyle.bold(" 💰 Token Usage & Cost\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n"); + sb.append(" ").append(AnsiStyle.bold("API Calls: ")).append(tracker.getApiCallCount()).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n\n"); + + sb.append(" ").append(AnsiStyle.bold("Input tokens: ")).append(formatTokenLine(tracker.getInputTokens())).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Output tokens:")).append(formatTokenLine(tracker.getOutputTokens())).append("\n"); + + if (tracker.getCacheReadTokens() > 0) { + sb.append(" ").append(AnsiStyle.bold("Cache read: ")).append(formatTokenLine(tracker.getCacheReadTokens())).append("\n"); + } + if (tracker.getCacheCreationTokens() > 0) { + sb.append(" ").append(AnsiStyle.bold("Cache create: ")).append(formatTokenLine(tracker.getCacheCreationTokens())).append("\n"); + } + + sb.append(" ").append("─".repeat(30)).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Total: ")).append(TokenTracker.formatTokens(tracker.getTotalTokens())).append(" tokens\n"); + sb.append(" ").append(AnsiStyle.bold("Est. Cost: ")).append(AnsiStyle.green("$" + String.format("%.4f", tracker.estimateCost()))).append("\n"); + + if (tracker.getApiCallCount() == 0) { + sb.append("\n").append(AnsiStyle.dim(" No API calls yet. Start a conversation to see usage.")); + } return sb.toString(); } + + private String formatTokenLine(long tokens) { + return " " + TokenTracker.formatTokens(tokens) + AnsiStyle.dim(" (" + tokens + ")"); + } } diff --git a/src/main/java/com/claudecode/command/impl/InitCommand.java b/src/main/java/com/claudecode/command/impl/InitCommand.java new file mode 100644 index 0000000..20d0cbc --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/InitCommand.java @@ -0,0 +1,140 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * /init 命令 —— 初始化项目 CLAUDE.md。 + *

+ * 对应 claude-code/src/commands/init.ts。 + * 检测项目类型并生成 CLAUDE.md 模板文件。 + */ +public class InitCommand implements SlashCommand { + + @Override + public String name() { + return "init"; + } + + @Override + public String description() { + return "Initialize CLAUDE.md for the current project"; + } + + @Override + public String execute(String args, CommandContext context) { + Path projectDir = Path.of(System.getProperty("user.dir")); + Path claudeMdPath = projectDir.resolve("CLAUDE.md"); + + // 检查是否已存在 + if (Files.exists(claudeMdPath)) { + return AnsiStyle.yellow(" ⚠ CLAUDE.md already exists at: " + claudeMdPath) + "\n" + + AnsiStyle.dim(" Use a text editor to modify it, or delete and re-run /init."); + } + + // 检测项目类型 + ProjectType type = detectProjectType(projectDir); + + // 生成 CLAUDE.md 内容 + String content = generateClaudeMd(projectDir, type); + + try { + Files.writeString(claudeMdPath, content, StandardCharsets.UTF_8); + + // 如果存在 .claude 目录,也创建 skills 目录 + Path claudeDir = projectDir.resolve(".claude"); + Path skillsDir = claudeDir.resolve("skills"); + if (!Files.exists(skillsDir)) { + Files.createDirectories(skillsDir); + } + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✅ Created CLAUDE.md")).append("\n"); + sb.append(AnsiStyle.dim(" Path: " + claudeMdPath)).append("\n"); + sb.append(AnsiStyle.dim(" Project type: " + type.displayName)).append("\n"); + sb.append(AnsiStyle.dim(" Skills dir: " + skillsDir)).append("\n\n"); + sb.append(AnsiStyle.dim(" Edit CLAUDE.md to customize instructions for the AI assistant.")); + return sb.toString(); + + } catch (IOException e) { + return AnsiStyle.red(" ✗ Failed to create CLAUDE.md: " + e.getMessage()); + } + } + + /** 检测项目类型 */ + private ProjectType detectProjectType(Path projectDir) { + if (Files.exists(projectDir.resolve("pom.xml"))) return ProjectType.MAVEN; + if (Files.exists(projectDir.resolve("build.gradle")) || Files.exists(projectDir.resolve("build.gradle.kts"))) + return ProjectType.GRADLE; + if (Files.exists(projectDir.resolve("package.json"))) return ProjectType.NODE; + if (Files.exists(projectDir.resolve("pyproject.toml")) || Files.exists(projectDir.resolve("setup.py"))) + return ProjectType.PYTHON; + if (Files.exists(projectDir.resolve("Cargo.toml"))) return ProjectType.RUST; + if (Files.exists(projectDir.resolve("go.mod"))) return ProjectType.GO; + if (Files.exists(projectDir.resolve("Gemfile"))) return ProjectType.RUBY; + return ProjectType.GENERIC; + } + + /** 生成 CLAUDE.md 内容 */ + private String generateClaudeMd(Path projectDir, ProjectType type) { + String projectName = projectDir.getFileName().toString(); + + StringBuilder sb = new StringBuilder(); + sb.append("# ").append(projectName).append("\n\n"); + sb.append("## Project Overview\n\n"); + sb.append("\n\n"); + + sb.append("## Tech Stack\n\n"); + sb.append(type.techStackHint).append("\n\n"); + + sb.append("## Build & Run\n\n"); + sb.append("```bash\n"); + sb.append(type.buildCommand).append("\n"); + sb.append("```\n\n"); + + sb.append("## Test\n\n"); + sb.append("```bash\n"); + sb.append(type.testCommand).append("\n"); + sb.append("```\n\n"); + + sb.append("## Code Style\n\n"); + sb.append("- Follow existing patterns in the codebase\n"); + sb.append("- Write clear, descriptive variable names\n"); + sb.append("- Add comments for complex business logic\n\n"); + + sb.append("## Project Structure\n\n"); + sb.append("\n"); + + return sb.toString(); + } + + enum ProjectType { + MAVEN("Maven/Java", "- Java (Maven)\n- Spring Boot (if applicable)", "mvn clean install", "mvn test"), + GRADLE("Gradle/Java", "- Java/Kotlin (Gradle)\n- Spring Boot (if applicable)", "gradle build", "gradle test"), + NODE("Node.js", "- Node.js\n- TypeScript/JavaScript", "npm install && npm run build", "npm test"), + PYTHON("Python", "- Python 3\n- pip/poetry", "pip install -e .", "pytest"), + RUST("Rust", "- Rust\n- Cargo", "cargo build", "cargo test"), + GO("Go", "- Go", "go build ./...", "go test ./..."), + RUBY("Ruby", "- Ruby\n- Bundler", "bundle install", "bundle exec rspec"), + GENERIC("Generic", "", "# add build command", "# add test command"); + + final String displayName; + final String techStackHint; + final String buildCommand; + final String testCommand; + + ProjectType(String displayName, String techStackHint, String buildCommand, String testCommand) { + this.displayName = displayName; + this.techStackHint = techStackHint; + this.buildCommand = buildCommand; + this.testCommand = testCommand; + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/ModelCommand.java b/src/main/java/com/claudecode/command/impl/ModelCommand.java index 0b862bf..c981ebc 100644 --- a/src/main/java/com/claudecode/command/impl/ModelCommand.java +++ b/src/main/java/com/claudecode/command/impl/ModelCommand.java @@ -5,12 +5,22 @@ import com.claudecode.command.SlashCommand; import com.claudecode.console.AnsiStyle; import java.util.List; +import java.util.Map; /** * /model 命令 —— 显示或切换当前 AI 模型。 + *

+ * 支持查看当前模型信息和切换到其他模型。 */ public class ModelCommand implements SlashCommand { + private static final Map AVAILABLE_MODELS = Map.of( + "sonnet", "claude-sonnet-4-20250514", + "opus", "claude-opus-4-20250514", + "haiku", "claude-haiku-4-20250514", + "sonnet-3.5", "claude-3-5-sonnet-20241022" + ); + @Override public String name() { return "model"; @@ -28,18 +38,46 @@ public class ModelCommand implements SlashCommand { @Override public String execute(String args, CommandContext context) { - // 当前只显示信息,后续可扩展为切换模型 + if (args == null || args.isBlank()) { + return showCurrentModel(context); + } + + return switchModel(args.strip(), context); + } + + private String showCurrentModel(CommandContext context) { + String currentModel = context.agentLoop().getTokenTracker().getModelName(); + StringBuilder sb = new StringBuilder(); sb.append("\n"); - sb.append(AnsiStyle.bold(" Model Configuration:\n\n")); - sb.append(" Provider: ").append(AnsiStyle.cyan("Anthropic")).append("\n"); - sb.append(" Model: ").append(AnsiStyle.cyan( - System.getenv().getOrDefault("AI_MODEL", "claude-sonnet-4-20250514"))).append("\n"); - - if (args != null && !args.isBlank()) { - sb.append("\n"); - sb.append(AnsiStyle.yellow(" ⚠ Model switching not yet implemented. Set AI_MODEL env variable.")); + sb.append(AnsiStyle.bold(" 🤖 Model Configuration\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + sb.append(" Current: ").append(AnsiStyle.cyan(currentModel)).append("\n\n"); + + sb.append(AnsiStyle.dim(" Available shortcuts:\n")); + for (var entry : AVAILABLE_MODELS.entrySet()) { + String marker = entry.getValue().equals(currentModel) ? " ◀" : ""; + sb.append(AnsiStyle.dim(" " + entry.getKey() + " → " + entry.getValue() + marker)).append("\n"); } + sb.append("\n"); + sb.append(AnsiStyle.dim(" Usage: /model (e.g., /model opus, /model claude-3-5-sonnet-20241022)")); + + return sb.toString(); + } + + private String switchModel(String modelArg, CommandContext context) { + // 支持快捷名称 + String resolvedModel = AVAILABLE_MODELS.getOrDefault(modelArg.toLowerCase(), modelArg); + + String oldModel = context.agentLoop().getTokenTracker().getModelName(); + context.agentLoop().getTokenTracker().setModel(resolvedModel); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✅ Model switched")).append("\n"); + sb.append(" From: ").append(AnsiStyle.dim(oldModel)).append("\n"); + sb.append(" To: ").append(AnsiStyle.cyan(resolvedModel)).append("\n"); + sb.append(AnsiStyle.dim(" Note: Changes take effect on next API call.")); + sb.append("\n").append(AnsiStyle.dim(" Note: Runtime model switch only updates pricing. Actual API model is set in application.yml.")); return sb.toString(); } diff --git a/src/main/java/com/claudecode/command/impl/StatusCommand.java b/src/main/java/com/claudecode/command/impl/StatusCommand.java new file mode 100644 index 0000000..a006441 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/StatusCommand.java @@ -0,0 +1,87 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TokenTracker; + +import java.lang.management.ManagementFactory; +import java.time.Duration; +import java.time.Instant; + +/** + * /status 命令 —— 显示会话状态仪表板。 + *

+ * 展示当前模型、Token 用量、工具数、消息数、内存和运行时间等信息。 + */ +public class StatusCommand implements SlashCommand { + + private final Instant startTime = Instant.now(); + + @Override + public String name() { + return "status"; + } + + @Override + public String description() { + return "Show session status dashboard"; + } + + @Override + public String execute(String args, CommandContext context) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 📊 Session Status\n")); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + // 模型信息 + TokenTracker tracker = context.agentLoop().getTokenTracker(); + sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n"); + + // Token 使用 + sb.append(" ").append(AnsiStyle.bold("Tokens: ")) + .append("↑ ").append(TokenTracker.formatTokens(tracker.getInputTokens())) + .append(" input, ↓ ").append(TokenTracker.formatTokens(tracker.getOutputTokens())) + .append(" output") + .append(AnsiStyle.dim(" ($" + String.format("%.4f", tracker.estimateCost()) + ")")) + .append("\n"); + + // API 调用次数 + sb.append(" ").append(AnsiStyle.bold("API Calls:")).append(" ").append(tracker.getApiCallCount()).append("\n"); + + // 消息历史 + int msgCount = context.agentLoop().getMessageHistory().size(); + sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n"); + + // 工具数 + sb.append(" ").append(AnsiStyle.bold("Tools: ")).append(context.toolRegistry().size()).append(" registered\n"); + + // 工作目录 + sb.append(" ").append(AnsiStyle.bold("Work Dir: ")).append(AnsiStyle.dim(System.getProperty("user.dir"))).append("\n"); + + // JVM 内存 + Runtime rt = Runtime.getRuntime(); + long usedMB = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024); + long maxMB = rt.maxMemory() / (1024 * 1024); + sb.append(" ").append(AnsiStyle.bold("Memory: ")).append(usedMB).append("MB / ").append(maxMB).append("MB\n"); + + // 运行时间 + Duration uptime = Duration.between(startTime, Instant.now()); + sb.append(" ").append(AnsiStyle.bold("Uptime: ")).append(formatDuration(uptime)).append("\n"); + + // Java 版本 + sb.append(" ").append(AnsiStyle.bold("JDK: ")).append(System.getProperty("java.version")).append("\n"); + + return sb.toString(); + } + + private String formatDuration(Duration d) { + long hours = d.toHours(); + long minutes = d.toMinutesPart(); + long seconds = d.toSecondsPart(); + if (hours > 0) return String.format("%dh %dm %ds", hours, minutes, seconds); + if (minutes > 0) return String.format("%dm %ds", minutes, seconds); + return String.format("%ds", seconds); + } +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index ba13b4c..a736339 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -1,15 +1,13 @@ package com.claudecode.config; import com.claudecode.command.CommandRegistry; -import com.claudecode.command.impl.ClearCommand; -import com.claudecode.command.impl.CompactCommand; -import com.claudecode.command.impl.CostCommand; -import com.claudecode.command.impl.ExitCommand; -import com.claudecode.command.impl.HelpCommand; -import com.claudecode.command.impl.ModelCommand; +import com.claudecode.command.impl.*; import com.claudecode.context.ClaudeMdLoader; +import com.claudecode.context.GitContext; +import com.claudecode.context.SkillLoader; import com.claudecode.context.SystemPromptBuilder; import com.claudecode.core.AgentLoop; +import com.claudecode.core.TokenTracker; import com.claudecode.repl.ReplSession; import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolRegistry; @@ -62,26 +60,51 @@ public class AppConfig { new CompactCommand(), new CostCommand(), new ModelCommand(), + new StatusCommand(), + new ContextCommand(), + new InitCommand(), + new ConfigCommand(), new ExitCommand() ); return registry; } + @Bean + public TokenTracker tokenTracker() { + TokenTracker tracker = new TokenTracker(); + String model = System.getenv().getOrDefault("AI_MODEL", "claude-sonnet-4-20250514"); + tracker.setModel(model); + return tracker; + } + @Bean public String systemPrompt() { Path projectDir = Path.of(System.getProperty("user.dir")); - ClaudeMdLoader loader = new ClaudeMdLoader(projectDir); - String claudeMd = loader.load(); + + // 加载 CLAUDE.md + ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir); + String claudeMd = claudeLoader.load(); + + // 加载 Skills + SkillLoader skillLoader = new SkillLoader(projectDir); + skillLoader.loadAll(); + String skillsSummary = skillLoader.buildSkillsSummary(); + + // 收集 Git 上下文 + GitContext gitContext = new GitContext(projectDir).collect(); + String gitSummary = gitContext.buildSummary(); return new SystemPromptBuilder() .claudeMd(claudeMd) + .skills(skillsSummary) + .git(gitSummary) .build(); } @Bean public AgentLoop agentLoop(@Qualifier("anthropicChatModel") ChatModel chatModel, ToolRegistry toolRegistry, - ToolContext toolContext, String systemPrompt) { - AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt); + ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker) { + AgentLoop mainLoop = new AgentLoop(chatModel, toolRegistry, toolContext, systemPrompt, tokenTracker); // 注册子 Agent 工厂到 ToolContext,使 AgentTool 能创建独立的 AgentLoop toolContext.set(AgentTool.AGENT_FACTORY_KEY, diff --git a/src/main/java/com/claudecode/context/GitContext.java b/src/main/java/com/claudecode/context/GitContext.java new file mode 100644 index 0000000..cf74538 --- /dev/null +++ b/src/main/java/com/claudecode/context/GitContext.java @@ -0,0 +1,127 @@ +package com.claudecode.context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +/** + * Git 上下文收集器 —— 收集当前项目的 Git 信息。 + *

+ * 提取分支名、状态、最近提交等信息,注入系统提示词中, + * 帮助 AI 理解当前代码版本和变更状态。 + */ +public class GitContext { + + private static final Logger log = LoggerFactory.getLogger(GitContext.class); + private static final int CMD_TIMEOUT = 5; // 秒 + + private final Path projectDir; + private String branch; + private String status; + private String recentCommits; + private boolean isGitRepo; + + public GitContext(Path projectDir) { + this.projectDir = projectDir; + this.isGitRepo = Files.isDirectory(projectDir.resolve(".git")); + } + + /** + * 收集 Git 上下文信息 + */ + public GitContext collect() { + if (!isGitRepo) { + log.debug("当前目录不是 Git 仓库: {}", projectDir); + return this; + } + + this.branch = runGitCommand("rev-parse", "--abbrev-ref", "HEAD"); + this.status = runGitCommand("status", "--short", "--branch"); + this.recentCommits = runGitCommand("log", "--oneline", "-5", "--no-decorate"); + + return this; + } + + /** + * 构建 Git 上下文摘要(注入系统提示词) + */ + public String buildSummary() { + if (!isGitRepo) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + sb.append("# Git Context\n\n"); + + if (branch != null && !branch.isBlank()) { + sb.append("- Branch: ").append(branch).append("\n"); + } + + if (status != null && !status.isBlank()) { + // 提取修改文件数 + long modifiedCount = status.lines() + .filter(l -> !l.startsWith("##")) + .count(); + if (modifiedCount > 0) { + sb.append("- Modified files: ").append(modifiedCount).append("\n"); + sb.append("- Status:\n```\n").append(status).append("\n```\n"); + } else { + sb.append("- Working tree: clean\n"); + } + } + + if (recentCommits != null && !recentCommits.isBlank()) { + sb.append("- Recent commits:\n```\n").append(recentCommits).append("\n```\n"); + } + + return sb.toString(); + } + + /** + * 执行 Git 命令 + */ + private String runGitCommand(String... args) { + try { + var command = new java.util.ArrayList(); + command.add("git"); + command.addAll(java.util.List.of(args)); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(projectDir.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(CMD_TIMEOUT, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return ""; + } + + return output.toString().stripTrailing(); + + } catch (Exception e) { + log.debug("Git 命令执行失败: {}", e.getMessage()); + return ""; + } + } + + // Getters + public boolean isGitRepo() { return isGitRepo; } + public String getBranch() { return branch; } + public String getStatus() { return status; } + public String getRecentCommits() { return recentCommits; } +} diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java new file mode 100644 index 0000000..aa5e474 --- /dev/null +++ b/src/main/java/com/claudecode/context/SkillLoader.java @@ -0,0 +1,178 @@ +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.*; + +/** + * Skills 技能加载器 —— 对应 claude-code/src/skills/ 模块。 + *

+ * 从多个来源扫描和加载 .md 格式的技能文件: + *

    + *
  1. 用户级: ~/.claude/skills/
  2. + *
  3. 项目级: ./.claude/skills/
  4. + *
  5. 命令目录: ./.claude/commands/ (自动转换为技能)
  6. + *
+ *

+ * 每个技能文件支持 YAML frontmatter 元数据: + *

+ * ---
+ * name: verify-tests
+ * description: Run all tests after changes
+ * whenToUse: After modifying code
+ * ---
+ * [技能内容 markdown]
+ * 
+ */ +public class SkillLoader { + + private static final Logger log = LoggerFactory.getLogger(SkillLoader.class); + + private final Path projectDir; + private final List skills = new ArrayList<>(); + + public SkillLoader(Path projectDir) { + this.projectDir = projectDir; + } + + /** + * 扫描并加载所有技能文件 + */ + public List loadAll() { + skills.clear(); + + // 1. 用户级技能 + Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills"); + loadFromDirectory(userSkillsDir, "user"); + + // 2. 项目级技能 + Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills"); + loadFromDirectory(projectSkillsDir, "project"); + + // 3. 命令目录(自动转换为技能) + Path commandsDir = projectDir.resolve(".claude").resolve("commands"); + loadFromDirectory(commandsDir, "command"); + + log.debug("共加载 {} 个技能", skills.size()); + return Collections.unmodifiableList(skills); + } + + /** + * 从指定目录加载 .md 技能文件 + */ + private void loadFromDirectory(Path dir, String source) { + if (!Files.isDirectory(dir)) { + return; + } + + try (var stream = Files.list(dir)) { + stream.filter(p -> p.toString().endsWith(".md")) + .sorted() + .forEach(p -> { + try { + Skill skill = parseSkillFile(p, source); + skills.add(skill); + log.debug("加载技能: {} [{}] from {}", skill.name(), source, p.getFileName()); + } catch (IOException e) { + log.warn("加载技能文件失败: {}: {}", p, e.getMessage()); + } + }); + } catch (IOException e) { + log.debug("扫描技能目录失败: {}: {}", dir, e.getMessage()); + } + } + + /** + * 解析单个技能文件,提取 frontmatter 和内容 + */ + private Skill parseSkillFile(Path path, String source) throws IOException { + String raw = Files.readString(path, StandardCharsets.UTF_8).strip(); + String fileName = path.getFileName().toString().replace(".md", ""); + + // 尝试提取 YAML frontmatter + String name = fileName; + String description = ""; + String whenToUse = ""; + String content = raw; + + if (raw.startsWith("---")) { + int endIdx = raw.indexOf("---", 3); + if (endIdx > 0) { + String frontmatter = raw.substring(3, endIdx).strip(); + content = raw.substring(endIdx + 3).strip(); + + // 简单的 YAML 解析(key: value 格式) + for (String line : frontmatter.split("\n")) { + line = line.strip(); + int colonIdx = line.indexOf(':'); + if (colonIdx > 0) { + String key = line.substring(0, colonIdx).strip(); + String value = line.substring(colonIdx + 1).strip(); + // 去掉引号 + if (value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + switch (key) { + case "name" -> name = value; + case "description" -> description = value; + case "whenToUse" -> whenToUse = value; + } + } + } + } + } + + return new Skill(name, description, whenToUse, content, source, path); + } + + /** + * 获取已加载的技能列表 + */ + public List getSkills() { + return Collections.unmodifiableList(skills); + } + + /** + * 按名称查找技能 + */ + public Optional findByName(String name) { + return skills.stream() + .filter(s -> s.name().equalsIgnoreCase(name)) + .findFirst(); + } + + /** + * 构建技能上下文摘要(注入系统提示词) + */ + public String buildSkillsSummary() { + if (skills.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + sb.append("# Available Skills\n\n"); + for (Skill skill : skills) { + sb.append("- **").append(skill.name()).append("**"); + if (!skill.description().isEmpty()) { + sb.append(": ").append(skill.description()); + } + if (!skill.whenToUse().isEmpty()) { + sb.append(" (use when: ").append(skill.whenToUse()).append(")"); + } + sb.append("\n"); + } + return sb.toString(); + } + + /** + * 技能数据记录 + */ + public record Skill(String name, String description, String whenToUse, + String content, String source, Path filePath) { + } +} diff --git a/src/main/java/com/claudecode/context/SystemPromptBuilder.java b/src/main/java/com/claudecode/context/SystemPromptBuilder.java index a6e8446..ff0b6c8 100644 --- a/src/main/java/com/claudecode/context/SystemPromptBuilder.java +++ b/src/main/java/com/claudecode/context/SystemPromptBuilder.java @@ -3,7 +3,8 @@ package com.claudecode.context; /** * 系统提示词构建器 —— 对应 claude-code/src/prompts.ts。 *

- * 组装完整的系统提示词,包括核心指令、环境信息、工具说明等。 + * 组装完整的系统提示词,包括核心指令、环境信息、 + * CLAUDE.md、Skills、Git 上下文等模块化内容。 */ public class SystemPromptBuilder { @@ -12,6 +13,8 @@ public class SystemPromptBuilder { private String userName; private String claudeMdContent; private String customInstructions; + private String skillsSummary; + private String gitSummary; public SystemPromptBuilder() { this.workDir = System.getProperty("user.dir"); @@ -34,6 +37,16 @@ public class SystemPromptBuilder { return this; } + public SystemPromptBuilder skills(String skillsSummary) { + this.skillsSummary = skillsSummary; + return this; + } + + public SystemPromptBuilder git(String gitSummary) { + this.gitSummary = gitSummary; + return this; + } + /** * 构建完整的系统提示词。 */ @@ -53,6 +66,8 @@ public class SystemPromptBuilder { sb.append("- Working directory: ").append(workDir).append("\n"); sb.append("- OS: ").append(osName).append("\n"); sb.append("- User: ").append(userName).append("\n"); + sb.append("- Shell: ").append(System.getenv().getOrDefault("SHELL", + System.getenv().getOrDefault("COMSPEC", "unknown"))).append("\n"); sb.append("\n"); // 行为准则 @@ -63,15 +78,27 @@ public class SystemPromptBuilder { - Use tools to explore the codebase before making changes - When writing code, follow existing patterns and conventions - Ask for clarification when requirements are ambiguous + - When making file edits, always use the Edit tool with exact string matching + - Prefer editing existing files over creating new ones """); + // Git 上下文 + if (gitSummary != null && !gitSummary.isBlank()) { + sb.append(gitSummary).append("\n"); + } + // CLAUDE.md 内容 if (claudeMdContent != null && !claudeMdContent.isBlank()) { sb.append("# Project Instructions (CLAUDE.md)\n"); sb.append(claudeMdContent).append("\n\n"); } + // Skills 摘要 + if (skillsSummary != null && !skillsSummary.isBlank()) { + sb.append(skillsSummary).append("\n"); + } + // 自定义指令 if (customInstructions != null && !customInstructions.isBlank()) { sb.append("# Custom Instructions\n"); diff --git a/src/main/java/com/claudecode/core/AgentLoop.java b/src/main/java/com/claudecode/core/AgentLoop.java index fd3044a..5f8275b 100644 --- a/src/main/java/com/claudecode/core/AgentLoop.java +++ b/src/main/java/com/claudecode/core/AgentLoop.java @@ -41,6 +41,7 @@ public class AgentLoop { private final ToolRegistry toolRegistry; private final ToolContext toolContext; private final String systemPrompt; + private final TokenTracker tokenTracker; /** 消息历史 —— 自行管理,不依赖 Spring AI ChatMemory */ private final List messageHistory = new ArrayList<>(); @@ -53,10 +54,16 @@ public class AgentLoop { public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, ToolContext toolContext, String systemPrompt) { + this(chatModel, toolRegistry, toolContext, systemPrompt, new TokenTracker()); + } + + public AgentLoop(ChatModel chatModel, ToolRegistry toolRegistry, + ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker) { this.chatModel = chatModel; this.toolRegistry = toolRegistry; this.toolContext = toolContext; this.systemPrompt = systemPrompt; + this.tokenTracker = tokenTracker; // 添加系统提示词到消息历史 this.messageHistory.add(new SystemMessage(systemPrompt)); } @@ -94,6 +101,15 @@ public class AgentLoop { Prompt prompt = new Prompt(List.copyOf(messageHistory), options); ChatResponse response = chatModel.call(prompt); + // 记录 Token 使用量 + if (response.getMetadata() != null && response.getMetadata().getUsage() != null) { + var usage = response.getMetadata().getUsage(); + tokenTracker.recordUsage( + usage.getPromptTokens(), + usage.getCompletionTokens() + ); + } + AssistantMessage assistant = response.getResult().getOutput(); messageHistory.add(assistant); @@ -169,6 +185,16 @@ public class AgentLoop { return Collections.unmodifiableList(messageHistory); } + /** 获取 Token 追踪器 */ + public TokenTracker getTokenTracker() { + return tokenTracker; + } + + /** 获取系统提示词 */ + public String getSystemPrompt() { + return systemPrompt; + } + /** 重置历史(保留系统提示词) */ public void reset() { messageHistory.clear(); diff --git a/src/main/java/com/claudecode/core/TokenTracker.java b/src/main/java/com/claudecode/core/TokenTracker.java new file mode 100644 index 0000000..1dbf5ad --- /dev/null +++ b/src/main/java/com/claudecode/core/TokenTracker.java @@ -0,0 +1,91 @@ +package com.claudecode.core; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Token 使用量追踪器 —— 记录 API 调用的 token 消耗。 + *

+ * 从 ChatResponse 的 usage 元数据中提取 token 统计信息, + * 支持按会话累计和费用估算。 + */ +public class TokenTracker { + + private final AtomicLong totalInputTokens = new AtomicLong(0); + private final AtomicLong totalOutputTokens = new AtomicLong(0); + private final AtomicLong totalCacheReadTokens = new AtomicLong(0); + private final AtomicLong totalCacheCreationTokens = new AtomicLong(0); + private final AtomicLong apiCallCount = new AtomicLong(0); + + /** 模型定价(每百万 token 的美元价格) */ + private double inputPricePerMillion = 3.0; // Claude Sonnet 4 input + private double outputPricePerMillion = 15.0; // Claude Sonnet 4 output + private double cacheReadPricePerMillion = 0.3; // 缓存读取 + private String modelName = "claude-sonnet-4-20250514"; + + /** 记录一次 API 调用的 token 使用 */ + public void recordUsage(long inputTokens, long outputTokens) { + totalInputTokens.addAndGet(inputTokens); + totalOutputTokens.addAndGet(outputTokens); + apiCallCount.incrementAndGet(); + } + + /** 记录一次包含缓存的 API 调用 */ + public void recordUsage(long inputTokens, long outputTokens, long cacheRead, long cacheCreation) { + totalInputTokens.addAndGet(inputTokens); + totalOutputTokens.addAndGet(outputTokens); + totalCacheReadTokens.addAndGet(cacheRead); + totalCacheCreationTokens.addAndGet(cacheCreation); + apiCallCount.incrementAndGet(); + } + + /** 设置模型和对应定价 */ + public void setModel(String model) { + this.modelName = model; + // 根据模型设置定价 + if (model.contains("opus")) { + inputPricePerMillion = 15.0; + outputPricePerMillion = 75.0; + cacheReadPricePerMillion = 1.5; + } else if (model.contains("sonnet")) { + inputPricePerMillion = 3.0; + outputPricePerMillion = 15.0; + cacheReadPricePerMillion = 0.3; + } else if (model.contains("haiku")) { + inputPricePerMillion = 0.25; + outputPricePerMillion = 1.25; + cacheReadPricePerMillion = 0.03; + } + } + + public long getInputTokens() { return totalInputTokens.get(); } + public long getOutputTokens() { return totalOutputTokens.get(); } + public long getCacheReadTokens() { return totalCacheReadTokens.get(); } + public long getCacheCreationTokens() { return totalCacheCreationTokens.get(); } + public long getTotalTokens() { return totalInputTokens.get() + totalOutputTokens.get(); } + public long getApiCallCount() { return apiCallCount.get(); } + public String getModelName() { return modelName; } + + /** 估算当前会话费用(美元) */ + public double estimateCost() { + double inputCost = totalInputTokens.get() * inputPricePerMillion / 1_000_000.0; + double outputCost = totalOutputTokens.get() * outputPricePerMillion / 1_000_000.0; + double cacheCost = totalCacheReadTokens.get() * cacheReadPricePerMillion / 1_000_000.0; + return inputCost + outputCost + cacheCost; + } + + /** 重置统计 */ + public void reset() { + totalInputTokens.set(0); + totalOutputTokens.set(0); + totalCacheReadTokens.set(0); + totalCacheCreationTokens.set(0); + apiCallCount.set(0); + } + + /** 格式化 token 数量(带千位分隔) */ + public static String formatTokens(long tokens) { + if (tokens < 1000) return String.valueOf(tokens); + if (tokens < 1_000_000) return String.format("%.1fK", tokens / 1000.0); + return String.format("%.2fM", tokens / 1_000_000.0); + } +}