From a91413e6f227fb640fbe146efe565e537b064c0f Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Sun, 17 May 2026 23:14:24 +0800 Subject: [PATCH 1/8] feat: adapt Claude Opus 4.7 model support and pricing - Add Claude Opus 4.7 model pricing (input $5, output $25/M tokens) - Add Opus 4.7 Fast Mode pricing (input $30, output $150/M tokens) - Add xhigh thinking effort level for Claude 4.7 Adaptive Thinking - Update model family detection to prioritize Opus 4.7 - Update CI workflow to use claude-opus-4-7 model - Fix partial_cmp unwrap safety in usage statistics sorting - Update i18n translations for new thinking effort level (en/zh/zh-TW) --- .github/workflows/claude.yml | 4 +- src-tauri/src/commands/claude/config.rs | 4 +- src-tauri/src/commands/usage.rs | 33 ++++++++++----- .../ThinkingModeToggle.tsx | 6 ++- .../FloatingPromptInput/constants.tsx | 13 ++++-- src/components/FloatingPromptInput/types.ts | 24 ++++++++--- src/i18n/locales/en.json | 5 ++- src/i18n/locales/zh-TW.json | 2 + src/i18n/locales/zh.json | 5 ++- src/lib/pricing.ts | 41 ++++++++++++++++--- 10 files changed, 105 insertions(+), 32 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 40088f80..84678c0f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,8 +35,8 @@ jobs: with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - model: "claude-opus-4-20250514" + # Optional: Specify model (defaults to Claude Sonnet 4.6, uncomment for Claude Opus 4.7) + model: "claude-opus-4-7" # Optional: Customize the trigger phrase (default: @claude) # trigger_phrase: "/claude" diff --git a/src-tauri/src/commands/claude/config.rs b/src-tauri/src/commands/claude/config.rs index ada902b0..8f4aa8d3 100644 --- a/src-tauri/src/commands/claude/config.rs +++ b/src-tauri/src/commands/claude/config.rs @@ -386,7 +386,7 @@ pub async fn save_claude_settings(settings: serde_json::Value) -> Result) -> Result { @@ -425,7 +425,7 @@ pub async fn update_thinking_mode(enabled: bool, effort: Option) -> Resu .as_object_mut() .ok_or("env is not an object")?; - // Update CLAUDE_CODE_THINKING_EFFORT (Claude 4.6 Adaptive Thinking) + // Update CLAUDE_CODE_THINKING_EFFORT (Claude 4.7 Adaptive Thinking) if enabled { let effort_value = effort.unwrap_or_else(|| "high".to_string()); env_obj.insert( diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index d8834205..4cd43091 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -69,7 +69,7 @@ pub struct ProjectUsage { // ============================================================================ // Claude Model Pricing - Single Source of Truth // Source: https://platform.claude.com/docs/en/about-claude/pricing -// Last Updated: February 2026 +// Last Updated: May 2026 // ============================================================================ /// Model pricing structure (prices per million tokens) @@ -84,6 +84,7 @@ struct ModelPricing { /// Model family enumeration for categorization #[derive(Debug, Clone, Copy, PartialEq)] enum ModelFamily { + Opus47, // Claude 4.7 Opus (Latest) Opus46, // Claude 4.6 Opus Sonnet46, // Claude 4.6 Sonnet Opus45, // Claude 4.5 Opus @@ -97,7 +98,14 @@ impl ModelPricing { /// Get pricing for a specific model family const fn for_family(family: ModelFamily) -> Self { match family { - // Claude 4.6 Series (Latest - February 2026) + // Claude 4.7 Series (Latest - May 2026) + ModelFamily::Opus47 => ModelPricing { + input: 5.0, + output: 25.0, + cache_write: 6.25, + cache_read: 0.50, + }, + // Claude 4.6 Series ModelFamily::Opus46 => ModelPricing { input: 5.0, output: 25.0, @@ -168,7 +176,12 @@ fn parse_model_family(model: &str) -> ModelFamily { // Priority-based matching (order matters!) // Check for specific model families in order from most to least specific - // Claude 4.6 Series (Latest) + // Claude 4.7 Series (Latest) + if normalized.contains("opus") && (normalized.contains("4.7") || normalized.contains("4-7")) { + return ModelFamily::Opus47; + } + + // Claude 4.6 Series if normalized.contains("opus") && (normalized.contains("4.6") || normalized.contains("4-6")) { return ModelFamily::Opus46; } @@ -197,7 +210,7 @@ fn parse_model_family(model: &str) -> ModelFamily { return ModelFamily::Haiku45; // Default to latest Haiku } if normalized.contains("opus") { - return ModelFamily::Opus46; // Default to latest Opus + return ModelFamily::Opus47; // Default to latest Opus } if normalized.contains("sonnet") { return ModelFamily::Sonnet46; // Default to latest Sonnet @@ -562,13 +575,13 @@ pub fn get_usage_stats(days: Option) -> Result { // Convert hashmaps to sorted vectors let mut by_model: Vec = model_stats.into_values().collect(); - by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap()); + by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap_or(std::cmp::Ordering::Equal)); let mut by_date: Vec = daily_stats.into_values().collect(); by_date.sort_by(|a, b| a.date.cmp(&b.date)); let mut by_project: Vec = project_stats.into_values().collect(); - by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap()); + by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap_or(std::cmp::Ordering::Equal)); Ok(UsageStats { total_cost, @@ -734,13 +747,13 @@ pub fn get_usage_by_date_range(start_date: String, end_date: String) -> Result = filtered_entries.iter().map(|e| &e.session_id).collect(); let mut by_model: Vec = model_stats.into_values().collect(); - by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap()); + by_model.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap_or(std::cmp::Ordering::Equal)); let mut by_date: Vec = daily_stats.into_values().collect(); by_date.sort_by(|a, b| a.date.cmp(&b.date)); let mut by_project: Vec = project_stats.into_values().collect(); - by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap()); + by_project.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap_or(std::cmp::Ordering::Equal)); Ok(UsageStats { total_cost, @@ -827,9 +840,9 @@ pub fn get_session_stats( // Sort by order let order_str = order.unwrap_or_else(|| "desc".to_string()); if order_str == "asc" { - by_session.sort_by(|a, b| a.total_cost.partial_cmp(&b.total_cost).unwrap()); + by_session.sort_by(|a, b| a.total_cost.partial_cmp(&b.total_cost).unwrap_or(std::cmp::Ordering::Equal)); } else { - by_session.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap()); + by_session.sort_by(|a, b| b.total_cost.partial_cmp(&a.total_cost).unwrap_or(std::cmp::Ordering::Equal)); } Ok(by_session) diff --git a/src/components/FloatingPromptInput/ThinkingModeToggle.tsx b/src/components/FloatingPromptInput/ThinkingModeToggle.tsx index b5b9445c..e1d3d5ca 100644 --- a/src/components/FloatingPromptInput/ThinkingModeToggle.tsx +++ b/src/components/FloatingPromptInput/ThinkingModeToggle.tsx @@ -17,6 +17,7 @@ const EFFORT_COLORS: Record = { low: "bg-blue-600 hover:bg-blue-700 border-blue-600 shadow-blue-500/20", medium: "bg-amber-600 hover:bg-amber-700 border-amber-600 shadow-amber-500/20", high: "bg-orange-600 hover:bg-orange-700 border-orange-600 shadow-orange-500/20", + xhigh: "bg-rose-600 hover:bg-rose-700 border-rose-600 shadow-rose-500/20", max: "bg-red-600 hover:bg-red-700 border-red-600 shadow-red-500/20", }; @@ -24,12 +25,13 @@ const EFFORT_LABELS: Record = { low: "Low", medium: "Med", high: "High", + xhigh: "XHigh", max: "Max", }; /** - * ThinkingModeToggle - Adaptive Thinking with effort levels (Claude 4.6) - * Click to cycle: off → high → max → low → medium → off + * ThinkingModeToggle - Adaptive Thinking with effort levels (Claude 4.7) + * Click to cycle: off → high → xhigh → max → low → medium → off */ export const ThinkingModeToggle: React.FC = ({ isEnabled, diff --git a/src/components/FloatingPromptInput/constants.tsx b/src/components/FloatingPromptInput/constants.tsx index 1ab03d16..f2e67d23 100644 --- a/src/components/FloatingPromptInput/constants.tsx +++ b/src/components/FloatingPromptInput/constants.tsx @@ -8,7 +8,7 @@ import { getCachedModelNames } from "@/lib/modelNameParser"; */ const DEFAULT_MODEL_NAMES: Record = { sonnet: "Claude Sonnet 4.6", - opus: "Claude Opus 4.6", + opus: "Claude Opus 4.7", }; /** @@ -63,7 +63,7 @@ export const MODELS: ModelConfig[] = getModels(); /** * Thinking modes configuration - * Claude 4.6 Adaptive Thinking with effort levels + * Claude 4.7 Adaptive Thinking with effort levels * Controls thinking depth via CLAUDE_CODE_THINKING_EFFORT env var * * Note: Names and descriptions are translation keys that will be resolved at runtime @@ -96,11 +96,18 @@ export const THINKING_MODES: ThinkingModeConfig[] = [ description: "promptInput.thinkingEffortHighDesc", level: 3, }, + { + id: "adaptive", + effort: "xhigh", + name: "promptInput.thinkingEffortXHigh", + description: "promptInput.thinkingEffortXHighDesc", + level: 4, + }, { id: "adaptive", effort: "max", name: "promptInput.thinkingEffortMax", description: "promptInput.thinkingEffortMaxDesc", - level: 4, + level: 5, } ]; diff --git a/src/components/FloatingPromptInput/types.ts b/src/components/FloatingPromptInput/types.ts index c13fe621..7b302e8d 100644 --- a/src/components/FloatingPromptInput/types.ts +++ b/src/components/FloatingPromptInput/types.ts @@ -2,19 +2,29 @@ import { ReactNode } from "react"; /** * Model type definition + * + * 内置模型使用固定字符串; + * 用户自定义模型使用 `custom:` 形式(modelId 即实际传给 CLI 的 ANTHROPIC_MODEL); + * `"custom"` 保留作为来自环境变量 ANTHROPIC_MODEL 的兼容入口。 */ -export type ModelType = "sonnet" | "opus" | "sonnet1m" | "opus1m" | "custom"; +export type ModelType = + | "sonnet" + | "opus" + | "sonnet1m" + | "opus1m" + | "custom" + | `custom:${string}`; /** * Thinking mode type definition - * Claude 4.6 Adaptive Thinking with effort levels + * Claude 4.7 Adaptive Thinking with effort levels */ export type ThinkingMode = "off" | "adaptive"; /** - * Thinking effort level (Claude 4.6 Adaptive Thinking) + * Thinking effort level (Claude 4.7 Adaptive Thinking) */ -export type ThinkingEffort = "low" | "medium" | "high" | "max"; +export type ThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; /** * Model configuration @@ -24,6 +34,10 @@ export interface ModelConfig { name: string; description: string; icon: ReactNode; + /** 自定义模型实际传给 CLI 的模型字符串(ANTHROPIC_MODEL);内置模型不需要 */ + modelId?: string; + /** 标记是否为用户自定义模型(来自 customModelStorage 或 env 变量) */ + isCustom?: boolean; } /** @@ -34,7 +48,7 @@ export interface ThinkingModeConfig { effort?: ThinkingEffort; // Effort level for adaptive thinking name: string; description: string; - level: number; // 0-4 for visual indicator + level: number; // 0-5 for visual indicator } /** diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eef4347c..160c75f6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -798,7 +798,8 @@ "subagent": "Subagent", "executionProcess": "Execution Process", "messageCount": "{{count}} messages", - "noSubagentMessages": "No subagent messages" + "noSubagentMessages": "No subagent messages", + "executing": "Subagent executing..." }, "provider": { "loadConfigFailed": "Failed to load provider config", @@ -1029,6 +1030,8 @@ "thinkingEffortMediumDesc": "Balance speed and thinking depth", "thinkingEffortHigh": "Thinking: High", "thinkingEffortHighDesc": "Deep thinking, recommended for complex tasks", + "thinkingEffortXHigh": "Thinking: Extra High", + "thinkingEffortXHighDesc": "Extra deep thinking, best for coding and agentic tasks", "thinkingEffortMax": "Thinking: Max", "thinkingEffortMaxDesc": "Maximum thinking depth for hardest reasoning", "deepThinkingTokens": "Claude will perform deep thinking (10K tokens)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 1c6cb82a..2d400d30 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1025,6 +1025,8 @@ "thinkingEffortMediumDesc": "平衡速度與思考深度", "thinkingEffortHigh": "思考: 高", "thinkingEffortHighDesc": "深度思考,推薦用於複雜任務", + "thinkingEffortXHigh": "思考: 超高", + "thinkingEffortXHighDesc": "超深度思考,最適合程式設計和代理任務", "thinkingEffortMax": "思考: 最大", "thinkingEffortMaxDesc": "最大思考深度,適合最複雜的推理", "deepThinkingTokens": "Claude 將進行深度思考 (10K tokens)", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 944d80d3..66e397d5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -798,7 +798,8 @@ "subagent": "子代理", "executionProcess": "执行过程", "messageCount": "{{count}} 条消息", - "noSubagentMessages": "暂无子代理消息" + "noSubagentMessages": "暂无子代理消息", + "executing": "子代理执行中..." }, "provider": { "loadConfigFailed": "加载代理商配置失败", @@ -1029,6 +1030,8 @@ "thinkingEffortMediumDesc": "平衡速度与思考深度", "thinkingEffortHigh": "思考: 高", "thinkingEffortHighDesc": "深度思考,推荐用于复杂任务", + "thinkingEffortXHigh": "思考: 超高", + "thinkingEffortXHighDesc": "超深度思考,最适合编程和代理任务", "thinkingEffortMax": "思考: 最大", "thinkingEffortMaxDesc": "最大思考深度,适合最复杂的推理", "deepThinkingTokens": "Claude 将进行深度思考 (10K tokens)", diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index e20dcda6..a32293fb 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -5,7 +5,7 @@ * Claude 定价:https://platform.claude.com/docs/en/about-claude/pricing * Codex 定价:https://platform.openai.com/docs/pricing (codex-mini-latest) * 价格单位:美元/百万 tokens - * Last Updated: March 2026 + * Last Updated: May 2026 */ export interface ModelPricing { @@ -24,7 +24,28 @@ export const MODEL_PRICING: Record = { // Claude Models (Anthropic) // ============================================================================ - // Claude 4.6 Series (Latest - February 2026) + // Claude 4.7 Series (Latest - May 2026) + 'claude-opus-4.7': { + input: 5.0, + output: 25.0, + cacheWrite: 6.25, + cacheRead: 0.50 + }, + 'claude-opus-4.7-1m': { + input: 5.0, + output: 25.0, + cacheWrite: 6.25, + cacheRead: 0.50 + }, + // Claude Opus 4.7 Fast Mode (faster output, higher cost) + 'claude-opus-4.7-fast': { + input: 30.0, + output: 150.0, + cacheWrite: 37.5, + cacheRead: 3.0 + }, + + // Claude 4.6 Series 'claude-opus-4.6': { input: 5.0, output: 25.0, @@ -39,8 +60,8 @@ export const MODEL_PRICING: Record = { }, // Claude Opus 4.6 Fast Mode (2.5x faster, higher cost) 'claude-opus-4.6-fast': { - input: 30.0, // $30 / 1M input tokens (<200K context) - output: 150.0, // $150 / 1M output tokens + input: 30.0, + output: 150.0, cacheWrite: 37.5, cacheRead: 3.0 }, @@ -391,7 +412,15 @@ export function getPricingForModel(model?: string, engine?: string): ModelPricin // Claude Models (Anthropic) // ============================================================================ - // Claude 4.6 Series (Latest) + // Claude 4.7 Series (Latest) + if (normalized.includes('opus') && (normalized.includes('4.7') || normalized.includes('4-7'))) { + if (normalized.includes('fast')) { + return MODEL_PRICING['claude-opus-4.7-fast']; + } + return MODEL_PRICING['claude-opus-4.7']; + } + + // Claude 4.6 Series if (normalized.includes('opus') && (normalized.includes('4.6') || normalized.includes('4-6'))) { if (normalized.includes('fast')) { return MODEL_PRICING['claude-opus-4.6-fast']; @@ -423,7 +452,7 @@ export function getPricingForModel(model?: string, engine?: string): ModelPricin return MODEL_PRICING['claude-haiku-4.5']; // Default to latest } if (normalized.includes('opus')) { - return MODEL_PRICING['claude-opus-4.6']; // Default to latest + return MODEL_PRICING['claude-opus-4.7']; // Default to latest } if (normalized.includes('sonnet')) { return MODEL_PRICING['claude-sonnet-4.6']; // Default to latest From 00382dac4a300938848b7e9fe174bf8624f114ff Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Sun, 17 May 2026 23:15:11 +0800 Subject: [PATCH 2/8] feat: add desktop notification, custom model management and editor integration - Add desktop notification service for AI response completion when window unfocused - Add custom model manager with API model fetching and manual entry support - Refactor ModelSelector to group built-in and custom models with action buttons - Add open_path_in_editor command supporting JetBrains IDEs, VS Code, and system editors - Add fetch_anthropic_models backend command via /v1/models endpoint - Add disable_auto_commit_after_response option to ClaudeExecutionConfig - Add 10-second cache for Codex and Gemini session listings - Register notification plugin and required Tauri capabilities --- package.json | 5 +- src-tauri/Cargo.lock | 2 +- src-tauri/capabilities/default.json | 4 + src-tauri/src/commands/file_operations.rs | 66 ++++ src-tauri/src/commands/provider.rs | 98 +++++- src-tauri/src/main.rs | 7 +- .../CustomModelManagerDialog.tsx | 316 ++++++++++++++++++ .../FloatingPromptInput/ModelSelector.tsx | 171 ++++++---- .../FloatingPromptInput/customModelStorage.ts | 127 +++++++ .../defaultModelStorage.ts | 5 +- src/components/FloatingPromptInput/index.tsx | 107 ++++-- src/lib/api.ts | 68 +++- src/lib/notificationService.ts | 40 +++ src/main.tsx | 5 + 14 files changed, 902 insertions(+), 119 deletions(-) create mode 100644 src/components/FloatingPromptInput/CustomModelManagerDialog.tsx create mode 100644 src/components/FloatingPromptInput/customModelStorage.ts create mode 100644 src/lib/notificationService.ts diff --git a/package.json b/package.json index 8e065cae..2f85058f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.3.3", @@ -80,6 +81,8 @@ }, "trustedDependencies": [ "@parcel/watcher", - "@tailwindcss/oxide" + "@tailwindcss/oxide", + "esbuild", + "sharp" ] } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b48f0774..9e9e4935 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "any-code" -version = "5.27.8" +version = "5.28.5" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index defde956..46cc4680 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -51,6 +51,10 @@ "process:allow-restart", "process:allow-exit", "clipboard-manager:allow-write-text", + "notification:default", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", "updater:default", { "identifier": "http:default", diff --git a/src-tauri/src/commands/file_operations.rs b/src-tauri/src/commands/file_operations.rs index 4ae036a8..833917cd 100644 --- a/src-tauri/src/commands/file_operations.rs +++ b/src-tauri/src/commands/file_operations.rs @@ -1,5 +1,71 @@ use std::process::Command as StdCommand; +fn try_editor_command(editor: &str, file_path: &str, line: Option, column: Option) -> Result<(), String> { + let mut cmd = StdCommand::new(editor); + + match editor { + "idea" | "webstorm" | "pycharm" | "goland" | "rubymine" | "phpstorm" | "clion" | "rider" => { + if let Some(l) = line { + cmd.arg("--line").arg(l.to_string()); + if let Some(c) = column { + cmd.arg("--column").arg(c.to_string()); + } + } + cmd.arg(file_path); + } + _ => { + let goto = match (line, column) { + (Some(l), Some(c)) => format!("{}:{}:{}", file_path, l, c), + (Some(l), None) => format!("{}:{}", file_path, l), + _ => file_path.to_string(), + }; + if line.is_some() { + cmd.arg("--goto").arg(&goto); + } else { + cmd.arg(&goto); + } + } + } + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x08000000); + } + + cmd.spawn() + .map(|_| ()) + .map_err(|e| format!("{}: {}", editor, e)) +} + +#[tauri::command] +pub async fn open_path_in_editor( + file_path: String, + line: Option, + column: Option, + editor: Option, +) -> Result<(), String> { + let preference = editor.unwrap_or_else(|| "auto".to_string()); + + let candidates: Vec<&str> = match preference.as_str() { + "system" => vec![], + "cursor" => vec!["cursor"], + "code" | "vscode" => vec!["code"], + "idea" | "jetbrains" => vec!["idea"], + "webstorm" => vec!["webstorm"], + "pycharm" => vec!["pycharm"], + _ => vec!["cursor", "code", "idea"], + }; + + for editor_bin in &candidates { + if try_editor_command(editor_bin, &file_path, line, column).is_ok() { + return Ok(()); + } + } + + open_file_with_default_app(file_path).await +} + /// Open a directory in the system file explorer (cross-platform) #[tauri::command] pub async fn open_directory_in_explorer(directory_path: String) -> Result<(), String> { diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 524d7fa6..ee6fe00c 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -298,16 +298,17 @@ pub async fn switch_provider_config( return Err("settings.json格式错误".to_string()); } - let settings_obj = settings.as_object_mut().unwrap(); + let settings_obj = settings + .as_object_mut() + .ok_or("settings.json 格式错误")?; if !settings_obj.contains_key("env") { settings_obj.insert("env".to_string(), serde_json::json!({})); } let env_obj = settings_obj .get_mut("env") - .unwrap() - .as_object_mut() - .ok_or("env字段格式错误")?; + .and_then(|v| v.as_object_mut()) + .ok_or("env 字段格式错误")?; // 清理之前的ANTHROPIC环境变量 env_obj.remove("ANTHROPIC_API_KEY"); @@ -392,7 +393,9 @@ pub async fn switch_provider_config( // apiKeyHelper 根据用户勾选状态决定是否自动生成 if config.enable_auto_api_key_helper.unwrap_or(false) { if let Some(token) = auth_token { - let helper_command = format!("echo '{}'", token); + // POSIX 单引号转义:' -> '\'' ,防止 token 中的引号破坏命令 + let escaped = token.replace('\'', "'\\''"); + let helper_command = format!("echo '{}'", escaped); settings_obj.insert( "apiKeyHelper".to_string(), serde_json::Value::String(helper_command), @@ -635,3 +638,88 @@ pub async fn query_provider_usage( query_end_date: end_date, }) } + +/// 从 Provider 的 /v1/models 接口拉取可用模型列表 +#[derive(Debug, Serialize, Deserialize)] +pub struct AnthropicModelInfo { + /// 模型 ID(如 "claude-sonnet-4-20250514") + pub id: String, + /// 显示名称(如果 API 返回了 display_name) + pub display_name: Option, +} + +#[command] +pub async fn fetch_anthropic_models() -> Result, String> { + use reqwest::Client; + + log::info!("开始拉取 Anthropic 可用模型列表"); + + // 从当前 provider 配置中获取 base_url 和 auth + let current = get_current_provider_config() + .map_err(|e| format!("获取当前 Provider 配置失败: {}", e))?; + + let base_url = current + .anthropic_base_url + .unwrap_or_else(|| "https://api.anthropic.com".to_string()); + + // 优先使用 api_key,其次 auth_token + let auth_value = current + .anthropic_api_key + .or(current.anthropic_auth_token) + .ok_or_else(|| "未配置 API Key 或 Auth Token,无法拉取模型列表".to_string())?; + + let normalized_base = normalize_base_url(&base_url); + let models_url = format!("{}/v1/models", normalized_base); + + log::info!("请求模型列表: {}", models_url); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + let response = client + .get(&models_url) + .header("x-api-key", &auth_value) + .header("anthropic-version", "2023-06-01") + .header("Authorization", format!("Bearer {}", auth_value)) + .send() + .await + .map_err(|e| format!("请求模型列表失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("模型列表请求失败: {} - {}", status, body)); + } + + let body: Value = response + .json() + .await + .map_err(|e| format!("解析模型列表响应失败: {}", e))?; + + // 兼容 Anthropic 官方格式 { "data": [...] } 和 OpenAI 兼容格式 { "data": [...] } + let models_array = body + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut result: Vec = Vec::new(); + for item in models_array { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + let display_name = item + .get("display_name") + .or_else(|| item.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + result.push(AnthropicModelInfo { + id: id.to_string(), + display_name, + }); + } + } + + log::info!("成功获取 {} 个模型", result.len()); + Ok(result) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4fce2d41..15fbd876 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,7 +56,7 @@ use commands::prompt_tracker::{ record_prompt_sent, revert_to_prompt, }; use commands::provider::{ - add_provider_config, clear_provider_config, delete_provider_config, + add_provider_config, clear_provider_config, delete_provider_config, fetch_anthropic_models, get_current_provider_config, get_provider_config, get_provider_presets, query_provider_usage, reorder_provider_configs, switch_provider_config, test_provider_connection, update_provider_config, }; @@ -129,7 +129,7 @@ use commands::extensions::{ open_commands_directory, open_plugins_directory, open_skills_directory, read_skill, read_subagent, reinstall_plugin, toggle_plugin_enabled, uninstall_plugin, }; -use commands::file_operations::{open_directory_in_explorer, open_file_with_default_app}; +use commands::file_operations::{open_directory_in_explorer, open_file_with_default_app, open_path_in_editor}; use commands::gemini::{ add_gemini_provider_config, cancel_gemini, @@ -184,6 +184,7 @@ fn main() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_notification::init()) .plugin( WindowStatePlugin::default() .with_state_flags(tauri_plugin_window_state::StateFlags::all()) @@ -402,6 +403,7 @@ fn main() { switch_provider_config, clear_provider_config, test_provider_connection, + fetch_anthropic_models, add_provider_config, update_provider_config, delete_provider_config, @@ -460,6 +462,7 @@ fn main() { // File Operations open_directory_in_explorer, open_file_with_default_app, + open_path_in_editor, // Git Statistics get_git_diff_stats, get_session_code_changes, diff --git a/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx b/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx new file mode 100644 index 00000000..9b95f118 --- /dev/null +++ b/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useState } from "react"; +import { Loader2, Plus, Trash2, RefreshCw, Download, AlertCircle } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + CustomModel, + addCustomModel, + getCustomModels, + removeCustomModel, +} from "./customModelStorage"; +import { api, AnthropicModelInfo } from "@/lib/api"; + +interface CustomModelManagerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * 自定义模型管理对话框 + * + * 三块布局: + * 1. 已添加模型列表(可删除) + * 2. 手动添加(modelId + 显示名) + * 3. 从 Provider /v1/models 接口拉取(多选 → 批量添加) + */ +export const CustomModelManagerDialog: React.FC = ({ + open, + onOpenChange, +}) => { + const [list, setList] = useState([]); + + const [manualModelId, setManualModelId] = useState(""); + const [manualName, setManualName] = useState(""); + const [manualError, setManualError] = useState(null); + + const [fetching, setFetching] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [remoteModels, setRemoteModels] = useState([]); + const [selectedRemote, setSelectedRemote] = useState>(new Set()); + + // 打开时刷新本地列表 + useEffect(() => { + if (open) { + setList(getCustomModels()); + setManualError(null); + setFetchError(null); + } + }, [open]); + + const handleAddManual = () => { + const id = manualModelId.trim(); + if (!id) { + setManualError("请填写 Model ID"); + return; + } + addCustomModel({ + modelId: id, + name: manualName.trim() || id, + source: "manual", + }); + setManualModelId(""); + setManualName(""); + setManualError(null); + setList(getCustomModels()); + }; + + const handleRemove = (id: string) => { + removeCustomModel(id); + setList(getCustomModels()); + }; + + const handleFetch = async () => { + setFetching(true); + setFetchError(null); + try { + const result = await api.fetchAnthropicModels(); + setRemoteModels(result); + // 默认全部选中本地未存在的 + const existingIds = new Set(getCustomModels().map((m) => m.modelId)); + setSelectedRemote( + new Set(result.filter((m) => !existingIds.has(m.id)).map((m) => m.id)), + ); + if (result.length === 0) { + setFetchError("接口返回空列表,请检查 Provider 是否支持 /v1/models"); + } + } catch (error) { + setFetchError(error instanceof Error ? error.message : String(error)); + setRemoteModels([]); + } finally { + setFetching(false); + } + }; + + const handleImportSelected = () => { + if (selectedRemote.size === 0) return; + for (const modelId of selectedRemote) { + const remote = remoteModels.find((m) => m.id === modelId); + if (!remote) continue; + addCustomModel({ + modelId: remote.id, + name: remote.display_name || remote.id, + source: "api", + }); + } + setSelectedRemote(new Set()); + setList(getCustomModels()); + }; + + const toggleRemote = (id: string) => { + setSelectedRemote((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( + + + + 管理自定义模型 + + 添加自定义 Claude 兼容模型。手动输入 Model ID 或从当前 Provider 接口拉取。 + + + +
+ {/* 已添加列表 */} +
+
+

已添加的模型

+ {list.length} 个 +
+ {list.length === 0 ? ( +
+ 暂未添加自定义模型 +
+ ) : ( +
+ {list.map((m) => ( +
+
+
{m.name}
+
+ {m.modelId} +
+
+ + {m.source === "api" ? "API" : "手动"} + + +
+ ))} +
+ )} +
+ + {/* 手动添加 */} +
+

手动添加

+
+ setManualModelId(e.target.value)} + inputSize="sm" + onKeyDown={(e) => { + if (e.key === "Enter") handleAddManual(); + }} + /> + setManualName(e.target.value)} + inputSize="sm" + onKeyDown={(e) => { + if (e.key === "Enter") handleAddManual(); + }} + /> +
+ {manualError && ( +
+ + {manualError} +
+ )} + +
+ + {/* 接口拉取 */} +
+
+

从 Provider 接口拉取

+ +
+

+ 调用当前 Provider 的 /v1/models 接口(需先在「代理商」中配置 Auth Token / API Key)。 +

+ {fetchError && ( +
+ + {fetchError} +
+ )} + {remoteModels.length > 0 && ( + <> +
+ {remoteModels.map((m) => { + const checked = selectedRemote.has(m.id); + const existing = list.some((x) => x.modelId === m.id); + return ( + + ); + })} +
+
+ + 已选 {selectedRemote.size} / {remoteModels.length} + + +
+ + )} +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/FloatingPromptInput/ModelSelector.tsx b/src/components/FloatingPromptInput/ModelSelector.tsx index 2416648d..8c832deb 100644 --- a/src/components/FloatingPromptInput/ModelSelector.tsx +++ b/src/components/FloatingPromptInput/ModelSelector.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { ChevronUp, Check, Star } from "lucide-react"; +import { ChevronUp, Check, Star, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { ModelType, ModelConfig } from "./types"; import { MODELS } from "./constants"; import { getDefaultModel, setDefaultModel } from "./defaultModelStorage"; +import { CustomModelManagerDialog } from "./CustomModelManagerDialog"; interface ModelSelectorProps { selectedModel: ModelType; @@ -24,86 +25,120 @@ export const ModelSelector: React.FC = ({ availableModels = MODELS }) => { const [open, setOpen] = React.useState(false); + const [managerOpen, setManagerOpen] = React.useState(false); const [currentDefaultModel, setCurrentDefaultModel] = React.useState(() => getDefaultModel()); + + // 内置模型 vs 自定义模型分组 + const builtInModels = availableModels.filter((m) => !m.isCustom); + const customModels = availableModels.filter((m) => m.isCustom); + const selectedModelData = availableModels.find(m => m.id === selectedModel) || availableModels[0]; - // Handle setting default model const handleSetDefault = (e: React.MouseEvent, modelId: ModelType) => { e.stopPropagation(); setDefaultModel(modelId); setCurrentDefaultModel(modelId); }; - return ( - - {selectedModelData.icon} - {selectedModelData.name} - {currentDefaultModel === selectedModel && ( - + const renderModelRow = (model: ModelConfig) => ( + - } - content={ -
-
- 选择模型(点击星标设为新会话默认) -
- {availableModels.map((model) => ( - + + ); + + return ( + <> + + {selectedModelData.icon} + {selectedModelData.name} + {currentDefaultModel === selectedModel && ( + + )} + + + } + content={ +
+
+ 选择模型(点击星标设为新会话默认) +
+ + {builtInModels.map(renderModelRow)} + + {customModels.length > 0 && ( + <> +
+ 自定义模型
-
+ {customModels.map(renderModelRow)} + + )} + +
- - ))} -
- } - open={open} - onOpenChange={setOpen} - align="start" - side="top" - /> +
+ + } + open={open} + onOpenChange={setOpen} + align="start" + side="top" + /> + + + ); }; diff --git a/src/components/FloatingPromptInput/customModelStorage.ts b/src/components/FloatingPromptInput/customModelStorage.ts new file mode 100644 index 00000000..c8539db9 --- /dev/null +++ b/src/components/FloatingPromptInput/customModelStorage.ts @@ -0,0 +1,127 @@ +/** + * Custom Model Storage + * + * 管理用户自定义的 Claude 模型列表(手动输入 + API 拉取)。 + * 全局多条,存 localStorage,支持增删改。 + */ + +const STORAGE_KEY = "claude_custom_models"; + +/** + * 自定义模型列表变更事件名。 + * UI 组件订阅此事件以在增删改后实时刷新可选模型列表。 + */ +export const CUSTOM_MODELS_UPDATED_EVENT = "custom_models_updated"; + +function emitChange() { + try { + window.dispatchEvent(new CustomEvent(CUSTOM_MODELS_UPDATED_EVENT)); + } catch { + // SSR 或测试环境无 window,安全忽略 + } +} + +export interface CustomModel { + /** 内部 id,形如 "custom:claude-3-5-sonnet-20241022" */ + id: string; + /** 模型显示名称 */ + name: string; + /** 实际传给 CLI 的模型字符串(ANTHROPIC_MODEL) */ + modelId: string; + /** 来源标识:手动输入 / API 拉取 */ + source: "manual" | "api"; + /** 添加时间戳 */ + addedAt: number; +} + +/** + * 获取所有自定义模型(按添加时间倒序) + */ +export function getCustomModels(): CustomModel[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const list = JSON.parse(raw); + if (!Array.isArray(list)) return []; + return list + .filter((m: any): m is CustomModel => + m && + typeof m.id === "string" && + typeof m.name === "string" && + typeof m.modelId === "string" + ) + .sort((a, b) => b.addedAt - a.addedAt); + } catch (error) { + console.warn("[customModelStorage] Failed to read custom models:", error); + return []; + } +} + +/** + * 添加一个自定义模型;如 modelId 已存在则更新名称。 + */ +export function addCustomModel(input: { + name: string; + modelId: string; + source: "manual" | "api"; +}): CustomModel { + const list = getCustomModels(); + const trimmedId = input.modelId.trim(); + const trimmedName = (input.name || trimmedId).trim(); + + const existing = list.find((m) => m.modelId === trimmedId); + if (existing) { + existing.name = trimmedName; + existing.source = input.source; + saveAll(list); + emitChange(); + return existing; + } + + const created: CustomModel = { + id: `custom:${trimmedId}`, + name: trimmedName, + modelId: trimmedId, + source: input.source, + addedAt: Date.now(), + }; + saveAll([created, ...list]); + emitChange(); + return created; +} + +/** + * 按 id 删除自定义模型 + */ +export function removeCustomModel(id: string): void { + const list = getCustomModels().filter((m) => m.id !== id); + saveAll(list); + emitChange(); +} + +/** + * 清空全部自定义模型 + */ +export function clearCustomModels(): void { + try { + localStorage.removeItem(STORAGE_KEY); + emitChange(); + } catch (error) { + console.error("[customModelStorage] Failed to clear:", error); + } +} + +function saveAll(list: CustomModel[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } catch (error) { + console.error("[customModelStorage] Failed to save:", error); + } +} + +/** + * 判断给定 model id 是否为自定义模型 id("custom:xxx") + */ +export function isCustomModelId(id: string): boolean { + return id.startsWith("custom:"); +} diff --git a/src/components/FloatingPromptInput/defaultModelStorage.ts b/src/components/FloatingPromptInput/defaultModelStorage.ts index 968214ca..aa04e79d 100644 --- a/src/components/FloatingPromptInput/defaultModelStorage.ts +++ b/src/components/FloatingPromptInput/defaultModelStorage.ts @@ -63,5 +63,8 @@ export function isDefaultModel(model: ModelType): boolean { * 验证模型类型是否有效 */ function isValidModelType(value: string): value is ModelType { - return ["sonnet", "opus", "sonnet1m", "opus1m", "custom"].includes(value); + if (["sonnet", "opus", "sonnet1m", "opus1m", "custom"].includes(value)) { + return true; + } + return value.startsWith("custom:") && value.length > "custom:".length; } diff --git a/src/components/FloatingPromptInput/index.tsx b/src/components/FloatingPromptInput/index.tsx index 894b4c34..d62906af 100644 --- a/src/components/FloatingPromptInput/index.tsx +++ b/src/components/FloatingPromptInput/index.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; import { FloatingPromptInputProps, FloatingPromptInputRef, ThinkingMode, ThinkingEffort, ModelType, ModelConfig } from "./types"; import { getModels } from "./constants"; import { MODEL_NAMES_UPDATED_EVENT } from "@/lib/modelNameParser"; +import { getCustomModels, CUSTOM_MODELS_UPDATED_EVENT, isCustomModelId } from "./customModelStorage"; import { useImageHandling } from "./hooks/useImageHandling"; import { useFileSelection } from "./hooks/useFileSelection"; import { usePromptEnhancement } from "./hooks/usePromptEnhancement"; @@ -66,6 +67,11 @@ const FloatingPromptInputInner = ( if (lowerModel.includes("sonnet") && lowerModel.includes("1m")) return "sonnet1m"; if (lowerModel.includes("sonnet")) return "sonnet"; + // 历史会话用 ANTHROPIC_MODEL 真实字符串保存(如 claude-3-5-sonnet-20241022) + // 若与本地自定义模型匹配,恢复为 "custom:" + const matched = getCustomModels().find((m) => m.modelId === modelStr); + if (matched) return matched.id as ModelType; + return null; }; @@ -116,13 +122,13 @@ const FloatingPromptInputInner = ( }, []); // Initialize thinking mode from settings.json (source of truth) - // Claude 4.6: Read CLAUDE_CODE_THINKING_EFFORT from settings.json env + // Claude 4.7: Read CLAUDE_CODE_THINKING_EFFORT from settings.json env useEffect(() => { const initThinkingMode = async () => { try { const settings = await api.getClaudeSettings(); const effort = settings?.env?.CLAUDE_CODE_THINKING_EFFORT; - if (effort && ['low', 'medium', 'high', 'max'].includes(effort)) { + if (effort && ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) { dispatch({ type: "SET_THINKING_MODE", payload: { mode: 'adaptive', effort: effort as ThinkingEffort } }); localStorage.setItem('thinking_mode', 'adaptive'); localStorage.setItem('thinking_effort', effort); @@ -174,26 +180,53 @@ const FloatingPromptInputInner = ( } }, [state.executionEngineConfig, onExecutionEngineConfigChange]); - // Dynamic model list - initialized with dynamic names from cache - const [availableModels, setAvailableModels] = useState(() => getModels()); + // 把 customModelStorage 中的自定义模型转成 ModelConfig + const buildCustomModelConfigs = (): ModelConfig[] => { + return getCustomModels().map((m) => ({ + id: `custom:${m.modelId}` as ModelType, + name: m.name, + description: m.source === "api" ? "从 API 拉取" : "手动添加", + icon: , + modelId: m.modelId, + isCustom: true, + })); + }; + + // Dynamic model list - 内置模型 + 用户自定义模型 + env 变量自定义模型(id="custom",loadEnvCustomModel 中追加) + const [availableModels, setAvailableModels] = useState(() => [ + ...getModels(), + ...buildCustomModelConfigs(), + ]); // Listen for model name updates from stream init messages useEffect(() => { const handleModelNamesUpdated = () => { - setAvailableModels(prev => { + setAvailableModels((prev) => { const updated = getModels(); - // Preserve any custom model that was dynamically added - const customModel = prev.find(m => m.id === 'custom'); - if (customModel) { - return [...updated, customModel]; - } - return updated; + // 保留所有自定义条目(id="custom" 与 id="custom:xxx") + const customs = prev.filter((m) => m.isCustom); + return [...updated, ...customs]; + }); + }; + + const handleCustomModelsUpdated = () => { + setAvailableModels((prev) => { + // 保留内置模型与 env 变量来源的 "custom" 条目;用最新 storage 替换 "custom:xxx" + const builtIns = prev.filter((m) => !m.isCustom); + const envCustom = prev.find((m) => m.id === "custom" && m.isCustom); + return [ + ...builtIns, + ...(envCustom ? [envCustom] : []), + ...buildCustomModelConfigs(), + ]; }); }; window.addEventListener(MODEL_NAMES_UPDATED_EVENT, handleModelNamesUpdated); + window.addEventListener(CUSTOM_MODELS_UPDATED_EVENT, handleCustomModelsUpdated); return () => { window.removeEventListener(MODEL_NAMES_UPDATED_EVENT, handleModelNamesUpdated); + window.removeEventListener(CUSTOM_MODELS_UPDATED_EVENT, handleCustomModelsUpdated); }; }, []); @@ -381,18 +414,19 @@ const FloatingPromptInputInner = ( const isBuiltInModel = ['sonnet', 'opus', 'sonnet1m', 'opus1m'].includes(customModel.toLowerCase()); if (!isBuiltInModel) { - // This is a custom model - add it to the list + // 来自 settings.json 环境变量的自定义模型,单独占位 id="custom" const customModelConfig: ModelConfig = { id: "custom" as ModelType, name: customModel, description: "Custom model from environment variables", - icon: + icon: , + modelId: customModel, + isCustom: true, }; setAvailableModels(prev => { const hasCustom = prev.some(m => m.id === "custom"); if (!hasCustom) return [...prev, customModelConfig]; - // Update existing custom model if name changed return prev.map(m => m.id === "custom" ? customModelConfig : m); }); } @@ -412,9 +446,14 @@ const FloatingPromptInputInner = ( setPrompt: (text: string) => dispatch({ type: "SET_PROMPT", payload: text }), })); - // Toggle thinking mode - cycle through: off → high → max → low → medium → off - const EFFORT_CYCLE: (ThinkingEffort | 'off')[] = ['off', 'high', 'max', 'low', 'medium']; + // Toggle thinking mode - cycle through: off → high → xhigh → max → low → medium → off + const EFFORT_CYCLE: (ThinkingEffort | 'off')[] = ['off', 'high', 'xhigh', 'max', 'low', 'medium']; + /** + * 🔧 修复并发竞态:先把 settings.json 写入完成(CLI 启动时会读它), + * 再更新 UI 状态和 localStorage。 + * 旧实现是先 dispatch 再 await,用户切到 max 后立刻发送会让 CLI 读到旧 effort。 + */ const handleToggleThinkingMode = useCallback(async () => { const currentMode = state.selectedThinkingMode; const currentEffort = state.selectedThinkingEffort; @@ -428,9 +467,16 @@ const FloatingPromptInputInner = ( const newMode: ThinkingMode = nextKey === 'off' ? 'off' : 'adaptive'; const newEffort: ThinkingEffort | undefined = nextKey === 'off' ? undefined : nextKey as ThinkingEffort; - dispatch({ type: "SET_THINKING_MODE", payload: { mode: newMode, effort: newEffort } }); + // 1️⃣ 先把权威源(settings.json)落盘 —— 失败就不动 UI + try { + await api.updateThinkingMode(newMode === 'adaptive', newEffort); + } catch (error) { + console.error("Failed to update thinking mode:", error); + return; + } - // Persist to localStorage + // 2️⃣ 落盘成功后再同步前端状态与缓存 + dispatch({ type: "SET_THINKING_MODE", payload: { mode: newMode, effort: newEffort } }); try { localStorage.setItem('thinking_mode', newMode); if (newEffort) localStorage.setItem('thinking_effort', newEffort); @@ -438,20 +484,6 @@ const FloatingPromptInputInner = ( } catch { // Ignore localStorage errors } - - try { - await api.updateThinkingMode(newMode === 'adaptive', newEffort); - } catch (error) { - console.error("Failed to update thinking mode:", error); - // Revert on error - dispatch({ type: "SET_THINKING_MODE", payload: { mode: currentMode, effort: currentEffort } }); - try { - localStorage.setItem('thinking_mode', currentMode); - if (currentEffort) localStorage.setItem('thinking_effort', currentEffort); - } catch { - // Ignore localStorage errors - } - } }, [state.selectedThinkingMode, state.selectedThinkingEffort]); // Focus management @@ -522,12 +554,13 @@ const FloatingPromptInputInner = ( finalPrompt = finalPrompt + (finalPrompt.endsWith(' ') || finalPrompt === '' ? '' : ' ') + imagePathMentions; } - // When custom model is selected, pass the actual model name instead of "custom" + // 自定义模型(id="custom" 或 "custom:xxx")需要把 ANTHROPIC_MODEL 真实字符串传出去 let modelToSend = state.selectedModel; - if (state.selectedModel === 'custom') { - const customModelConfig = availableModels.find(m => m.id === 'custom'); - if (customModelConfig) { - modelToSend = customModelConfig.name as ModelType; + if (state.selectedModel === 'custom' || isCustomModelId(state.selectedModel)) { + const customModelConfig = availableModels.find(m => m.id === state.selectedModel); + const realModelId = customModelConfig?.modelId || customModelConfig?.name; + if (realModelId) { + modelToSend = realModelId as ModelType; } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 0fcd1efe..661340bf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -141,6 +141,7 @@ export interface ClaudeExecutionConfig { verbose: boolean; permissions: ClaudePermissionConfig; disable_rewind_git_operations: boolean; + disable_auto_commit_after_response?: boolean; } /** @@ -365,6 +366,14 @@ export interface ApiKeyUsage { query_end_date: string; } +/** + * 从 /v1/models 接口拉取的模型信息 + */ +export interface AnthropicModelInfo { + id: string; + display_name?: string; +} + /** * Codex provider configuration for OpenAI Codex API switching */ @@ -963,10 +972,10 @@ export const api = { }, /** - * Updates the thinking mode using Claude 4.6 Adaptive Thinking + * Updates the thinking mode using Claude 4.7 Adaptive Thinking * Sets CLAUDE_CODE_THINKING_EFFORT env var in settings.json * @param enabled - Whether to enable adaptive thinking - * @param effort - Effort level: low, medium, high, max (only used when enabled) + * @param effort - Effort level: low, medium, high, xhigh, max (only used when enabled) * @returns Promise resolving when the settings are updated */ async updateThinkingMode(enabled: boolean, effort?: string): Promise { @@ -2134,6 +2143,18 @@ export const api = { } }, + /** + * 调用当前 Provider 的 /v1/models 接口拉取可用模型列表 + */ + async fetchAnthropicModels(): Promise { + try { + return await invoke("fetch_anthropic_models"); + } catch (error) { + console.error("Failed to fetch Anthropic models:", error); + throw error; + } + }, + /** * Reorders provider configurations * @param ids - Array of provider IDs in the desired order @@ -3061,6 +3082,24 @@ export const api = { } }, + /** + * Open a file path in the user's preferred IDE with optional line/column. + * editor: 'auto' | 'cursor' | 'code' | 'idea' | 'system' + */ + async openPathInEditor( + filePath: string, + line?: number, + column?: number, + editor?: string, + ): Promise { + try { + return await invoke("open_path_in_editor", { filePath, line, column, editor }); + } catch (error) { + console.error("Failed to open path in editor:", error); + throw error; + } + }, + // ==================== Git Statistics ==================== /** @@ -3163,13 +3202,23 @@ export const api = { * @returns Promise resolving to array of Codex sessions */ async listCodexSessions(): Promise { + // Cache for 10 seconds to avoid duplicate calls during startup + const now = Date.now(); + if (this._codexSessionsCache && now - this._codexSessionsCacheTime < 10000) { + return this._codexSessionsCache; + } try { - return await invoke("list_codex_sessions"); + const result = await invoke("list_codex_sessions"); + this._codexSessionsCache = result; + this._codexSessionsCacheTime = now; + return result; } catch (error) { console.error("Failed to list Codex sessions:", error); throw error; } }, + _codexSessionsCache: null as import('@/types/codex').CodexSession[] | null, + _codexSessionsCacheTime: 0, /** * Deletes a Codex session @@ -4192,13 +4241,24 @@ export const api = { * @returns Promise resolving to array of session info */ async listGeminiSessions(projectPath: string): Promise { + // Cache per project path for 10 seconds + const now = Date.now(); + const cacheKey = projectPath; + const cached = this._geminiSessionsCache?.get(cacheKey); + if (cached && now - cached.time < 10000) { + return cached.data; + } try { - return await invoke("list_gemini_sessions", { projectPath }); + const result = await invoke("list_gemini_sessions", { projectPath }); + if (!this._geminiSessionsCache) this._geminiSessionsCache = new Map(); + this._geminiSessionsCache.set(cacheKey, { data: result, time: now }); + return result; } catch (error) { console.error("Failed to list Gemini sessions:", error); throw error; } }, + _geminiSessionsCache: null as Map | null, /** * Gets detailed session information diff --git a/src/lib/notificationService.ts b/src/lib/notificationService.ts new file mode 100644 index 00000000..4dc7ffef --- /dev/null +++ b/src/lib/notificationService.ts @@ -0,0 +1,40 @@ +/** + * AI 回复完成通知服务 + * 当窗口不在前台时,AI 回复完成后发送桌面通知 + */ + +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from '@tauri-apps/plugin-notification'; + +let permissionGranted = false; + +export async function initNotification(): Promise { + try { + permissionGranted = await isPermissionGranted(); + if (!permissionGranted) { + const permission = await requestPermission(); + permissionGranted = permission === 'granted'; + } + } catch (e) { + console.warn('[Notification] Failed to initialize:', e); + } +} + +export function notifyResponseComplete(engine: string = 'Claude'): void { + if (!permissionGranted) return; + + // 只在窗口不聚焦时通知 + if (document.hasFocus()) return; + + try { + sendNotification({ + title: 'Any Code', + body: `${engine} 回复完成`, + }); + } catch (e) { + console.warn('[Notification] Failed to send:', e); + } +} diff --git a/src/main.tsx b/src/main.tsx index 022819d2..4b34d230 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -45,6 +45,11 @@ const AppWrapper: React.FC = () => { // 后台异步初始化 toolRegistry(不阻塞) initializeToolRegistry(); + // 初始化桌面通知权限 + import('./lib/notificationService').then(({ initNotification }) => { + initNotification(); + }).catch(() => {}); + // 立即显示窗口(生产模式已优化,不需要长延迟) const timer = setTimeout(showWindow, 50); return () => clearTimeout(timer); From 45030ce7e62c9584b7455f8d80fb56f6055fe319 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Sun, 17 May 2026 23:17:05 +0800 Subject: [PATCH 3/8] refactor: improve message rendering, session UI and tool widgets - Refactor message components for better streaming and subagent grouping - Improve thinking block rendering and tool call group display - Enhance settings panels and session list interactions - Optimize smart auto-scroll behavior for long conversations - Add validation for clipboard image base64 data in image handling - Improve token counter accuracy and prompt enhancement service - Update tool registry initialization with new widget definitions - Adjust component styles for visual consistency --- src/components/ClaudeCodeSession.tsx | 139 ++++--- src/components/ClaudeExtensionsManager.tsx | 12 +- src/components/ExecutionEngineSelector.tsx | 14 +- .../hooks/useImageHandling.ts | 19 +- src/components/SessionList.tsx | 36 +- src/components/Settings.tsx | 18 +- src/components/TranslationSettings.tsx | 28 ++ src/components/layout/AppLayout.tsx | 2 +- src/components/message/AIMessage.tsx | 285 +++++++------ src/components/message/MessageBubble.tsx | 40 +- src/components/message/MessageContent.tsx | 37 ++ src/components/message/StreamMessageV2.tsx | 18 +- .../message/SubagentMessageGroup.tsx | 54 ++- src/components/message/ThinkingBlock.tsx | 49 ++- src/components/message/ToolCallsGroup.tsx | 22 +- src/components/message/UserMessage.tsx | 66 ++- src/components/session/SessionMessages.tsx | 160 ++++---- src/components/settings/GeneralSettings.tsx | 94 ++++- .../widgets/common/useToolTranslation.ts | 31 +- .../widgets/file-operations/WriteWidget.tsx | 10 +- .../widgets/system/AskUserQuestionWidget.tsx | 60 ++- .../widgets/system/ThinkingWidget.tsx | 17 +- src/hooks/usePromptExecution.ts | 45 ++- src/hooks/useSmartAutoScroll.ts | 380 +++++++++--------- src/lib/claudeSDK.ts | 9 +- src/lib/promptEnhancementService.ts | 2 +- src/lib/subagentGrouping.ts | 14 +- src/lib/tokenCounter.ts | 43 +- src/lib/toolRegistryInit.tsx | 173 +++++++- src/styles/components.css | 22 +- 30 files changed, 1211 insertions(+), 688 deletions(-) diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 3c2627c3..9dae488c 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -159,7 +159,7 @@ const ClaudeCodeSessionInner: React.FC = ({ // Default config return { engine: 'claude', - codexMode: 'read-only', + codexMode: 'default', codexModel: 'gpt-5.2', geminiModel: 'gemini-3-flash', }; @@ -252,52 +252,30 @@ const ClaudeCodeSessionInner: React.FC = ({ isLoading }); - // Fix: Scroll to bottom when session history is loaded - // Also re-trigger when message count changes significantly (e.g., streaming adds many messages) + // 初次加载会话历史时滚到底部,仅触发一次;若用户已开始浏览则放弃 const hasScrolledToBottomRef = useRef(null); - const lastScrolledMessageCountRef = useRef(0); useEffect(() => { - // Check if we have messages and parentRef is attached - if (displayableMessages.length > 0 && parentRef.current) { - const currentSessionId = session?.id || 'new_session'; - const currentCount = displayableMessages.length; - - // Determine if we should scroll: - // 1. First time for this session (initial history load) - const isFirstTimeForSession = hasScrolledToBottomRef.current !== currentSessionId; - // 2. Message count jumped significantly (e.g., streaming added many messages at once) - const countDelta = currentCount - lastScrolledMessageCountRef.current; - const significantCountChange = lastScrolledMessageCountRef.current > 0 && countDelta >= 5; - - if (isFirstTimeForSession || significantCountChange) { - // Use a small delay to ensure virtualizer has calculated sizes - const timer = setTimeout(() => { - if (parentRef.current) { - // Force scroll to bottom - parentRef.current.scrollTop = parentRef.current.scrollHeight; - - // Sync with smart auto-scroll state - setUserScrolled(false); - setShouldAutoScroll(true); - - // Mark as done for this session - hasScrolledToBottomRef.current = currentSessionId; - lastScrolledMessageCountRef.current = currentCount; - - // Schedule a follow-up scroll to handle virtualizer re-measurements - setTimeout(() => { - if (parentRef.current) { - parentRef.current.scrollTop = parentRef.current.scrollHeight; - } - }, 200); - } - }, 150); // 150ms delay for stability - - return () => clearTimeout(timer); - } - } - }, [displayableMessages.length, session?.id, setUserScrolled, setShouldAutoScroll]); + if (displayableMessages.length === 0 || !parentRef.current) return; + const currentSessionId = session?.id || 'new_session'; + if (hasScrolledToBottomRef.current === currentSessionId) return; + + // 标记预占,防止 effect 因 length 变化而重复触发 + hasScrolledToBottomRef.current = currentSessionId; + + const startTop = parentRef.current.scrollTop; + const timer = setTimeout(() => { + const el = parentRef.current; + if (!el) return; + // 用户在 150ms 内主动滚动过 → 放弃强制滚底 + if (Math.abs(el.scrollTop - startTop) > 4) return; + el.scrollTop = el.scrollHeight; + setUserScrolled(false); + setShouldAutoScroll(true); + }, 150); + + return () => clearTimeout(timer); + }, [session?.id, displayableMessages.length === 0, setUserScrolled, setShouldAutoScroll]); // ============================================================================ // MESSAGE-LEVEL OPERATIONS (Fine-grained Undo/Redo) @@ -455,21 +433,21 @@ const ClaudeCodeSessionInner: React.FC = ({ processMessageWithTranslation }); - // 🆕 包装 handleSendPrompt,发送消息时自动滚动到底部 - // 解决问题:当用户滚动查看历史消息后发送新消息,页面不会自动滚动到底部 - // 🔧 修复:消息数量过多时使用虚拟列表的 scrollToIndex 确保滚动到真正的底部 + // 发送新消息时强制重置跟随状态并多次滚动到底部,避免新消息被遮挡 const handleSendPromptWithScroll = useCallback((prompt: string, model: ModelType, maxThinkingTokens?: number) => { - // 重置滚动状态,确保发送消息后自动滚动到底部 setUserScrolled(false); setShouldAutoScroll(true); - // 使用虚拟列表的 scrollToBottom 方法,解决消息过多时 scrollHeight 估算不准的问题 - // 延迟执行,等待消息添加到列表后再滚动 - setTimeout(() => { - sessionMessagesRef.current?.scrollToBottom(); - }, 50); - handleSendPrompt(prompt, model, maxThinkingTokens); + + // 多帧滚动覆盖:消息渲染、虚拟列表测量、流式开始三个时机 + requestAnimationFrame(() => { + sessionMessagesRef.current?.scrollToBottom(); + requestAnimationFrame(() => { + sessionMessagesRef.current?.scrollToBottom(); + }); + }); + setTimeout(() => sessionMessagesRef.current?.scrollToBottom(), 120); }, [handleSendPrompt, setUserScrolled, setShouldAutoScroll]); // 🆕 方案 B-1: 设置发送提示词回调,用于计划批准后自动执行 @@ -600,6 +578,59 @@ const ClaudeCodeSessionInner: React.FC = ({ onStreamingChange?.(isLoading, claudeSessionId); }, [isLoading, claudeSessionId, onStreamingChange]); + // 🔧 FIX: Streaming watchdog — detect stuck "responding" state + // If isLoading is true but no new messages arrive for 60s, check if process is still alive + // 使用连续失败计数器避免误判(子代理执行期间进程可能暂时不在列表中) + const watchdogFailCountRef = useRef(0); + useEffect(() => { + if (!isLoading) { + watchdogFailCountRef.current = 0; + return; + } + + // 进程存活检测:8s × 连续 2 次失败 ≈ 16s 内恢复 spinner, + // 平衡子代理短暂离队的误判与僵死状态的响应速度 + const watchdogTimer = setInterval(async () => { + if (!isLoading) return; + try { + const running = await api.listRunningClaudeSessions(); + const sessionId = claudeSessionId || extractedSessionInfo?.sessionId; + + const forceComplete = (reason: string) => { + watchdogFailCountRef.current += 1; + if (watchdogFailCountRef.current >= 2) { + console.warn(`[Watchdog] ${reason}, forcing completion`); + unlistenRefs.current.forEach(u => u && typeof u === 'function' && u()); + unlistenRefs.current = []; + setIsLoading(false); + hasActiveSessionRef.current = false; + isListeningRef.current = false; + watchdogFailCountRef.current = 0; + } + }; + + if (sessionId) { + const isStillRunning = running.some( + (s: any) => s.session_id === sessionId || s.sessionId === sessionId + ); + if (!isStillRunning) { + forceComplete('Session process not found'); + } else { + watchdogFailCountRef.current = 0; + } + } else if (running.length === 0) { + forceComplete('No running sessions found'); + } else { + watchdogFailCountRef.current = 0; + } + } catch { + // Ignore errors in watchdog + } + }, 8000); + + return () => clearInterval(watchdogTimer); + }, [isLoading, claudeSessionId, extractedSessionInfo, setIsLoading]); + // 🔧 FIX: When a tab becomes active (visible), re-verify session running state // Listeners persist across tab switches (DO NOT clean up on tab switch). // But we need to: diff --git a/src/components/ClaudeExtensionsManager.tsx b/src/components/ClaudeExtensionsManager.tsx index 322c1266..976d46ec 100644 --- a/src/components/ClaudeExtensionsManager.tsx +++ b/src/components/ClaudeExtensionsManager.tsx @@ -650,7 +650,11 @@ export const ClaudeExtensionsManager: React.FC = ( api.openFileWithDefaultApp(agent.path)} + onClick={async () => { + const editor = (typeof localStorage !== 'undefined' && localStorage.getItem('preferred_editor')) || 'auto'; + try { await api.openPathInEditor(agent.path, undefined, undefined, editor); } + catch { await api.openFileWithDefaultApp(agent.path); } + }} >
@@ -728,7 +732,11 @@ export const ClaudeExtensionsManager: React.FC = ( api.openFileWithDefaultApp(skill.path)} + onClick={async () => { + const editor = (typeof localStorage !== 'undefined' && localStorage.getItem('preferred_editor')) || 'auto'; + try { await api.openPathInEditor(skill.path, undefined, undefined, editor); } + catch { await api.openFileWithDefaultApp(skill.path); } + }} >
diff --git a/src/components/ExecutionEngineSelector.tsx b/src/components/ExecutionEngineSelector.tsx index 8514a67e..13681599 100644 --- a/src/components/ExecutionEngineSelector.tsx +++ b/src/components/ExecutionEngineSelector.tsx @@ -409,7 +409,7 @@ export const ExecutionEngineSelector: React.FC = ( {getEngineDisplayName()} {value.engine === 'codex' && value.codexMode && ( - ({value.codexMode === 'read-only' ? '只读' : value.codexMode === 'full-auto' ? '编辑' : '完全访问'}) + ({value.codexMode === 'read-only' ? '只读' : value.codexMode === 'default' ? '默认' : value.codexMode === 'full-auto' ? '编辑' : '完全访问'}) )} {value.engine === 'gemini' && value.geminiApprovalMode && ( @@ -465,13 +465,19 @@ export const ExecutionEngineSelector: React.FC = (
{ + try { return localStorage.getItem('preferred_editor') || 'auto'; } + catch { return 'auto'; } + })()} + onChange={(e) => { + try { localStorage.setItem('preferred_editor', e.target.value); } + catch (err) { console.error('Failed to save preferred_editor:', err); } + }} + > + + + + + + + + +
+ + {/* #171: 设置导出 */} +
+
+ +

+ 导出当前 Claude 设置为 JSON 文件(不包含 API Key 等敏感信息) +

+
+ +
+ {/* Cleanup Period */}
diff --git a/src/components/widgets/common/useToolTranslation.ts b/src/components/widgets/common/useToolTranslation.ts index 62f2e030..2f588ecb 100644 --- a/src/components/widgets/common/useToolTranslation.ts +++ b/src/components/widgets/common/useToolTranslation.ts @@ -21,34 +21,26 @@ import { translationMiddleware } from '@/lib/translationMiddleware'; * - 优雅的错误处理 */ export const useToolTranslation = () => { - const [translatedContent, setTranslatedContent] = React.useState>(new Map()); + const cacheRef = React.useRef>(new Map()); + const [cacheVersion, setCacheVersion] = React.useState(0); - /** - * 翻译内容 - * @param content 要翻译的内容 - * @param cacheKey 缓存键(用于避免重复翻译) - * @returns 翻译后的内容,如果翻译失败或未启用则返回原内容 - */ const translateContent = React.useCallback(async (content: string, cacheKey: string) => { - // 检查缓存 - if (translatedContent.has(cacheKey)) { - return translatedContent.get(cacheKey)!; + if (cacheRef.current.has(cacheKey)) { + return cacheRef.current.get(cacheKey)!; } try { - // 检查翻译是否启用 const isEnabled = await translationMiddleware.isEnabled(); if (!isEnabled) { return content; } - // 检测语言,只翻译英文内容 const detectedLanguage = await translationMiddleware.detectLanguage(content); if (detectedLanguage === 'en') { const result = await translationMiddleware.translateClaudeResponse(content, true); if (result.wasTranslated) { - // 更新缓存 - setTranslatedContent(prev => new Map(prev).set(cacheKey, result.translatedText)); + cacheRef.current.set(cacheKey, result.translatedText); + setCacheVersion(v => v + 1); return result.translatedText; } } @@ -58,18 +50,17 @@ export const useToolTranslation = () => { console.error('[useToolTranslation] Translation failed:', error); return content; } - }, [translatedContent]); + }, []); - /** - * 清空翻译缓存 - */ const clearCache = React.useCallback(() => { - setTranslatedContent(new Map()); + cacheRef.current = new Map(); + setCacheVersion(v => v + 1); }, []); return { translateContent, clearCache, - cacheSize: translatedContent.size, + cacheSize: cacheRef.current.size, + cacheVersion, }; }; diff --git a/src/components/widgets/file-operations/WriteWidget.tsx b/src/components/widgets/file-operations/WriteWidget.tsx index aabe7c8b..12bef144 100644 --- a/src/components/widgets/file-operations/WriteWidget.tsx +++ b/src/components/widgets/file-operations/WriteWidget.tsx @@ -76,9 +76,15 @@ export const WriteWidget: React.FC = ({ */ const handleOpenInSystem = async () => { try { - await api.openFileWithDefaultApp(filePath); + const editor = (typeof localStorage !== 'undefined' && localStorage.getItem('preferred_editor')) || 'auto'; + await api.openPathInEditor(filePath, undefined, undefined, editor); } catch (error) { - console.error('Failed to open file in system:', error); + console.error('Failed to open file in editor:', error); + try { + await api.openFileWithDefaultApp(filePath); + } catch (fallbackError) { + console.error('Failed to open file with system default:', fallbackError); + } } }; diff --git a/src/components/widgets/system/AskUserQuestionWidget.tsx b/src/components/widgets/system/AskUserQuestionWidget.tsx index eeba7466..5fc0174a 100644 --- a/src/components/widgets/system/AskUserQuestionWidget.tsx +++ b/src/components/widgets/system/AskUserQuestionWidget.tsx @@ -12,7 +12,7 @@ import React, { useState, useMemo, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { HelpCircle, CheckCircle, MessageCircle, ChevronDown, ChevronUp, Check } from "lucide-react"; +import { HelpCircle, CheckCircle, MessageCircle, ChevronDown, ChevronUp, Check, MousePointerClick } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useUserQuestion, getQuestionId } from "@/contexts/UserQuestionContext"; @@ -126,6 +126,15 @@ export const AskUserQuestionWidget: React.FC = ({ } }, [questions, hasAnswers, answered, triggerQuestionDialog, isError, result]); + // 🆕 手动重新打开问答对话框(修复 Plan 模式下自动触发失效问题 #176) + const handleManualTrigger = () => { + if (triggerQuestionDialog && questions.length > 0) { + // 重置触发标记,允许再次触发 + hasTriggered.current = false; + triggerQuestionDialog(questions); + } + }; + // 解析answers - 可能在result.content中以字符串格式存储 const parsedAnswers = useMemo(() => { // 如果answers不为空,直接使用 @@ -265,22 +274,41 @@ export const AskUserQuestionWidget: React.FC = ({ )}
- {/* 折叠按钮 */} - )} - + + {/* 折叠按钮 */} + +
{/* 折叠时显示的简要信息 */} diff --git a/src/components/widgets/system/ThinkingWidget.tsx b/src/components/widgets/system/ThinkingWidget.tsx index 7c454751..19e7cf8a 100644 --- a/src/components/widgets/system/ThinkingWidget.tsx +++ b/src/components/widgets/system/ThinkingWidget.tsx @@ -41,8 +41,21 @@ export const ThinkingWidget: React.FC = ({ // 判断是否有内容 const hasContent = trimmedThinking.length > 0; - // 翻译思考内容 + // 🆕 #171: 思考输出翻译开关(默认关闭以节省 tokens 与延迟) + const translateThinkingEnabled = React.useMemo(() => { + try { + return localStorage.getItem('translate_thinking_output') === 'true'; + } catch { + return false; + } + }, []); + + // 翻译思考内容(仅在开关启用时) React.useEffect(() => { + if (!translateThinkingEnabled) { + setTranslatedThinking(trimmedThinking); + return; + } const translateThinking = async () => { if (hasContent) { const cacheKey = `thinking-${trimmedThinking.substring(0, 100)}`; @@ -52,7 +65,7 @@ export const ThinkingWidget: React.FC = ({ }; translateThinking(); - }, [trimmedThinking, hasContent, translateContent]); + }, [trimmedThinking, hasContent, translateContent, translateThinkingEnabled]); // 格式化 Token 使用情况 const formatThinkingTokens = (usage: any) => { diff --git a/src/hooks/usePromptExecution.ts b/src/hooks/usePromptExecution.ts index 5ed0368a..b5cf3d4c 100644 --- a/src/hooks/usePromptExecution.ts +++ b/src/hooks/usePromptExecution.ts @@ -130,7 +130,7 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE isFirstPrompt, extractedSessionInfo, executionEngine = 'claude', // 🆕 默认使用 Claude Code - codexMode = 'read-only', // 🆕 Codex 默认只读模式 + codexMode = 'default', // 🆕 Codex 默认模式(每次敏感操作前请求授权) codexModel, // 🆕 Codex 模型 geminiModel, // 🆕 Gemini 模型 geminiApprovalMode, // 🆕 Gemini 审批模式 @@ -466,6 +466,12 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE // Helper function to process Codex completion const processCodexComplete = async () => { + // 发送桌面通知 + try { + const { notifyResponseComplete } = await import('@/lib/notificationService'); + notifyResponseComplete('Codex'); + } catch {} + setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; @@ -893,6 +899,12 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE // Helper function to process Gemini completion const processGeminiComplete = async () => { + // 发送桌面通知 + try { + const { notifyResponseComplete } = await import('@/lib/notificationService'); + notifyResponseComplete('Gemini'); + } catch {} + setIsLoading(false); hasActiveSessionRef.current = false; isListeningRef.current = false; @@ -1157,10 +1169,14 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE const specificErrorUnlisten = await listen(`claude-error:${sid}`, (evt) => { console.error('Claude error (scoped):', evt.payload); setError(evt.payload); + // 错误时兜底关闭 loading 状态:进程可能因 stderr 触发 error 后才退出, + // 也可能直接异常退出而不再发 complete 事件 + if (hasActiveSessionRef.current) { + processComplete(); + } }); const specificCompleteUnlisten = await listen(`claude-complete:${sid}`, () => { - processComplete(); }); @@ -1202,7 +1218,12 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE // Helper: Process Completion // ==================================================================== const processComplete = async () => { - + + // 发送桌面通知(窗口不在前台时) + try { + const { notifyResponseComplete } = await import('@/lib/notificationService'); + notifyResponseComplete('Claude'); + } catch {} // 🔧 FIX: Wait for pending prompt recording to complete (race condition fix) if (pendingClaudePromptRecordingPromise) { @@ -1417,10 +1438,8 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE // 🔒 CRITICAL FIX: 全局事件现在格式为 { tab_id: string | null, payload: string } const genericErrorUnlisten = await listen>('claude-error', (evt) => { - // 🔧 FIX: Only process if this tab has an active session if (!hasActiveSessionRef.current) return; - // 🔒 CRITICAL FIX: 使用 tab_id 过滤消息 const { tabId: eventTabId, payload: errorPayload } = normalizeClaudeGlobalPayload(evt.payload); if (eventTabId && eventTabId !== tabIdRef.current) { return; @@ -1428,14 +1447,14 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE console.error('Claude error:', errorPayload); setError(errorPayload); + // 错误时兜底关闭 loading:避免后端只发 error 不发 complete 时 spinner 永转 + if (hasActiveSessionRef.current) { + processComplete(); + } }); - // 🔒 CRITICAL FIX: 全局事件现在格式为 { tab_id: string | null, payload: boolean } const genericCompleteUnlisten = await listen>('claude-complete', (evt) => { - // 🔧 FIX: Only process if this tab has an active session - if (!hasActiveSessionRef.current) return; - - // 🔒 CRITICAL FIX: 使用 tab_id 过滤消息 + // 不再用 hasActiveSessionRef 过滤:错误兜底可能已先翻转 ref,会吞掉真正的 complete const { tabId: eventTabId } = normalizeClaudeGlobalPayload(evt.payload); if (eventTabId && eventTabId !== tabIdRef.current) { return; @@ -1556,7 +1575,7 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE await api.resumeCodex(effectiveSession.id, { projectPath, prompt: processedPrompt, - mode: codexMode || 'read-only', + mode: codexMode || 'default', model: codexModel || model, json: true }); @@ -1565,7 +1584,7 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE await api.resumeLastCodex({ projectPath, prompt: processedPrompt, - mode: codexMode || 'read-only', + mode: codexMode || 'default', model: codexModel || model, json: true }); @@ -1576,7 +1595,7 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE await api.executeCodex({ projectPath, prompt: processedPrompt, - mode: codexMode || 'read-only', + mode: codexMode || 'default', model: codexModel || model, json: true }); diff --git a/src/hooks/useSmartAutoScroll.ts b/src/hooks/useSmartAutoScroll.ts index 99cbd276..5580277a 100644 --- a/src/hooks/useSmartAutoScroll.ts +++ b/src/hooks/useSmartAutoScroll.ts @@ -1,253 +1,239 @@ /** * 智能自动滚动 Hook * - * 从 ClaudeCodeSession 提取(原 166-170 状态,305-435 逻辑) - * 提供智能滚动管理:用户手动滚动检测、自动滚动到底部、流式输出滚动 + * 设计理念(参考 ChatGPT / Slack / Discord 的 Tail-mode 实现): + * 1. **单一真理**:滚动状态全部用 ref 管理,避免 React 状态切换重建监听器 + * 2. **用户优先**:捕获 wheel / touch / keyboard / 拖动滚动条,交互期间禁止程序化滚动 + * 3. **自动跟随**:仅当用户未离开底部时自动粘底;用户上滚后保持位置 + * 4. **节流粘底**:流式期间使用单一 rAF 循环,间隔 100ms 比对距离再决定是否滚动 + * + * 修复要点: + * - userScrolled 不再是 state,避免 handleScroll 闭包反复重建 + * - 移除 200ms 间隔的 setInterval 修正,避免与用户交互争用 + * - 借助原生交互事件(wheel / touch / mousedown)判定用户意图, + * 不再依赖 scrollDelta 估算(动量滚动会污染估算) */ -import { useRef, useState, useEffect, useMemo } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import type { ClaudeStreamMessage } from '@/types/claude'; interface SmartAutoScrollConfig { - /** 可显示的消息列表(用于触发滚动) */ displayableMessages: ClaudeStreamMessage[]; - /** 是否正在加载(流式输出时) */ isLoading: boolean; } -/** - * 计算消息的内容哈希,用于检测内容变化 - */ -function getLastMessageContentHash(messages: ClaudeStreamMessage[]): string { - if (messages.length === 0) return ''; - const lastMsg = messages[messages.length - 1]; - // 简单地使用内容长度和类型作为哈希 - const contentLength = JSON.stringify(lastMsg.message?.content || '').length; - return `${messages.length}-${lastMsg.type}-${contentLength}`; -} - interface SmartAutoScrollReturn { - /** 滚动容器 ref */ parentRef: React.RefObject; - /** 用户是否手动滚动离开底部 */ userScrolled: boolean; - /** 设置用户滚动状态 */ setUserScrolled: (scrolled: boolean) => void; - /** 设置自动滚动状态 */ setShouldAutoScroll: (should: boolean) => void; } -/** - * 智能自动滚动 Hook - * - * @param config - 配置对象 - * @returns 滚动管理对象 - * - * @example - * const { parentRef, userScrolled, setUserScrolled, shouldAutoScroll, setShouldAutoScroll } = - * useSmartAutoScroll({ - * displayableMessages, - * isLoading - * }); - */ +const BOTTOM_THRESHOLD = 80; // 距底 80px 视为"在底部" +const NEAR_BOTTOM_RECOVERY = 32; // 距底 32px 内自动恢复粘底 +const STREAM_TICK_INTERVAL = 100; // 流式粘底间隔 +const INTERACTION_RELEASE_DELAY = 220; // 交互释放后延迟判定(兼容动量滚动) + export function useSmartAutoScroll(config: SmartAutoScrollConfig): SmartAutoScrollReturn { const { displayableMessages, isLoading } = config; - // Scroll state - const [userScrolled, setUserScrolled] = useState(false); - const [shouldAutoScroll, setShouldAutoScroll] = useState(true); - - // Refs const parentRef = useRef(null); - const lastScrollPositionRef = useRef(0); - const isAutoScrollingRef = useRef(false); // Track if scroll was initiated by code - const autoScrollTimerRef = useRef | null>(null); // Timer for resetting auto-scroll flag - const prevMessageCountRef = useRef(0); // Track previous message count for new message detection - - // 计算最后一条消息的内容哈希,用于检测内容变化 - const lastMessageHash = useMemo( - () => getLastMessageContentHash(displayableMessages), - [displayableMessages] - ); - - // Helper to perform auto-scroll safely - const performAutoScroll = (behavior: ScrollBehavior = 'smooth') => { - if (parentRef.current) { - const scrollElement = parentRef.current; - // Check if we actually need to scroll to avoid unnecessary events - const { scrollTop, scrollHeight, clientHeight } = scrollElement; - const targetScrollTop = scrollHeight - clientHeight; - - if (Math.abs(scrollTop - targetScrollTop) > 1) { // Small tolerance - // Set the flag and use a timeout to reset it, avoiding race conditions - // where a single scrollTo triggers multiple scroll events - isAutoScrollingRef.current = true; - if (autoScrollTimerRef.current) { - clearTimeout(autoScrollTimerRef.current); - } - // Use longer timeout for smooth scrolling to cover the animation duration (~300ms), - // preventing false "user scrolled" detections from animation-triggered scroll events. - // Use shorter timeout for instant scrolling to allow quick user scroll detection. - const flagTimeout = behavior === 'smooth' ? 300 : 80; - autoScrollTimerRef.current = setTimeout(() => { - isAutoScrollingRef.current = false; - autoScrollTimerRef.current = null; - }, flagTimeout); - - scrollElement.scrollTo({ - top: targetScrollTop, - behavior - }); - } - } - }; - // Smart scroll detection - detect when user manually scrolls + // 仅用于通知外部消费者;内部状态走 ref + const [userScrolled, setUserScrolledStateInternal] = useState(false); + + // 单一状态对象(ref),避免 setState 触发的不必要 re-render + const stateRef = useRef({ + userScrolled: false, // 用户已离开底部 + interacting: false, // 用户正在交互(wheel / drag / touch / key) + lastAutoScroll: 0, // 上次程序化滚动时间戳 + prevMessageCount: 0, + }); + + const setUserScrolled = useCallback((v: boolean) => { + if (stateRef.current.userScrolled === v) return; + stateRef.current.userScrolled = v; + setUserScrolledStateInternal(v); + }, []); + + const setShouldAutoScroll = useCallback((v: boolean) => { + setUserScrolled(!v); + }, [setUserScrolled]); + + const distanceFromBottom = useCallback((): number => { + const el = parentRef.current; + if (!el) return 0; + return el.scrollHeight - el.scrollTop - el.clientHeight; + }, []); + + const scrollToBottom = useCallback(() => { + const el = parentRef.current; + if (!el) return; + if (stateRef.current.interacting) return; // 交互期禁止程序化滚动 + stateRef.current.lastAutoScroll = performance.now(); + // 使用 scrollTop 直接赋值,避免 scrollTo 触发 smooth 兼容差异 + el.scrollTop = el.scrollHeight - el.clientHeight; + }, []); + + // ============== 用户交互检测 ============== + // 通过原生事件判定用户意图,不再依赖 scroll 事件 delta(避免动量滚动误判) useEffect(() => { - const scrollElement = parentRef.current; - if (!scrollElement) return; - - const handleScroll = () => { - // 1. Check if this scroll event was triggered by our auto-scroll - // The flag is now reset via timeout, so all events within the timeout window are ignored - if (isAutoScrollingRef.current) { - lastScrollPositionRef.current = scrollElement.scrollTop; - return; - } + const el = parentRef.current; + if (!el) return; + + let releaseTimer: number | undefined; + let scrollbarDragging = false; - const { scrollTop, scrollHeight, clientHeight } = scrollElement; + const beginInteraction = () => { + stateRef.current.interacting = true; + if (releaseTimer) { + window.clearTimeout(releaseTimer); + releaseTimer = undefined; + } + }; - // 2. Calculate distance from bottom - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - // Use a forgiving threshold (150px) to account for virtualizer measurement errors - const isAtBottom = distanceFromBottom <= 150; + const scheduleRelease = () => { + if (releaseTimer) window.clearTimeout(releaseTimer); + releaseTimer = window.setTimeout(() => { + stateRef.current.interacting = false; + // 释放后基于当前位置重新评估是否恢复粘底 + const dist = distanceFromBottom(); + if (dist <= NEAR_BOTTOM_RECOVERY) { + setUserScrolled(false); + } else { + setUserScrolled(true); + } + }, INTERACTION_RELEASE_DELAY); + }; - // 3. Determine user intent - // If user is not at bottom, they are viewing history -> Stop auto scroll - if (!isAtBottom) { + const onWheel = (e: WheelEvent) => { + beginInteraction(); + // 立即决策:向上滚 → 离开底部 + if (e.deltaY < 0) { setUserScrolled(true); - setShouldAutoScroll(false); - } else { - // User is at bottom (or scrolled back to bottom) -> Resume auto scroll - setUserScrolled(false); - setShouldAutoScroll(true); } + scheduleRelease(); + }; - lastScrollPositionRef.current = scrollTop; + const onTouchStart = () => beginInteraction(); + const onTouchEnd = () => scheduleRelease(); + const onTouchCancel = () => scheduleRelease(); + + const onKeyDown = (e: KeyboardEvent) => { + const navKeys = ['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ']; + if (navKeys.includes(e.key)) { + beginInteraction(); + if (['PageUp', 'Home', 'ArrowUp'].includes(e.key)) { + setUserScrolled(true); + } + scheduleRelease(); + } }; - scrollElement.addEventListener('scroll', handleScroll, { passive: true }); + // 拖动滚动条:mousedown 在容器上但 target 不是子节点(即原生滚动条区域) + const onMouseDown = (e: MouseEvent) => { + const rect = el.getBoundingClientRect(); + // 落点在右侧滚动条区域(容差 20px) + const isOnScrollbar = e.clientX > rect.right - 20; + if (isOnScrollbar) { + scrollbarDragging = true; + beginInteraction(); + const onMouseUp = () => { + scrollbarDragging = false; + scheduleRelease(); + window.removeEventListener('mouseup', onMouseUp); + }; + window.addEventListener('mouseup', onMouseUp); + } + }; - return () => { - scrollElement.removeEventListener('scroll', handleScroll); + // scroll 事件仅作辅助:判断是否仍在底部以"自动恢复粘底" + const onScroll = () => { + if (scrollbarDragging || stateRef.current.interacting) return; + const dist = distanceFromBottom(); + // 程序化滚动后 50ms 内不参与判定 + if (performance.now() - stateRef.current.lastAutoScroll < 50) return; + if (dist > BOTTOM_THRESHOLD) { + if (!stateRef.current.userScrolled) setUserScrolled(true); + } else if (dist <= NEAR_BOTTOM_RECOVERY) { + if (stateRef.current.userScrolled) setUserScrolled(false); + } }; - }, []); // Empty deps - event listener only needs to be registered once - // Cleanup timer on unmount - useEffect(() => { + el.addEventListener('wheel', onWheel, { passive: true }); + el.addEventListener('touchstart', onTouchStart, { passive: true }); + el.addEventListener('touchend', onTouchEnd, { passive: true }); + el.addEventListener('touchcancel', onTouchCancel, { passive: true }); + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('keydown', onKeyDown); + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { - if (autoScrollTimerRef.current) { - clearTimeout(autoScrollTimerRef.current); - } + el.removeEventListener('wheel', onWheel); + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchend', onTouchEnd); + el.removeEventListener('touchcancel', onTouchCancel); + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('keydown', onKeyDown); + el.removeEventListener('scroll', onScroll); + if (releaseTimer) window.clearTimeout(releaseTimer); }; + // 故意空依赖:监听器只装一次,全部读 ref + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Track message count changes and auto-enable scroll when new messages appear + // ============== 新消息到达:若仍在底部则跟随 ============== useEffect(() => { - const currentCount = displayableMessages.length; - const prevCount = prevMessageCountRef.current; - prevMessageCountRef.current = currentCount; - - // When new messages arrive (count increased) and we were near the bottom, re-enable auto-scroll - if (currentCount > prevCount && prevCount > 0) { - if (parentRef.current) { - const { scrollTop, scrollHeight, clientHeight } = parentRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - // If user was within a generous range of the bottom, re-enable auto-scroll - if (distanceFromBottom <= 300) { - setUserScrolled(false); - setShouldAutoScroll(true); - } - } + const count = displayableMessages.length; + const prev = stateRef.current.prevMessageCount; + stateRef.current.prevMessageCount = count; + + if (count <= prev) return; + if (stateRef.current.interacting) return; + + // 仅在已经接近底部时跟随 + if (distanceFromBottom() <= 300 && !stateRef.current.userScrolled) { + // 双 rAF 等待虚拟列表测量 + const id1 = requestAnimationFrame(() => { + const id2 = requestAnimationFrame(scrollToBottom); + // @ts-ignore 保存到外层闭包 + cleanupId = id2; + }); + let cleanupId = id1; + return () => cancelAnimationFrame(cleanupId); } - }, [displayableMessages.length]); + }, [displayableMessages.length, distanceFromBottom, scrollToBottom]); - // Smart auto-scroll for new messages (initial load or update) - // Uses lastMessageHash instead of displayableMessages.length to ensure - // content changes during streaming also trigger scrolling + // ============== 流式输出粘底循环 ============== useEffect(() => { - if (displayableMessages.length > 0 && shouldAutoScroll && !userScrolled) { - const timeoutId = setTimeout(() => { - // Use rAF to ensure scroll happens after DOM updates are painted - requestAnimationFrame(() => performAutoScroll()); - }, 100); + if (!isLoading) return; - return () => clearTimeout(timeoutId); - } - }, [lastMessageHash, shouldAutoScroll, userScrolled]); + let rafId = 0; + let lastTime = 0; - // Enhanced streaming scroll - use requestAnimationFrame for smoother - // rendering-synced scrolling instead of raw setInterval. - // rAF ensures scroll operations align with the browser's paint cycle, - // reducing jank and improving coordination with the virtualizer. - useEffect(() => { - if (isLoading && shouldAutoScroll && !userScrolled) { - // Immediate scroll on update - performAutoScroll('auto'); - - // rAF-based loop throttled to ~100ms for rendering-synced scroll updates - let rafId: number; - let lastScrollTime = 0; - - const tick = (timestamp: number) => { - if (timestamp - lastScrollTime >= 100) { - performAutoScroll('auto'); - lastScrollTime = timestamp; - } + const tick = (now: number) => { + // 用户交互期暂停粘底 + if (stateRef.current.interacting || stateRef.current.userScrolled) { rafId = requestAnimationFrame(tick); - }; - - rafId = requestAnimationFrame(tick); - - return () => cancelAnimationFrame(rafId); - } - }, [isLoading, shouldAutoScroll, userScrolled]); - - // 当消息内容变化时触发额外滚动(确保流式输出时跟踪最新内容) - // 进入历史会话/初次渲染时,虚拟列表的测量会在短时间内不断修正高度,导致首次滚动不到真正的底部。 - // 在非流式状态下提供一个短暂的"粘底"窗口,确保最终停在最新消息处。 - useEffect(() => { - if (isLoading) return; - if (!shouldAutoScroll || userScrolled || displayableMessages.length === 0) return; - - let ticks = 0; - const intervalId = setInterval(() => { - ticks += 1; - // Use rAF to sync scroll with the rendering cycle, ensuring the - // virtualizer's height re-measurements are applied before scrolling - requestAnimationFrame(() => performAutoScroll('auto')); - if (ticks >= 8) { - clearInterval(intervalId); + return; } - }, 100); - - return () => clearInterval(intervalId); - }, [lastMessageHash, isLoading, shouldAutoScroll, userScrolled, displayableMessages.length]); + if (now - lastTime >= STREAM_TICK_INTERVAL) { + if (distanceFromBottom() > 4) { + scrollToBottom(); + } + lastTime = now; + } + rafId = requestAnimationFrame(tick); + }; - useEffect(() => { - if (shouldAutoScroll && !userScrolled && displayableMessages.length > 0) { - // 使用 requestAnimationFrame 确保在 DOM 更新后滚动 - const frameId = requestAnimationFrame(() => { - performAutoScroll(); - }); - return () => cancelAnimationFrame(frameId); - } - }, [lastMessageHash]); + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isLoading, distanceFromBottom, scrollToBottom]); return { parentRef, userScrolled, setUserScrolled, - setShouldAutoScroll + setShouldAutoScroll, }; } diff --git a/src/lib/claudeSDK.ts b/src/lib/claudeSDK.ts index b163bc35..19f78fb5 100644 --- a/src/lib/claudeSDK.ts +++ b/src/lib/claudeSDK.ts @@ -76,7 +76,7 @@ export class ClaudeSDKService { constructor(config: ClaudeSDKConfig = {}) { this.config = { - defaultModel: 'claude-3-5-sonnet-20241022', + defaultModel: 'claude-sonnet-4-6', maxTokens: 4000, temperature: 0.7, topP: 1, @@ -378,9 +378,10 @@ export class ClaudeSDKService { */ getAvailableModels(): string[] { return [ - 'claude-3-5-sonnet-20241022', - 'claude-3-opus-20240229', - 'claude-3-sonnet-20240229', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-haiku-4-5-20251001', ]; } diff --git a/src/lib/promptEnhancementService.ts b/src/lib/promptEnhancementService.ts index 561e3f5a..7a84b8e8 100644 --- a/src/lib/promptEnhancementService.ts +++ b/src/lib/promptEnhancementService.ts @@ -68,7 +68,7 @@ export const PRESET_PROVIDERS = { id: 'anthropic', name: 'Anthropic Claude', apiUrl: 'https://api.anthropic.com', - model: 'claude-sonnet-4-20250514', + model: 'claude-sonnet-4-6', apiFormat: 'anthropic' as const, enabled: false, apiKey: '', diff --git a/src/lib/subagentGrouping.ts b/src/lib/subagentGrouping.ts index a65952c0..49c1f1dc 100644 --- a/src/lib/subagentGrouping.ts +++ b/src/lib/subagentGrouping.ts @@ -42,13 +42,13 @@ export type MessageGroup = */ export function hasTaskToolCall(message: ClaudeStreamMessage): boolean { if (message.type !== 'assistant') return false; - + const content = message.message?.content; if (!Array.isArray(content)) return false; - - return content.some((item: any) => - item.type === 'tool_use' && - item.name?.toLowerCase() === 'task' + + return content.some((item: any) => + item.type === 'tool_use' && + /^(?:task|agent)$/i.test(item.name || '') ); } @@ -60,7 +60,7 @@ export function extractTaskToolUseIds(message: ClaudeStreamMessage): string[] { const content = message.message?.content as any[]; return content - .filter((item: any) => item.type === 'tool_use' && item.name?.toLowerCase() === 'task') + .filter((item: any) => item.type === 'tool_use' && /^(?:task|agent)$/i.test(item.name || '')) .map((item: any) => item.id) .filter(Boolean); } @@ -75,7 +75,7 @@ export function extractTaskToolDetails(message: ClaudeStreamMessage): Map item.type === 'tool_use' && item.name?.toLowerCase() === 'task') + .filter((item: any) => item.type === 'tool_use' && /^(?:task|agent)$/i.test(item.name || '')) .forEach((item: any) => { if (item.id) { details.set(item.id, { diff --git a/src/lib/tokenCounter.ts b/src/lib/tokenCounter.ts index 7e0f975d..e50fdc3c 100644 --- a/src/lib/tokenCounter.ts +++ b/src/lib/tokenCounter.ts @@ -4,7 +4,7 @@ * 基于Claude官方Token Count API的准确token计算服务 * 支持所有消息类型和Claude模型的精确token统计和成本计算 * - * 2026年最新官方定价和Claude 4.6系列模型支持 + * 2026年最新官方定价和Claude 4.7系列模型支持 */ import Anthropic from '@anthropic-ai/sdk'; @@ -15,16 +15,23 @@ import { api } from './api'; // ⚠️ WARNING: This pricing table MUST be kept in sync with: // src-tauri/src/commands/usage.rs::ModelPricing // Source: https://docs.claude.com/en/docs/about-claude/models/overview -// Last Updated: February 2026 +// Last Updated: May 2026 // ============================================================================ export const CLAUDE_PRICING = { - // Claude 4.6 Series (Latest - February 2026) + // Claude 4.7 Series (Latest - May 2026) + 'claude-opus-4-7': { + input: 5.0, + output: 25.0, + cache_write: 6.25, + cache_read: 0.50, + }, + // Claude 4.6 Series 'claude-opus-4-6': { - input: 15.0, - output: 75.0, - cache_write: 18.75, - cache_read: 1.50, + input: 5.0, + output: 25.0, + cache_write: 6.25, + cache_read: 0.50, }, 'claude-sonnet-4-6': { input: 3.0, @@ -99,8 +106,11 @@ export const CLAUDE_PRICING = { // ============================================================================ export const CLAUDE_CONTEXT_WINDOWS = { + // Claude 4.7 Series + 'claude-opus-4-7': 1000000, + 'claude-opus-4-7[1m]': 1000000, // Claude 4.6 Series - 'claude-opus-4-6': 200000, + 'claude-opus-4-6': 1000000, 'claude-opus-4-6[1m]': 1000000, 'claude-sonnet-4-6': 200000, 'claude-sonnet-4-6[1m]': 1000000, @@ -277,8 +287,10 @@ export function getContextWindowSize(model?: string, engine?: string): number { // 标准化模型名称映射 export const MODEL_ALIASES = { - 'opus': 'claude-opus-4-6', // 默认最新版本 - 'opus1m': 'claude-opus-4-6[1m]', + 'opus': 'claude-opus-4-7', // 默认最新版本 + 'opus1m': 'claude-opus-4-7[1m]', + 'opus4.7': 'claude-opus-4-7', + 'opus-4.7': 'claude-opus-4-7', 'opus4.6': 'claude-opus-4-6', 'opus-4.6': 'claude-opus-4-6', 'opus4.5': 'claude-opus-4-5', @@ -456,7 +468,12 @@ export class TokenCounterService { // Priority-based matching (order matters! MUST match backend logic) - // Claude 4.6 Series (Latest) + // Claude 4.7 Series (Latest) + if (normalized.includes('opus') && (normalized.includes('4.7') || normalized.includes('4-7'))) { + return 'claude-opus-4-7'; + } + + // Claude 4.6 Series if (normalized.includes('opus') && (normalized.includes('4.6') || normalized.includes('4-6'))) { return 'claude-opus-4-6'; } @@ -485,7 +502,7 @@ export class TokenCounterService { return 'claude-haiku-4-5'; // Default to latest } if (normalized.includes('opus')) { - return 'claude-opus-4-6'; // Default to latest + return 'claude-opus-4-7'; // Default to latest } if (normalized.includes('sonnet')) { return 'claude-sonnet-4-6'; // Default to latest @@ -1047,7 +1064,7 @@ export function calculateSessionStats( total_tokens: breakdown.total, total_cost: breakdown.cost.total_cost, message_count: messages.length, - average_tokens_per_message: breakdown.total / messages.length, + average_tokens_per_message: messages.length > 0 ? breakdown.total / messages.length : 0, cache_efficiency: breakdown.efficiency.cache_hit_rate, breakdown, trend: { diff --git a/src/lib/toolRegistryInit.tsx b/src/lib/toolRegistryInit.tsx index 0b3cf792..8cde3596 100644 --- a/src/lib/toolRegistryInit.tsx +++ b/src/lib/toolRegistryInit.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { toolRegistry, ToolRenderer, ToolRenderProps } from './toolRegistry'; +import { cn } from './utils'; // ✅ 已迁移组件:从新的 widgets 目录导入 import { @@ -603,11 +604,12 @@ export function initializeToolRegistry(): void { // Task - 子代理工具(Claude Code 特有) { name: 'task', + pattern: /^(?:task|agent)$/i, render: createToolAdapter(TaskWidget, (props) => ({ description: props.input?.description ?? props.result?.content?.description, prompt: props.input?.prompt ?? props.result?.content?.prompt, result: props.result, - subagentType: props.input?.subagent_type ?? props.result?.content?.subagent_type, + subagentType: props.input?.subagent_type ?? props.input?.subagentType ?? props.result?.content?.subagent_type, })), description: 'Claude Code 子代理工具', }, @@ -763,6 +765,175 @@ export function initializeToolRegistry(): void { })), description: '用户问题询问工具', }, + + // ToolSearch - 工具搜索 + { + name: 'toolsearch', + pattern: /^tool[-_]?search$/i, + render: (props) => { + const query = props.input?.query || ''; + return ( +
+
+ 🔍 工具搜索 + {query && {query}} +
+
+ ); + }, + description: '工具搜索', + }, + + // SendMessage - 代理间通信 + { + name: 'sendmessage', + pattern: /^send[-_]?message$/i, + render: (props) => { + const to = props.input?.to || ''; + return ( +
+
+ 💬 发送消息 + {to && {to}} +
+
+ ); + }, + description: '代理间消息通信', + }, + + // Skill - 技能调用(Claude Code Skills) + { + name: 'skill', + pattern: /^skill$/i, + render: (props) => { + const skill = props.input?.skill || props.input?.name || ''; + const args = props.input?.args; + const isError = props.result?.is_error; + const hasResult = props.result?.content !== undefined && props.result?.content !== null; + const resultText = hasResult + ? (typeof props.result?.content === 'string' + ? props.result.content + : JSON.stringify(props.result?.content, null, 2)) + : ''; + + return ( +
+
+
+ S +
+ Skill + {skill && ( + + {skill} + + )} + + {props.isStreaming ? '运行中…' : isError ? '失败' : hasResult ? '完成' : '已触发'} + +
+ {args && ( +
+ args: {typeof args === 'string' ? args : JSON.stringify(args)} +
+ )} + {hasResult && resultText && ( +
+                {resultText}
+              
+ )} +
+ ); + }, + description: 'Claude Code Skill 调用', + }, + + // Monitor - 进程监控 + { + name: 'monitor', + pattern: /^monitor$/i, + render: (_props) => { + return ( +
+
+ 📡 进程监控 +
+
+ ); + }, + description: '进程监控工具', + }, + + // ScheduleWakeup - 定时唤醒 + { + name: 'schedulewakeup', + pattern: /^schedule[-_]?wakeup$/i, + render: (props) => { + const delay = props.input?.delaySeconds; + const reason = props.input?.reason || ''; + return ( +
+
+ ⏰ 定时唤醒 + {delay && {delay}s} + {reason && · {reason}} +
+
+ ); + }, + description: '定时唤醒工具', + }, + + // NotebookEdit - Notebook 编辑 + { + name: 'notebookedit', + pattern: /^notebook[-_]?edit$/i, + render: createToolAdapter(EditWidget, (props) => ({ + file_path: props.input?.notebook || props.input?.file_path || '', + old_string: '', + new_string: props.input?.new_source || props.input?.content || '', + result: props.result, + })), + description: 'Notebook 编辑工具', + }, + + // Worktree 管理 + { + name: 'enterworktree', + pattern: /^(?:enter|exit)[-_]?worktree$/i, + render: (props) => { + const isEnter = /enter/i.test(props.toolName); + return ( +
+
+ 🌲 {isEnter ? '进入' : '退出'} Worktree +
+
+ ); + }, + description: 'Git Worktree 管理', + }, + + // Cron 管理 + { + name: 'croncreate', + pattern: /^cron[-_]?(?:create|delete|list)$/i, + render: (props) => { + const action = /create/i.test(props.toolName) ? '创建' : /delete/i.test(props.toolName) ? '删除' : '列表'; + return ( +
+
+ 🕐 定时任务{action} +
+
+ ); + }, + description: '定时任务管理', + }, ]; // 批量注册所有工具 diff --git a/src/styles/components.css b/src/styles/components.css index c050069c..90f49e99 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -77,20 +77,20 @@ /* --- CROSS-PLATFORM SCROLLBARS (OverlayScrollbars Style) --- */ -/* Global scrollbar reset */ +/* Global scrollbar reset — 始终显示细滚动条轨道,避免 hover 切换导致布局抖动 */ html * { scrollbar-width: thin; - scrollbar-color: transparent transparent; + scrollbar-color: rgba(128, 128, 128, 0.15) transparent; } -/* Show scrollbar on hover */ +/* 滚动容器 hover 时加深滚动条 */ html *:hover { scrollbar-color: var(--color-muted-foreground) transparent; } /* Webkit Scrollbar Styling (Chrome, Edge, Safari) */ html *::-webkit-scrollbar { - width: 8px; /* Slightly wider for better usability */ + width: 8px; height: 8px; } @@ -99,11 +99,11 @@ html *::-webkit-scrollbar-track { } html *::-webkit-scrollbar-thumb { - background-color: transparent; - border-radius: 4px; /* Pill shape */ - border: 2px solid transparent; /* Creates padding effect */ + background-color: rgba(128, 128, 128, 0.12); + border-radius: 4px; + border: 2px solid transparent; background-clip: content-box; - transition: background-color 0.2s ease; + transition: background-color 0.3s ease; } html *:hover::-webkit-scrollbar-thumb { @@ -112,7 +112,7 @@ html *:hover::-webkit-scrollbar-thumb { } html *::-webkit-scrollbar-thumb:hover { - background-color: var(--color-foreground); /* Darker on direct hover */ + background-color: var(--color-foreground); opacity: 0.8; } @@ -203,7 +203,7 @@ code:hover::-webkit-scrollbar-thumb, /* Light theme scrollbars */ .light * { - scrollbar-color: transparent transparent; + scrollbar-color: rgba(0, 0, 0, 0.08) transparent; } .light *::-webkit-scrollbar-track { @@ -211,7 +211,7 @@ code:hover::-webkit-scrollbar-thumb, } .light *::-webkit-scrollbar-thumb { - background-color: transparent; + background-color: rgba(0, 0, 0, 0.06); border: none; } From 00caed977ae1e4e27d477af5f234d1b09c594155 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Sun, 17 May 2026 23:20:09 +0800 Subject: [PATCH 4/8] fix: improve backend stability, session history and Codex execution modes - Optimize session history extraction with single-pass metadata reading - Refactor project store for better error handling and path resolution - Add Codex 'default' execution mode (sandbox with user prompts) - Improve git operations error handling for Codex and Gemini engines - Add permission config validation and prompt tracker improvements - Fix extensions command edge cases --- .../src/commands/claude/project_store.rs | 191 +++++++-------- .../src/commands/claude/session_history.rs | 221 ++++++++++++------ src-tauri/src/commands/codex/git_ops.rs | 38 +-- src-tauri/src/commands/codex/session.rs | 63 ++++- src-tauri/src/commands/extensions.rs | 2 + src-tauri/src/commands/gemini/git_ops.rs | 40 ++-- src-tauri/src/commands/permission_config.rs | 6 + src-tauri/src/commands/prompt_tracker.rs | 39 ++-- src/types/codex.ts | 2 +- 9 files changed, 357 insertions(+), 245 deletions(-) diff --git a/src-tauri/src/commands/claude/project_store.rs b/src-tauri/src/commands/claude/project_store.rs index 65eb473f..108dda7b 100644 --- a/src-tauri/src/commands/claude/project_store.rs +++ b/src-tauri/src/commands/claude/project_store.rs @@ -8,9 +8,7 @@ use serde_json::Value; use super::models::{Project, Session}; use super::paths::{decode_project_path, get_claude_dir, normalize_path_for_comparison}; -use super::session_history::{ - extract_first_user_message, extract_last_message_timestamp, extract_session_model, -}; +use super::session_history::extract_session_metadata; pub struct ProjectStore { claude_dir: PathBuf, @@ -36,23 +34,21 @@ impl ProjectStore { let mut hidden_projects = self.load_hidden_projects()?; if projects_dir.exists() { - let entries = fs::read_dir(&projects_dir) - .map_err(|e| format!("Failed to read projects directory: {}", e))?; + let entries: Vec<_> = fs::read_dir(&projects_dir) + .map_err(|e| format!("Failed to read projects directory: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); - // Count total valid project directories first - let total_project_count = fs::read_dir(&projects_dir) - .map(|entries| entries.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).count()) - .unwrap_or(0); + let total_project_count = entries.len(); // Safety check: if hidden_projects would hide ALL projects, clear the hidden list - // This prevents the "no projects found" issue caused by corrupted hidden_projects.json if total_project_count > 0 && hidden_projects.len() >= total_project_count { log::warn!( - "Safety check triggered: hidden_projects ({}) >= total projects ({}). Clearing hidden list to prevent all projects from being hidden.", + "Safety check triggered: hidden_projects ({}) >= total projects ({}). Clearing hidden list.", hidden_projects.len(), total_project_count ); - // Clear the hidden projects file if let Err(e) = self.save_hidden_projects(&[]) { log::error!("Failed to clear hidden projects file: {}", e); } @@ -60,86 +56,75 @@ impl ProjectStore { } for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let path = entry.path(); + let dir_name = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; - if path.is_dir() { - let dir_name = path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| "Invalid directory name".to_string())?; - - if hidden_projects.contains(&dir_name.to_string()) { - log::debug!("Skipping hidden project: {}", dir_name); - continue; - } - - let metadata = fs::metadata(&path) - .map_err(|e| format!("Failed to read directory metadata: {}", e))?; - - let created_at = metadata - .created() - .or_else(|_| metadata.modified()) - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let project_path = match get_project_path_from_sessions(&path) { - Ok(path) => path, - Err(e) => { - log::warn!( - "Failed to get project path from sessions for {}: {}, falling back to decode", - dir_name, - e - ); - decode_project_path(dir_name) - } - }; + if hidden_projects.contains(&dir_name) { + continue; + } - let mut sessions = Vec::new(); - let mut latest_activity = created_at; - - if let Ok(session_entries) = fs::read_dir(&path) { - for session_entry in session_entries.flatten() { - let session_path = session_entry.path(); - if session_path.is_file() - && session_path.extension().and_then(|s| s.to_str()) - == Some("jsonl") - { - if let Some(session_id) = - session_path.file_stem().and_then(|s| s.to_str()) - { - let (first_message, _) = - extract_first_user_message(&session_path); - if first_message.is_some() { - sessions.push(session_id.to_string()); - - if let Ok(session_metadata) = fs::metadata(&session_path) { - let session_modified = session_metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - if session_modified > latest_activity { - latest_activity = session_modified; - } + let created_at = entry.metadata() + .ok() + .and_then(|m| m.modified().ok().or_else(|| m.created().ok())) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let project_path = match get_project_path_from_sessions(&path) { + Ok(path) => path, + Err(_) => decode_project_path(&dir_name), + }; + + // Fast session enumeration: only use file metadata, don't read contents + let mut sessions = Vec::new(); + let mut latest_activity = created_at; + + if let Ok(session_entries) = fs::read_dir(&path) { + for session_entry in session_entries.flatten() { + let session_path = session_entry.path(); + if session_path.is_file() + && session_path.extension().and_then(|s| s.to_str()) == Some("jsonl") + { + if let Some(session_id) = session_path.file_stem().and_then(|s| s.to_str()) { + // Skip agent files + if session_id.starts_with("agent-") { + continue; + } + // Use file size as a quick validity check (skip empty/tiny files) + let is_valid = session_entry.metadata() + .map(|m| m.len() > 50) + .unwrap_or(false); + + if is_valid { + sessions.push(session_id.to_string()); + + // Use file modification time for activity tracking + if let Ok(m) = session_entry.metadata() { + let modified = m.modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if modified > latest_activity { + latest_activity = modified; } } } } } } - - all_projects.push(Project { - id: dir_name.to_string(), - path: project_path, - sessions, - created_at: latest_activity, - }); } + + all_projects.push(Project { + id: dir_name, + path: project_path, + sessions, + created_at: latest_activity, + }); } } else { log::warn!("Projects directory does not exist: {:?}", projects_dir); @@ -160,14 +145,7 @@ impl ProjectStore { let project_path = match get_project_path_from_sessions(&project_dir) { Ok(path) => path, - Err(e) => { - log::warn!( - "Failed to get project path from sessions for {}: {}, falling back to decode", - project_id, - e - ); - decode_project_path(project_id) - } + Err(_) => decode_project_path(project_id), }; let mut sessions = Vec::new(); @@ -180,39 +158,29 @@ impl ProjectStore { if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { - // 🔧 Skip agent-*.jsonl files (subagent sessions) if session_id.starts_with("agent-") { continue; } - let metadata = fs::metadata(&path) - .map_err(|e| format!("Failed to read file metadata: {}", e))?; - let created_at = metadata - .created() - .or_else(|_| metadata.modified()) + let created_at = entry.metadata() + .ok() + .and_then(|m| m.created().ok().or_else(|| m.modified().ok())) .unwrap_or(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let (first_message_raw, message_timestamp) = extract_first_user_message(&path); - let last_message_timestamp = extract_last_message_timestamp(&path); - let model = extract_session_model(&path); - - // ✅ Fallback: 如果 first_message 为空,使用默认文本以确保会话能显示 - // 这样即使所有用户消息都被过滤掉,会话仍然可见 - let first_message = first_message_raw.or_else(|| { - // 检查会话是否真的有内容: - // 1. 有 last_message_timestamp,说明有消息 - // 2. 文件大小 > 100 字节(排除几乎空的会话文件) - let has_content = last_message_timestamp.is_some() + // Single-pass metadata extraction (reads file once instead of 3 times) + let metadata = extract_session_metadata(&path); + + let first_message = metadata.first_message.or_else(|| { + let has_content = metadata.last_message_timestamp.is_some() && path.metadata() .ok() .map(|m| m.len() > 100) .unwrap_or(false); if has_content { - // 只显示 session_id 的前8位,避免 UI 过长 let short_id = if session_id.len() >= 8 { &session_id[..8] } else { @@ -220,7 +188,6 @@ impl ProjectStore { }; Some(format!("Resumed Session ({}...)", short_id)) } else { - // 真正的空会话 None } }); @@ -241,9 +208,9 @@ impl ProjectStore { todo_data, created_at, first_message, - message_timestamp, - last_message_timestamp, - model, + message_timestamp: metadata.message_timestamp, + last_message_timestamp: metadata.last_message_timestamp, + model: metadata.model, }); } } diff --git a/src-tauri/src/commands/claude/session_history.rs b/src-tauri/src/commands/claude/session_history.rs index ab67703a..0f110612 100644 --- a/src-tauri/src/commands/claude/session_history.rs +++ b/src-tauri/src/commands/claude/session_history.rs @@ -1,5 +1,5 @@ use std::fs; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; use std::time::SystemTime; @@ -9,7 +9,114 @@ use serde_json::Value; use super::models::JsonlEntry; use super::paths::get_claude_dir; +/// Session metadata extracted in a single pass +pub struct SessionMetadata { + pub first_message: Option, + pub message_timestamp: Option, + pub last_message_timestamp: Option, + pub model: Option, +} + +/// Extracts all session metadata in a single file read pass +/// This is much more efficient than calling extract_first_user_message, +/// extract_last_message_timestamp, and extract_session_model separately +pub fn extract_session_metadata>(jsonl_path: P) -> SessionMetadata { + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return SessionMetadata { + first_message: None, + message_timestamp: None, + last_message_timestamp: None, + model: None, + }, + }; + + let reader = BufReader::new(file); + let mut first_message: Option = None; + let mut message_timestamp: Option = None; + let mut last_timestamp: Option = None; + let mut model: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + let entry: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + // Extract model (from system init or assistant message) + if model.is_none() { + if let Some(model_str) = entry.get("model").and_then(|m| m.as_str()) { + model = Some(model_str.to_string()); + } else if let Some(message) = entry.get("message") { + if let Some(model_str) = message.get("model").and_then(|m| m.as_str()) { + model = Some(model_str.to_string()); + } + } + } + + // Track last timestamp from any message + if entry.get("message").is_some() { + if let Some(ts) = entry.get("timestamp").and_then(|t| t.as_str()) { + last_timestamp = Some(ts.to_string()); + } + } + + // Extract first user message (only if not found yet) + if first_message.is_none() { + if let Ok(parsed_entry) = serde_json::from_value::(entry.clone()) { + if let Some(message) = parsed_entry.message { + if message.role.as_deref() == Some("user") { + if let Some(content_value) = message.content { + let mut extracted_text = String::new(); + let mut has_text_content = false; + + if let Some(text) = content_value.as_str() { + extracted_text = text.to_string(); + has_text_content = !text.trim().is_empty(); + } else if let Some(arr) = content_value.as_array() { + for item in arr { + if let Some(item_type) = item.get("type").and_then(|t| t.as_str()) { + if item_type == "text" { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + extracted_text.push_str(text); + has_text_content = true; + } + } + } + } + } + + if has_text_content + && !extracted_text.contains("Caveat: The messages below were generated by the user while running local commands") + && !extracted_text.starts_with("") + && !extracted_text.starts_with("") + && !extracted_text.contains("Warmup") + { + first_message = Some(extracted_text); + message_timestamp = parsed_entry.timestamp; + } + } + } + } + } + } + } + + SessionMetadata { + first_message, + message_timestamp, + last_message_timestamp: last_timestamp, + model, + } +} + /// Extracts the first valid user message from a JSONL file +#[allow(dead_code)] pub fn extract_first_user_message>( jsonl_path: P, ) -> (Option, Option) { @@ -26,16 +133,13 @@ pub fn extract_first_user_message>( if let Some(message) = entry.message { if message.role.as_deref() == Some("user") { if let Some(content_value) = message.content { - // 提取文本内容(支持字符串和数组两种格式) let mut extracted_text = String::new(); let mut has_text_content = false; if let Some(text) = content_value.as_str() { - // 字符串格式 extracted_text = text.to_string(); has_text_content = !text.trim().is_empty(); } else if let Some(arr) = content_value.as_array() { - // 数组格式(可能包含 text 和 tool_result) for item in arr { if let Some(item_type) = item.get("type").and_then(|t| t.as_str()) @@ -52,29 +156,24 @@ pub fn extract_first_user_message>( } } - // 必须有文本内容 if !has_text_content { continue; } - // Skip if it contains the caveat message if extracted_text.contains("Caveat: The messages below were generated by the user while running local commands") { continue; } - // Skip if it starts with command tags if extracted_text.starts_with("") || extracted_text.starts_with("") { continue; } - // Skip Warmup messages (auto-sent on session start) if extracted_text.contains("Warmup") { continue; } - // Found a valid user message return (Some(extracted_text), entry.timestamp); } } @@ -86,22 +185,58 @@ pub fn extract_first_user_message>( (None, None) } -/// Extracts the timestamp of the last message (user or assistant) from a JSONL file +/// Extracts the timestamp of the last message using reverse file reading +/// Much faster than reading the entire file for large session files +#[allow(dead_code)] pub fn extract_last_message_timestamp>(jsonl_path: P) -> Option { - let file = match fs::File::open(jsonl_path) { + let mut file = match fs::File::open(jsonl_path.as_ref()) { Ok(file) => file, Err(_) => return None, }; - let reader = BufReader::new(file); + // Read last 8KB of the file (usually enough to find the last message) + let file_size = file.metadata().ok()?.len(); + let read_size = std::cmp::min(file_size, 8192) as usize; + let seek_pos = file_size - read_size as u64; + + if file.seek(SeekFrom::Start(seek_pos)).is_err() { + return None; + } + + let mut buffer = vec![0u8; read_size]; + if file.read_exact(&mut buffer).is_err() { + return None; + } + + let content = String::from_utf8_lossy(&buffer); let mut last_timestamp: Option = None; + // Parse lines from the tail chunk + for line in content.lines() { + if let Ok(entry) = serde_json::from_str::(line) { + if entry.message.is_some() { + if let Some(timestamp) = entry.timestamp { + last_timestamp = Some(timestamp); + } + } + } + } + + // If we found a timestamp in the tail, return it + if last_timestamp.is_some() { + return last_timestamp; + } + + // Fallback: read the full file (only for very small files or edge cases) + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return None, + }; + let reader = BufReader::new(file); for line in reader.lines() { if let Ok(line) = line { if let Ok(entry) = serde_json::from_str::(&line) { - // Check if this entry has a message (user or assistant) if entry.message.is_some() { - // Update last_timestamp if this entry has a timestamp if let Some(timestamp) = entry.timestamp { last_timestamp = Some(timestamp); } @@ -109,12 +244,12 @@ pub fn extract_last_message_timestamp>(jsonl_path: P) -> Option>(jsonl_path: P) -> Option { let file = match fs::File::open(jsonl_path) { Ok(file) => file, @@ -122,28 +257,22 @@ pub fn extract_session_model>(jsonl_path: P) -> Option { }; let reader = BufReader::new(file); - let mut last_model: Option = None; - for line in reader.lines() { + for line in reader.lines().take(30) { if let Ok(line) = line { - // Try to parse as a generic JSON value first if let Ok(entry) = serde_json::from_str::(&line) { - // Check for model in different locations: - // 1. System init message: { "type": "system", "model": "..." } - // 2. Assistant message: { "type": "assistant", "message": { "model": "..." } } - if let Some(model_str) = entry.get("model").and_then(|m| m.as_str()) { - last_model = Some(model_str.to_string()); + return Some(model_str.to_string()); } else if let Some(message) = entry.get("message") { if let Some(model_str) = message.get("model").and_then(|m| m.as_str()) { - last_model = Some(model_str.to_string()); + return Some(model_str.to_string()); } } } } } - last_model + None } /// Loads the JSONL history for a specific session @@ -163,7 +292,6 @@ pub fn load_session_history(session_id: &str, project_id: &str) -> Result Result tool_use_id mapping let mut agent_to_tool_use_id: std::collections::HashMap = std::collections::HashMap::new(); for line in reader.lines() { if let Ok(line) = line { if let Ok(json) = serde_json::from_str::(&line) { - // Check for tool_result with agentId to build mapping if let Some(content) = json .get("message") .and_then(|m| m.get("content")) @@ -191,18 +317,12 @@ pub fn load_session_history(session_id: &str, project_id: &str) -> Result {}", - agent_id, - tool_use_id - ); agent_to_tool_use_id .insert(agent_id.to_string(), tool_use_id.to_string()); } @@ -214,44 +334,26 @@ pub fn load_session_history(session_id: &str, project_id: &str) -> Result "aa740fde") let agent_id = file_name .strip_prefix("agent-") .and_then(|s| s.strip_suffix(".jsonl")) .unwrap_or(""); - // Check if this agent belongs to our session if let Some(tool_use_id) = agent_to_tool_use_id.get(agent_id) { - log::info!( - "Loading subagent file: {} for tool_use_id: {}", - file_name, - tool_use_id - ); - - // Load subagent messages if let Ok(file) = fs::File::open(&path) { let reader = BufReader::new(file); for line in reader.lines() { if let Ok(line) = line { if let Ok(mut json) = serde_json::from_str::(&line) { - // Verify this subagent belongs to our session let subagent_session_id = json.get("sessionId").and_then(|s| s.as_str()); if subagent_session_id == Some(session_id) { - // Add parent_tool_use_id to link subagent messages to Task json["parent_tool_use_id"] = Value::String(tool_use_id.clone()); messages.push(json); @@ -267,37 +369,26 @@ pub fn load_session_history(session_id: &str, project_id: &str) -> Result::from(message_time).to_rfc3339(); - // Set appropriate timestamp fields based on message type, only if they don't exist match message_type { "user" => { if !message.get("sentAt").is_some() { message["sentAt"] = Value::String(timestamp_iso.clone()); } } - "assistant" | "system" | "result" => { - if !message.get("receivedAt").is_some() { - message["receivedAt"] = Value::String(timestamp_iso.clone()); - } - } _ => { - // For unknown types, add receivedAt if !message.get("receivedAt").is_some() { message["receivedAt"] = Value::String(timestamp_iso.clone()); } diff --git a/src-tauri/src/commands/codex/git_ops.rs b/src-tauri/src/commands/codex/git_ops.rs index 377e7efb..4679f455 100644 --- a/src-tauri/src/commands/codex/git_ops.rs +++ b/src-tauri/src/commands/codex/git_ops.rs @@ -581,24 +581,26 @@ pub async fn record_codex_prompt_completed( return Ok(()); } - // Auto-commit any changes made by AI - let commit_message = build_prompt_commit_message("[Codex]", prompt_text.as_deref(), prompt_index); - match simple_git::git_commit_changes(&project_path, &commit_message) { - Ok(true) => { - log::info!( - "[Codex Record] Auto-committed changes after prompt #{}", - prompt_index - ); - } - Ok(false) => { - log::debug!( - "[Codex Record] No changes to commit after prompt #{}", - prompt_index - ); - } - Err(e) => { - log::warn!("[Codex Record] Failed to auto-commit: {}", e); - // Continue anyway + if execution_config.disable_auto_commit_after_response { + log::info!("[Codex Record] Auto-commit disabled by user setting, skipping git commit"); + } else { + let commit_message = build_prompt_commit_message("[Codex]", prompt_text.as_deref(), prompt_index); + match simple_git::git_commit_changes(&project_path, &commit_message) { + Ok(true) => { + log::info!( + "[Codex Record] Auto-committed changes after prompt #{}", + prompt_index + ); + } + Ok(false) => { + log::debug!( + "[Codex Record] No changes to commit after prompt #{}", + prompt_index + ); + } + Err(e) => { + log::warn!("[Codex Record] Failed to auto-commit: {}", e); + } } } diff --git a/src-tauri/src/commands/codex/session.rs b/src-tauri/src/commands/codex/session.rs index d19671e9..4d3aa4da 100644 --- a/src-tauri/src/commands/codex/session.rs +++ b/src-tauri/src/commands/codex/session.rs @@ -30,20 +30,22 @@ use super::config::get_codex_sessions_dir; // ============================================================================ /// Codex execution mode -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum CodexExecutionMode { - /// Read-only mode (default, safe) + /// Read-only mode (safe, no writes) ReadOnly, - /// Allow file edits + /// Default sandbox: prompt user before sensitive operations (recommended) + Default, + /// Allow file edits without prompts (workspace-write) FullAuto, - /// Full access including network + /// Full access including network (danger) DangerFullAccess, } impl Default for CodexExecutionMode { fn default() -> Self { - Self::ReadOnly + Self::Default } } @@ -93,6 +95,15 @@ fn default_json_mode() -> bool { true } +fn codex_sandbox_mode_override(mode: &CodexExecutionMode) -> Option<&'static str> { + match mode { + CodexExecutionMode::ReadOnly => Some("read-only"), + CodexExecutionMode::FullAuto => Some("workspace-write"), + CodexExecutionMode::DangerFullAccess => Some("danger-full-access"), + CodexExecutionMode::Default => None, + } +} + /// Codex session metadata #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -639,21 +650,34 @@ fn build_codex_command( } if is_resume { - // Add 'resume' after --json + // Pass sandbox mode via -c config override BEFORE 'resume' subcommand + // This fixes the issue where resume defaults to read-only regardless + // of the user's selected mode (issue #174) + if let Some(sandbox_mode) = codex_sandbox_mode_override(&options.mode) { + cmd.arg("-c"); + cmd.arg(format!("sandbox_mode={}", sandbox_mode)); + } + + // Pass model override on resume if user selected one + if let Some(ref model) = options.model { + cmd.arg("-c"); + cmd.arg(format!("model={}", model)); + } + cmd.arg("resume"); - // Add session_id if let Some(sid) = session_id { cmd.arg(sid); } - - // Resume mode: other options are NOT supported - // The session retains its original mode/model configuration } else { // For new sessions: add other options // (--json already added above) match options.mode { + CodexExecutionMode::Default => { + // Default sandbox: prompt user before sensitive operations + // Don't pass --sandbox or --full-auto, let Codex use default behavior + } CodexExecutionMode::FullAuto => { cmd.arg("--full-auto"); } @@ -662,7 +686,8 @@ fn build_codex_command( cmd.arg("danger-full-access"); } CodexExecutionMode::ReadOnly => { - // Read-only is default + cmd.arg("--sandbox"); + cmd.arg("read-only"); } } @@ -732,12 +757,23 @@ fn build_wsl_codex_command( } if is_resume { + if let Some(sandbox_mode) = codex_sandbox_mode_override(&options.mode) { + args.push("-c".to_string()); + args.push(format!("sandbox_mode={}", sandbox_mode)); + } + if let Some(ref model) = options.model { + args.push("-c".to_string()); + args.push(format!("model={}", model)); + } args.push("resume".to_string()); if let Some(sid) = session_id { args.push(sid.to_string()); } } else { match options.mode { + CodexExecutionMode::Default => { + // Default sandbox: prompt user before sensitive operations + } CodexExecutionMode::FullAuto => { args.push("--full-auto".to_string()); } @@ -745,7 +781,10 @@ fn build_wsl_codex_command( args.push("--sandbox".to_string()); args.push("danger-full-access".to_string()); } - CodexExecutionMode::ReadOnly => {} + CodexExecutionMode::ReadOnly => { + args.push("--sandbox".to_string()); + args.push("read-only".to_string()); + } } if let Some(ref model) = options.model { diff --git a/src-tauri/src/commands/extensions.rs b/src-tauri/src/commands/extensions.rs index 5db2db44..ccbef33b 100644 --- a/src-tauri/src/commands/extensions.rs +++ b/src-tauri/src/commands/extensions.rs @@ -145,6 +145,7 @@ fn scan_agents_directory(dir: &Path, scope: &str) -> Result, S for entry in WalkDir::new(dir) .max_depth(2) // Limit depth + .follow_links(true) .into_iter() .filter_map(|e| e.ok()) { @@ -216,6 +217,7 @@ fn scan_skills_directory(dir: &Path, scope: &str) -> Result, for entry in WalkDir::new(dir) .max_depth(2) + .follow_links(true) .into_iter() .filter_map(|e| e.ok()) { diff --git a/src-tauri/src/commands/gemini/git_ops.rs b/src-tauri/src/commands/gemini/git_ops.rs index 5e86e3eb..3d3ebdbc 100644 --- a/src-tauri/src/commands/gemini/git_ops.rs +++ b/src-tauri/src/commands/gemini/git_ops.rs @@ -498,25 +498,27 @@ pub async fn record_gemini_prompt_completed( return Ok(()); } - // Auto-commit any changes made by AI - let commit_message = - build_prompt_commit_message("[Gemini]", prompt_text.as_deref(), prompt_index); - match simple_git::git_commit_changes(&project_path, &commit_message) { - Ok(true) => { - log::info!( - "[Gemini Record] Auto-committed changes after prompt #{}", - prompt_index - ); - } - Ok(false) => { - log::debug!( - "[Gemini Record] No changes to commit after prompt #{}", - prompt_index - ); - } - Err(e) => { - log::warn!("[Gemini Record] Failed to auto-commit: {}", e); - // Continue anyway + if execution_config.disable_auto_commit_after_response { + log::info!("[Gemini Record] Auto-commit disabled by user setting, skipping git commit"); + } else { + let commit_message = + build_prompt_commit_message("[Gemini]", prompt_text.as_deref(), prompt_index); + match simple_git::git_commit_changes(&project_path, &commit_message) { + Ok(true) => { + log::info!( + "[Gemini Record] Auto-committed changes after prompt #{}", + prompt_index + ); + } + Ok(false) => { + log::debug!( + "[Gemini Record] No changes to commit after prompt #{}", + prompt_index + ); + } + Err(e) => { + log::warn!("[Gemini Record] Failed to auto-commit: {}", e); + } } } diff --git a/src-tauri/src/commands/permission_config.rs b/src-tauri/src/commands/permission_config.rs index 3bf298ed..39579bb2 100644 --- a/src-tauri/src/commands/permission_config.rs +++ b/src-tauri/src/commands/permission_config.rs @@ -70,6 +70,11 @@ pub struct ClaudeExecutionConfig { pub permissions: ClaudePermissionConfig, #[serde(default)] pub disable_rewind_git_operations: bool, + /// Controls automatic `git commit` after each AI response. + /// Issue #181/#175: separated from `disable_rewind_git_operations` + /// so users can keep rewind history without auto-committing. + #[serde(default)] + pub disable_auto_commit_after_response: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -89,6 +94,7 @@ impl Default for ClaudeExecutionConfig { verbose: true, permissions: ClaudePermissionConfig::default(), disable_rewind_git_operations: false, + disable_auto_commit_after_response: false, } } } diff --git a/src-tauri/src/commands/prompt_tracker.rs b/src-tauri/src/commands/prompt_tracker.rs index 62674337..578b08ef 100644 --- a/src-tauri/src/commands/prompt_tracker.rs +++ b/src-tauri/src/commands/prompt_tracker.rs @@ -596,24 +596,27 @@ pub async fn mark_prompt_completed( return Ok(()); } - // Auto-commit any changes made by AI - // This ensures each prompt has a distinct git state - let commit_message = - build_prompt_commit_message("[Claude Code]", prompt_text.as_deref(), prompt_index); - match simple_git::git_commit_changes(&project_path, &commit_message) { - Ok(true) => { - log::info!("Auto-committed changes after prompt #{}", prompt_index); - } - Ok(false) => { - log::debug!("No changes to commit after prompt #{}", prompt_index); - } - Err(e) => { - log::warn!( - "Failed to auto-commit after prompt #{}: {}", - prompt_index, - e - ); - // Continue anyway, don't fail the whole operation + if execution_config.disable_auto_commit_after_response { + log::info!( + "[Mark Complete] Auto-commit disabled by user setting, skipping git commit" + ); + } else { + let commit_message = + build_prompt_commit_message("[Claude Code]", prompt_text.as_deref(), prompt_index); + match simple_git::git_commit_changes(&project_path, &commit_message) { + Ok(true) => { + log::info!("Auto-committed changes after prompt #{}", prompt_index); + } + Ok(false) => { + log::debug!("No changes to commit after prompt #{}", prompt_index); + } + Err(e) => { + log::warn!( + "Failed to auto-commit after prompt #{}: {}", + prompt_index, + e + ); + } } } diff --git a/src/types/codex.ts b/src/types/codex.ts index adbc567f..eb3d4153 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -128,7 +128,7 @@ export type CodexItem = /** * Codex execution mode */ -export type CodexExecutionMode = 'read-only' | 'full-auto' | 'danger-full-access'; +export type CodexExecutionMode = 'read-only' | 'default' | 'full-auto' | 'danger-full-access'; /** * Codex execution options From 5dada0dca2f14fa3688a8c762b5b69801c818bf2 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Mon, 18 May 2026 10:47:08 +0800 Subject: [PATCH 5/8] =?UTF-8?q?perf:=20=E9=87=8D=E6=9E=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86=E4=B8=8E=E6=A8=A1=E5=9E=8B=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E7=83=AD=E7=82=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDisplayableMessages: tool_use_id 索引 + 单遍历合并 warmup 检测,O(n²) → O(n) - progressiveTranslation: getStats 单 reduce 取代 5 次 filter;移除 sortQueue 重建 Map - subagentGrouping: 抽出 isTaskOrAgentToolUse + 单次扫描返回 ids 与 details - modelNameParser: 模块级 Map 缓存(256 上限 + LRU),并加版本守卫防止旧 init 消息固化陈旧名(Sonnet 4.5 → 4.6 自动更新) - tokenCounter: normalizeModel 改预排序规则表,串行 includes 改为命中即返 - useSessionStream/usePromptExecution: rawJsonlOutput 加 2000 条环形缓冲,长会话不再 OOM --- src/hooks/useDisplayableMessages.ts | 90 +++++++++--------- src/hooks/usePromptExecution.ts | 37 +++++++- src/hooks/useSessionStream.ts | 15 ++- src/lib/modelNameParser.ts | 139 ++++++++++++++++++++++++++-- src/lib/progressiveTranslation.ts | 60 ++++++------ src/lib/subagentGrouping.ts | 94 ++++++++++--------- src/lib/tokenCounter.ts | 89 ++++++++++-------- 7 files changed, 352 insertions(+), 172 deletions(-) diff --git a/src/hooks/useDisplayableMessages.ts b/src/hooks/useDisplayableMessages.ts index 40ca3ef7..99742da3 100644 --- a/src/hooks/useDisplayableMessages.ts +++ b/src/hooks/useDisplayableMessages.ts @@ -18,6 +18,23 @@ interface DisplayableMessagesOptions { hideStartupWarnings?: boolean; } +/** + * 这些工具有专用的 Widget,结果不需要单独显示 + * 模块级 Set,避免每次 filter 重建数组 + */ +const TOOLS_WITH_WIDGETS = new Set([ + 'task', + 'edit', + 'multiedit', + 'todowrite', + 'ls', + 'read', + 'glob', + 'bash', + 'write', + 'grep', +]); + /** * 检查消息是否为启动期间的系统警告消息 * 这些消息通常在 Gemini 等引擎初始化 MCP 客户端时产生 @@ -116,20 +133,30 @@ export function useDisplayableMessages( const hideStartupWarnings = options.hideStartupWarnings !== false; return useMemo(() => { - // 如果需要隐藏 Warmup,先找到所有 Warmup 消息的索引 + // 单次正向扫描:构建 tool_use_id -> { name } 的索引,并收集 Warmup 索引 + // 通过一次遍历同时完成原本的两遍循环,整体复杂度从 O(n²) 降到 O(n) + const toolUseIndex = new Map(); const warmupIndices = new Set(); - if (hideWarmupMessages) { - messages.forEach((msg, idx) => { - if (isWarmupMessage(msg)) { - warmupIndices.add(idx); - // 找到紧跟其后的 assistant 回复(Warmup 的响应) - if (idx + 1 < messages.length && messages[idx + 1].type === 'assistant') { - warmupIndices.add(idx + 1); + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + + // 收集 assistant 消息中的所有 tool_use + if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) { + for (const item of msg.message.content as any[]) { + if (item.type === 'tool_use' && item.id) { + toolUseIndex.set(item.id, { name: item.name || '' }); } } - }); - + } + + // Warmup 检测并入主扫描 + if (hideWarmupMessages && isWarmupMessage(msg)) { + warmupIndices.add(i); + if (i + 1 < messages.length && messages[i + 1].type === 'assistant') { + warmupIndices.add(i + 1); + } + } } return messages.filter((message, index) => { @@ -174,44 +201,17 @@ export function useDisplayableMessages( let willBeSkipped = false; if (content.tool_use_id) { - // 向前查找匹配的 tool_use - for (let i = index - 1; i >= 0; i--) { - const prevMsg = messages[i]; + // O(1) 查询:通过预先构建的索引获取 tool_use 名称 + const toolUse = toolUseIndex.get(content.tool_use_id); + + if (toolUse) { + const toolName = toolUse.name?.toLowerCase(); if ( - prevMsg.type === 'assistant' && - prevMsg.message?.content && - Array.isArray(prevMsg.message.content) + (toolName && TOOLS_WITH_WIDGETS.has(toolName)) || + toolUse.name?.startsWith('mcp__') ) { - const toolUse = prevMsg.message.content.find( - (c: any) => c.type === 'tool_use' && c.id === content.tool_use_id - ); - - if (toolUse) { - const toolName = toolUse.name?.toLowerCase(); - - // 这些工具有专用的 Widget,结果不需要单独显示 - const toolsWithWidgets = [ - 'task', - 'edit', - 'multiedit', - 'todowrite', - 'ls', - 'read', - 'glob', - 'bash', - 'write', - 'grep' - ]; - - if ( - toolsWithWidgets.includes(toolName) || - toolUse.name?.startsWith('mcp__') - ) { - willBeSkipped = true; - } - break; - } + willBeSkipped = true; } } } diff --git a/src/hooks/usePromptExecution.ts b/src/hooks/usePromptExecution.ts index b5cf3d4c..49338850 100644 --- a/src/hooks/usePromptExecution.ts +++ b/src/hooks/usePromptExecution.ts @@ -23,6 +23,12 @@ import { CodexEventConverter, extractCodexRateLimitsFromEvent } from '@/lib/code import type { CodexExecutionMode, CodexRateLimits } from '@/types/codex'; import { cacheModelFromInitMessage } from '@/lib/modelNameParser'; +/** + * 环形缓冲,避免长会话 OOM + * rawJsonlOutput 在长会话中会无限增长,超过该上限时丢弃最早的条目 + */ +const MAX_RAW_JSONL_ENTRIES = 2000; + // ============================================================================ // Global Type Declarations // ============================================================================ @@ -403,7 +409,13 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE const message = sessionCodexConverter.convertEvent(payload); if (message) { setMessages(prev => [...prev, message]); - setRawJsonlOutput((prev) => [...prev, payload]); + // 环形缓冲,避免长会话 OOM + setRawJsonlOutput((prev) => { + const next = prev.length >= MAX_RAW_JSONL_ENTRIES + ? [...prev.slice(prev.length - MAX_RAW_JSONL_ENTRIES + 1), payload] + : [...prev, payload]; + return next; + }); // Extract and save Codex thread_id from thread.started for session resuming // NOTE: claudeSessionId is already set to the backend channel ID in codex-session-init handler @@ -877,7 +889,12 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE const message = convertGeminiToClaudeMessage(data); return message ? [...prev, message] : prev; }); - setRawJsonlOutput((prev) => [...prev, payload]); + setRawJsonlOutput((prev) => { + const next = prev.length >= MAX_RAW_JSONL_ENTRIES + ? [...prev.slice(prev.length - MAX_RAW_JSONL_ENTRIES + 1), payload] + : [...prev, payload]; + return next; + }); return; } @@ -886,7 +903,12 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE if (message) { setMessages(prev => [...prev, message]); - setRawJsonlOutput((prev) => [...prev, payload]); + setRawJsonlOutput((prev) => { + const next = prev.length >= MAX_RAW_JSONL_ENTRIES + ? [...prev.slice(prev.length - MAX_RAW_JSONL_ENTRIES + 1), payload] + : [...prev, payload]; + return next; + }); // 🔧 NOTE: Session ID handling moved to gemini-cli-session-id event listener // The init message from gemini-output may contain backend's temporary ID (gemini-{uuid}) @@ -1201,8 +1223,13 @@ export function usePromptExecution(config: UsePromptExecutionConfig): UsePromptE } processedClaudeMessages.add(messageId); - // Store raw JSONL - setRawJsonlOutput((prev) => [...prev, payload]); + // Store raw JSONL(环形缓冲,避免长会话 OOM) + setRawJsonlOutput((prev) => { + const next = prev.length >= MAX_RAW_JSONL_ENTRIES + ? [...prev.slice(prev.length - MAX_RAW_JSONL_ENTRIES + 1), payload] + : [...prev, payload]; + return next; + }); const message = JSON.parse(payload) as ClaudeStreamMessage; diff --git a/src/hooks/useSessionStream.ts b/src/hooks/useSessionStream.ts index 4430e4e9..e5c467bd 100644 --- a/src/hooks/useSessionStream.ts +++ b/src/hooks/useSessionStream.ts @@ -30,6 +30,12 @@ import { cacheGeminiModelFromStream, } from '@/lib/modelNameParser'; +/** + * 环形缓冲,避免长会话 OOM + * rawJsonlOutput 在长会话中会无限增长,超过该上限时丢弃最早的条目 + */ +const MAX_RAW_JSONL_ENTRIES = 2000; + /** * Hook 配置 * 与 useSessionLifecycle 完全兼容 @@ -163,8 +169,13 @@ export function useSessionStream(config: UseSessionStreamConfig): UseSessionStre ) => { if (!isMountedRef.current) return; - // 存储原始 JSONL - setRawJsonlOutput(prev => [...prev, rawPayload]); + // 存储原始 JSONL(环形缓冲,避免长会话 OOM) + setRawJsonlOutput(prev => { + const next = prev.length >= MAX_RAW_JSONL_ENTRIES + ? [...prev.slice(prev.length - MAX_RAW_JSONL_ENTRIES + 1), rawPayload] + : [...prev, rawPayload]; + return next; + }); // 通过翻译中间件处理 await processMessageWithTranslation(message, rawPayload); diff --git a/src/lib/modelNameParser.ts b/src/lib/modelNameParser.ts index abb5feac..07829eb4 100644 --- a/src/lib/modelNameParser.ts +++ b/src/lib/modelNameParser.ts @@ -15,6 +15,61 @@ const CACHE_KEY = 'model_display_names'; const CODEX_CACHE_KEY = 'codex_model_display_names'; const GEMINI_CACHE_KEY = 'gemini_model_display_names'; +/** + * 各 family 的最低可接受版本号(防陈旧缓存) + * - 历史会话的 init 消息会把更早版本(如 Sonnet 4.5)写进 localStorage + * 并永久覆盖默认显示名;这里设最低门槛,低于此版本的缓存条目读取时直接丢弃 + * - 当 Anthropic 升级模型时,把这里的版本号往上提即可让旧缓存自动失效 + */ +const MIN_FAMILY_VERSIONS: Record = { + sonnet: 4.6, + opus: 4.7, + haiku: 4.5, +}; + +/** + * 从显示名(如 "Claude Sonnet 4.6")提取数值版本号 + * 失败时返回 null + */ +function extractVersionFromDisplayName(displayName: string): number | null { + const m = displayName.match(/(\d+(?:\.\d+)?)/); + if (!m) return null; + const v = parseFloat(m[1]); + return Number.isFinite(v) ? v : null; +} + +/** + * 判断显示名是否陈旧(低于该 family 的最低门槛) + */ +function isStaleDisplayName(family: string, displayName: string): boolean { + const minVersion = MIN_FAMILY_VERSIONS[family.toLowerCase()]; + if (minVersion === undefined) return false; + const version = extractVersionFromDisplayName(displayName); + if (version === null) return false; + return version < minVersion; +} + +/** + * 纯函数结果缓存:避免对相同 modelId 重复执行 split / regex / map + * - 上限 256 条;溢出时按插入顺序淘汰最早的 64 条 + * - 仅模块内使用,不导出 + */ +const PARSE_CACHE_LIMIT = 256; +const PARSE_CACHE_EVICT = 64; + +const parseModelDisplayNameCache = new Map(); +const formatCodexModelNameCache = new Map(); +const formatGeminiModelNameCache = new Map(); + +function evictIfNeeded(cache: Map): void { + if (cache.size <= PARSE_CACHE_LIMIT) return; + let i = 0; + for (const key of cache.keys()) { + cache.delete(key); + if (++i >= PARSE_CACHE_EVICT) break; + } +} + /** * Custom event name dispatched when model names are updated in cache. * Components can listen for this to refresh their model display names. @@ -43,17 +98,26 @@ export const GEMINI_MODEL_NAMES_UPDATED_EVENT = 'gemini-model-names-updated'; export function parseModelDisplayName(modelId: string): string | null { if (!modelId || typeof modelId !== 'string') return null; + // 命中缓存直接返回 + if (parseModelDisplayNameCache.has(modelId)) { + return parseModelDisplayNameCache.get(modelId)!; + } + // Pattern: claude-{family}-{major}[-{minor}[...]]-{date} // The date is always 8 digits at the end const match = modelId.match(/^claude-(\w+)-([\d]+(?:-[\d]+)*)-\d{8}/); - if (!match) return null; - - const family = match[1]; // "sonnet", "opus", "haiku" - const versionParts = match[2].split('-'); // ["4", "5"] or ["4"] - const version = versionParts.join('.'); + let result: string | null = null; + if (match) { + const family = match[1]; // "sonnet", "opus", "haiku" + const versionParts = match[2].split('-'); // ["4", "5"] or ["4"] + const version = versionParts.join('.'); + const familyName = family.charAt(0).toUpperCase() + family.slice(1); + result = `Claude ${familyName} ${version}`; + } - const familyName = family.charAt(0).toUpperCase() + family.slice(1); - return `Claude ${familyName} ${version}`; + parseModelDisplayNameCache.set(modelId, result); + evictIfNeeded(parseModelDisplayNameCache); + return result; } /** @@ -81,7 +145,27 @@ export function getCachedModelNames(): Record { if (cached) { const parsed = JSON.parse(cached); if (parsed && typeof parsed === 'object') { - return parsed; + // 🔧 过滤陈旧条目:低于 MIN_FAMILY_VERSIONS 的缓存视为失效 + // 例:升级到 Sonnet 4.6 后,旧的 "Claude Sonnet 4.5" 自动让位给默认值 + let mutated = false; + const filtered: Record = {}; + for (const [family, displayName] of Object.entries(parsed)) { + if (typeof displayName !== 'string') continue; + if (isStaleDisplayName(family, displayName)) { + mutated = true; + continue; + } + filtered[family] = displayName; + } + // 顺手把陈旧条目从 localStorage 中清理掉,避免下次再读 + if (mutated) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(filtered)); + } catch { + // 静默忽略写入失败 + } + } + return filtered; } } } catch { @@ -99,7 +183,26 @@ export function getCachedModelNames(): Record { */ export function cacheModelName(family: string, displayName: string): void { try { + // 🔧 拒绝写入陈旧版本:浏览历史会话时,旧 init 消息(如 Sonnet 4.5) + // 不应覆盖已升级到的新版本(如 Sonnet 4.6) + if (isStaleDisplayName(family, displayName)) return; + const cached = getCachedModelNames(); + + // 若缓存已有更高版本,不让低版本覆盖 + const existing = cached[family]; + if (existing) { + const existingVersion = extractVersionFromDisplayName(existing); + const incomingVersion = extractVersionFromDisplayName(displayName); + if ( + existingVersion !== null && + incomingVersion !== null && + incomingVersion < existingVersion + ) { + return; + } + } + // Only update and notify if the name actually changed if (cached[family] === displayName) return; @@ -205,7 +308,11 @@ export function cacheCodexModelFromStream(modelId: string): void { export function formatCodexModelName(modelId: string): string { if (!modelId) return modelId; - return modelId + // 命中缓存直接返回 + const cached = formatCodexModelNameCache.get(modelId); + if (cached !== undefined) return cached; + + const result = modelId .split('-') .map(part => { // Keep version numbers as-is (e.g., "5.2") @@ -219,6 +326,10 @@ export function formatCodexModelName(modelId: string): string { // Clean up double spaces .replace(/\s+/g, ' ') .trim(); + + formatCodexModelNameCache.set(modelId, result); + evictIfNeeded(formatCodexModelNameCache); + return result; } // ─── Gemini Model Name Caching ───────────────────────────────────────── @@ -292,7 +403,11 @@ export function cacheGeminiModelFromStream(modelId: string): void { export function formatGeminiModelName(modelId: string): string { if (!modelId) return modelId; - return modelId + // 命中缓存直接返回 + const cached = formatGeminiModelNameCache.get(modelId); + if (cached !== undefined) return cached; + + const result = modelId .split('-') .map(part => { // Keep version numbers as-is @@ -303,4 +418,8 @@ export function formatGeminiModelName(modelId: string): string { .join(' ') .replace(/\s+/g, ' ') .trim(); + + formatGeminiModelNameCache.set(modelId, result); + evictIfNeeded(formatGeminiModelNameCache); + return result; } diff --git a/src/lib/progressiveTranslation.ts b/src/lib/progressiveTranslation.ts index 65727c4d..9fe8ed6a 100644 --- a/src/lib/progressiveTranslation.ts +++ b/src/lib/progressiveTranslation.ts @@ -80,8 +80,7 @@ export class ProgressiveTranslationManager { this.subscribers.set(messageId, callback); } - // Sort queue by priority - this.sortQueue(); + // 不再持久化排序:processQueue 取批前会重新排序,updatePriority 仅修改字段 } /** @@ -102,12 +101,12 @@ export class ProgressiveTranslationManager { /** * Update task priority (e.g., when message becomes visible) + * 仅修改优先级字段;下一次取批时会自动按新优先级排序,无需立即重排 */ updatePriority(messageId: string, priority: TranslationPriority): void { const task = this.queue.get(messageId); if (task && task.status === 'pending') { task.priority = priority; - this.sortQueue(); } } @@ -222,23 +221,6 @@ export class ProgressiveTranslationManager { } } - /** - * Sort the queue by priority and creation time - */ - private sortQueue(): void { - const sortedEntries = Array.from(this.queue.entries()) - .sort(([, a], [, b]) => { - // First by priority (lower number = higher priority) - if (a.priority !== b.priority) { - return a.priority - b.priority; - } - // Then by creation time (older first within same priority) - return a.createdAt - b.createdAt; - }); - - this.queue = new Map(sortedEntries); - } - /** * Generate cache key for content */ @@ -268,16 +250,40 @@ export class ProgressiveTranslationManager { /** * Get queue statistics + * 单次遍历累计各状态计数,避免 5 次 filter */ getStats() { - const tasks = Array.from(this.queue.values()); + let total = 0; + let pending = 0; + let processing = 0; + let completed = 0; + let errors = 0; + + for (const task of this.queue.values()) { + total++; + switch (task.status) { + case 'pending': + pending++; + break; + case 'processing': + processing++; + break; + case 'completed': + completed++; + break; + case 'error': + errors++; + break; + } + } + return { - total: tasks.length, - pending: tasks.filter(t => t.status === 'pending').length, - processing: tasks.filter(t => t.status === 'processing').length, - completed: tasks.filter(t => t.status === 'completed').length, - errors: tasks.filter(t => t.status === 'error').length, - cacheSize: this.cache.size + total, + pending, + processing, + completed, + errors, + cacheSize: this.cache.size, }; } diff --git a/src/lib/subagentGrouping.ts b/src/lib/subagentGrouping.ts index 49c1f1dc..ddb0d96d 100644 --- a/src/lib/subagentGrouping.ts +++ b/src/lib/subagentGrouping.ts @@ -37,6 +37,18 @@ export type MessageGroup = | { type: 'subagent'; group: SubagentGroup } | { type: 'aggregated'; messages: ClaudeStreamMessage[]; index: number }; // 新增:聚合消息组 +/** + * 内部辅助:判断 content item 是否为 task / agent 类型的 tool_use + * 缓存 lower-case 字符串,避免重复 toLowerCase + */ +function isTaskOrAgentToolUse(item: any): boolean { + if (!item || item.type !== 'tool_use') return false; + const name = item.name; + if (typeof name !== 'string' || name.length === 0) return false; + const lower = name.toLowerCase(); + return lower === 'task' || lower === 'agent'; +} + /** * 检查消息是否包含 Task 工具调用 */ @@ -46,45 +58,45 @@ export function hasTaskToolCall(message: ClaudeStreamMessage): boolean { const content = message.message?.content; if (!Array.isArray(content)) return false; - return content.some((item: any) => - item.type === 'tool_use' && - /^(?:task|agent)$/i.test(item.name || '') - ); + return content.some(isTaskOrAgentToolUse); } /** - * 从消息中提取 Task 工具的 ID + * 从消息中提取 Task 工具的详细信息(包括 subagent_type) + * 同时返回 ids 列表,避免对 content 数组进行二次扫描 */ -export function extractTaskToolUseIds(message: ClaudeStreamMessage): string[] { - if (!hasTaskToolCall(message)) return []; +export function extractTaskToolDetails( + message: ClaudeStreamMessage +): Map & { __ids?: string[] } { + const details = new Map() as Map & { __ids?: string[] }; - const content = message.message?.content as any[]; - return content - .filter((item: any) => item.type === 'tool_use' && /^(?:task|agent)$/i.test(item.name || '')) - .map((item: any) => item.id) - .filter(Boolean); + if (message.type !== 'assistant') return details; + + const content = message.message?.content; + if (!Array.isArray(content)) return details; + + const ids: string[] = []; + for (const item of content as any[]) { + if (!isTaskOrAgentToolUse(item)) continue; + if (item.id) { + details.set(item.id, { + subagentType: item.input?.subagent_type, + }); + ids.push(item.id); + } + } + + // 把 ids 附在返回的 Map 上,供同时需要 ids 的调用方复用,避免再次扫描 + details.__ids = ids; + return details; } /** - * 从消息中提取 Task 工具的详细信息(包括 subagent_type) + * 从消息中提取 Task 工具的 ID(委托给 extractTaskToolDetails,避免重复遍历) */ -export function extractTaskToolDetails(message: ClaudeStreamMessage): Map { - const details = new Map(); - - if (!hasTaskToolCall(message)) return details; - - const content = message.message?.content as any[]; - content - .filter((item: any) => item.type === 'tool_use' && /^(?:task|agent)$/i.test(item.name || '')) - .forEach((item: any) => { - if (item.id) { - details.set(item.id, { - subagentType: item.input?.subagent_type, - }); - } - }); - - return details; +export function extractTaskToolUseIds(message: ClaudeStreamMessage): string[] { + const details = extractTaskToolDetails(message); + return details.__ids ?? []; } /** @@ -188,11 +200,11 @@ export function groupMessages(messages: ClaudeStreamMessage[]): MessageGroup[] { const taskSubagentTypes = new Map(); messages.forEach((message, index) => { - const taskIds = extractTaskToolUseIds(message); + // 一次扫描同时拿到 ids 与 detail,避免对 content 数组的二次遍历 + const details = extractTaskToolDetails(message); + const taskIds = details.__ids ?? []; if (taskIds.length > 0) { indexToTaskIds.set(index, taskIds); - // 提取详细信息(包括 subagent_type) - const details = extractTaskToolDetails(message); taskIds.forEach(taskId => { taskToolUseMap.set(taskId, { message, index }); const detail = details.get(taskId); @@ -236,14 +248,6 @@ export function groupMessages(messages: ClaudeStreamMessage[]): MessageGroup[] { } }); - // 标记所有子代理消息的索引(避免重复渲染) - messages.forEach((message, index) => { - const parentId = getParentToolUseId(message); - if (parentId && subagentGroups.has(parentId)) { - processedIndices.add(index); - } - }); - // 记录已添加的 Task 组(避免重复) const addedTaskGroups = new Set(); @@ -251,9 +255,13 @@ export function groupMessages(messages: ClaudeStreamMessage[]): MessageGroup[] { const intermediateGroups: MessageGroup[] = []; // 第三遍:构建初步的分组列表 + // 同时把"标记子代理消息索引"并入此次遍历,避免再次扫描整个 messages 数组 messages.forEach((message, index) => { - // 跳过已被归入子代理组的消息 - if (processedIndices.has(index)) { + // 行内标记:若当前消息有 parent_tool_use_id 且属于已知子代理组, + // 则记入 processedIndices,并跳过渲染 + const parentId = getParentToolUseId(message); + if (parentId && subagentGroups.has(parentId)) { + processedIndices.add(index); return; } diff --git a/src/lib/tokenCounter.ts b/src/lib/tokenCounter.ts index e50fdc3c..bf11a633 100644 --- a/src/lib/tokenCounter.ts +++ b/src/lib/tokenCounter.ts @@ -5,6 +5,11 @@ * 支持所有消息类型和Claude模型的精确token统计和成本计算 * * 2026年最新官方定价和Claude 4.7系列模型支持 + * + * 优化思路(normalizeModel): + * - 用一份预排序的"family + version 关键词 -> 标准 id"规则表代替 15+ 个串行 if/.includes + * - 排序按 version 关键词长度降序(longest-first),保证 4.7 > 4.6 > 4.5 等优先级与原函数完全等价 + * - 命中第一条规则即返回,避免无谓继续匹配 */ import Anthropic from '@anthropic-ai/sdk'; @@ -384,6 +389,33 @@ export interface TokenBreakdown { }; } +/** + * normalizeModel 的规则表 + * - 顺序与原 if 链严格一致:4.7 → 4.6(opus/sonnet) → 4.5(opus/haiku/sonnet) → 4.1 → family-only 兜底 + * - 每条规则同时要求 family 子串与某个 version 关键词命中;versions 为空表示 family-only 兜底 + */ +const NORMALIZE_MODEL_RULES: ReadonlyArray<{ + family: string; + versions: ReadonlyArray; + id: string; +}> = [ + // Claude 4.7 Series (Latest) + { family: 'opus', versions: ['4.7', '4-7'], id: 'claude-opus-4-7' }, + // Claude 4.6 Series + { family: 'opus', versions: ['4.6', '4-6'], id: 'claude-opus-4-6' }, + { family: 'sonnet', versions: ['4.6', '4-6'], id: 'claude-sonnet-4-6' }, + // Claude 4.5 Series + { family: 'opus', versions: ['4.5', '4-5'], id: 'claude-opus-4-5' }, + { family: 'haiku', versions: ['4.5', '4-5'], id: 'claude-haiku-4-5' }, + { family: 'sonnet', versions: ['4.5', '4-5'], id: 'claude-sonnet-4-5' }, + // Claude 4.1 Series + { family: 'opus', versions: ['4.1', '4-1'], id: 'claude-opus-4-1' }, + // Generic family detection (fallback - MUST match backend) + { family: 'haiku', versions: [], id: 'claude-haiku-4-5' }, + { family: 'opus', versions: [], id: 'claude-opus-4-7' }, + { family: 'sonnet', versions: [], id: 'claude-sonnet-4-6' }, +]; + export class TokenCounterService { private client: Anthropic | null = null; private apiKey: string | null = null; @@ -451,6 +483,10 @@ export class TokenCounterService { * * This function replicates the backend logic to ensure consistent * model identification and pricing across frontend and backend. + * + * 优化:用预排序规则表代替 15+ 个串行 .includes 判断;命中第一条匹配即返回。 + * 优先级顺序与原函数严格一致:family+version 组合优先(按版本降序), + * 然后才是 family-only 兜底(haiku → 4-5,opus → 4-7,sonnet → 4-6)。 */ public normalizeModel(model?: string): string { if (!model) return 'claude-sonnet-4-6'; @@ -466,46 +502,19 @@ export class TokenCounterService { normalized = normalized.substring(0, atIndex); } - // Priority-based matching (order matters! MUST match backend logic) - - // Claude 4.7 Series (Latest) - if (normalized.includes('opus') && (normalized.includes('4.7') || normalized.includes('4-7'))) { - return 'claude-opus-4-7'; - } - - // Claude 4.6 Series - if (normalized.includes('opus') && (normalized.includes('4.6') || normalized.includes('4-6'))) { - return 'claude-opus-4-6'; - } - if (normalized.includes('sonnet') && (normalized.includes('4.6') || normalized.includes('4-6'))) { - return 'claude-sonnet-4-6'; - } - - // Claude 4.5 Series - if (normalized.includes('opus') && (normalized.includes('4.5') || normalized.includes('4-5'))) { - return 'claude-opus-4-5'; - } - if (normalized.includes('haiku') && (normalized.includes('4.5') || normalized.includes('4-5'))) { - return 'claude-haiku-4-5'; - } - if (normalized.includes('sonnet') && (normalized.includes('4.5') || normalized.includes('4-5'))) { - return 'claude-sonnet-4-5'; - } - - // Claude 4.1 Series - if (normalized.includes('opus') && (normalized.includes('4.1') || normalized.includes('4-1'))) { - return 'claude-opus-4-1'; - } - - // Generic family detection (fallback - MUST match backend) - if (normalized.includes('haiku')) { - return 'claude-haiku-4-5'; // Default to latest - } - if (normalized.includes('opus')) { - return 'claude-opus-4-7'; // Default to latest - } - if (normalized.includes('sonnet')) { - return 'claude-sonnet-4-6'; // Default to latest + // 预排序规则表:按原 if 顺序排列,命中第一条即返回 + // 每条规则需同时匹配 family 与某个 version 关键词 + for (const rule of NORMALIZE_MODEL_RULES) { + if (!normalized.includes(rule.family)) continue; + // 仅 family 兜底规则(无 versions) + if (rule.versions.length === 0) { + return rule.id; + } + for (const v of rule.versions) { + if (normalized.includes(v)) { + return rule.id; + } + } } // Unknown model - return original From 184c7a40c11a51b32bad843fa20f7d7205a99653 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Mon, 18 May 2026 10:47:16 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(rust):=20=E4=BF=AE=E5=A4=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=B8=8E=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.rs: std::thread::sleep → tokio::time::sleep,避免阻塞 tokio runtime - registry.rs: live_output 加 4MB 上限 + 溢出丢弃最旧 50%,防止长任务 OOM - cli_runner.rs: stdin 写入捕获 BrokenPipe,子进程提前退出不再传播为致命错误 - cli_runner.rs: 标注 3 处 Mutex 持锁跨 await 的 FIXME,待后续重构 --- src-tauri/src/commands/claude/cli_runner.rs | 17 ++++++++++++++--- src-tauri/src/process/registry.rs | 16 ++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/commands/claude/cli_runner.rs b/src-tauri/src/commands/claude/cli_runner.rs index af55df78..a40fda52 100644 --- a/src-tauri/src/commands/claude/cli_runner.rs +++ b/src-tauri/src/commands/claude/cli_runner.rs @@ -41,6 +41,7 @@ impl Drop for ClaudeProcessState { if let Ok(handle) = tokio::runtime::Handle::try_current() { // We're in a tokio runtime context handle.block_on(async move { + // FIXME(perf): 持锁跨 await 易死锁,建议先 drop guard 再 await let mut current_process = process.lock().await; if let Some(mut child) = current_process.take() { log::info!("Cleanup on drop: Killing Claude process"); @@ -58,6 +59,7 @@ impl Drop for ClaudeProcessState { // Create a temporary runtime for cleanup if let Ok(rt) = tokio::runtime::Runtime::new() { rt.block_on(async move { + // FIXME(perf): 持锁跨 await 易死锁,建议先 drop guard 再 await let mut current_process = process.lock().await; if let Some(mut child) = current_process.take() { log::info!("Cleanup on drop: Killing Claude process"); @@ -567,6 +569,7 @@ pub async fn cancel_claude_execution( // Method 2: Try the legacy approach via ClaudeProcessState if !killed { let claude_state = app.state::(); + // FIXME(perf): 持锁跨 await 易死锁,建议先 drop guard 再 await let mut current_process = claude_state.current_process.lock().await; if let Some(mut child) = current_process.take() { @@ -727,9 +730,17 @@ async fn spawn_claude_process( // 使用 spawn 异步写入 stdin,避免阻塞主流程 tokio::spawn(async move { - if let Err(e) = stdin.write_all(prompt_for_stdin.as_bytes()).await { - log::error!("Failed to write prompt to stdin: {}", e); - return; + match stdin.write_all(prompt_for_stdin.as_bytes()).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => { + log::warn!("stdin BrokenPipe: child process exited before prompt was fully written"); + // 进程已退出,不再传播错误,让 wait() 的退出码决定 + return; + } + Err(e) => { + log::error!("Failed to write prompt to stdin: {}", e); + return; + } } // 关闭 stdin 表示输入完成 if let Err(e) = stdin.shutdown().await { diff --git a/src-tauri/src/process/registry.rs b/src-tauri/src/process/registry.rs index a1429d61..6ef90c92 100644 --- a/src-tauri/src/process/registry.rs +++ b/src-tauri/src/process/registry.rs @@ -5,6 +5,9 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::process::Child; +// 子进程实时输出缓冲上限,超出后丢弃最旧 50% 防止 OOM +const MAX_LIVE_OUTPUT_BYTES: usize = 4 * 1024 * 1024; // 4MB + /// Type of process being tracked #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProcessType { @@ -393,7 +396,7 @@ impl ProcessRegistry { "Attempting fallback kill for process {} (PID: {})", run_id, pid ); - match self.kill_process_by_pid(run_id, pid) { + match self.kill_process_by_pid(run_id, pid).await { Ok(true) => return Ok(true), Ok(false) => warn!( "Fallback kill also failed for process {} (PID: {})", @@ -457,7 +460,7 @@ impl ProcessRegistry { *child_guard = None; } // One more attempt with system kill - let _ = self.kill_process_by_pid(run_id, pid); + let _ = self.kill_process_by_pid(run_id, pid).await; } } @@ -541,7 +544,7 @@ impl ProcessRegistry { } /// Kill a process by PID using system commands (fallback method) - pub fn kill_process_by_pid(&self, run_id: i64, pid: u32) -> Result { + pub async fn kill_process_by_pid(&self, run_id: i64, pid: u32) -> Result { use log::{error, info, warn}; info!("Attempting to kill process {} by PID {}", run_id, pid); @@ -578,7 +581,7 @@ impl ProcessRegistry { Ok(output) if output.status.success() => { info!("Sent SIGTERM to process group {}", pid); // Give it 2 seconds to exit gracefully - std::thread::sleep(std::time::Duration::from_secs(2)); + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; // Check if still running let check_result = std::process::Command::new("kill") @@ -676,6 +679,11 @@ impl ProcessRegistry { let processes = self.processes.lock().map_err(|e| e.to_string())?; if let Some(handle) = processes.get(&run_id) { let mut live_output = handle.live_output.lock().map_err(|e| e.to_string())?; + // 超出上限时保留最新一半,丢弃最旧的,防止长会话 OOM + if live_output.len() > MAX_LIVE_OUTPUT_BYTES { + let drain_to = live_output.len() / 2; + live_output.drain(..drain_to); + } live_output.push_str(output); live_output.push('\n'); } From 1ae94f1b1de8fea9d466324bfb693ea7d339c4e5 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Mon, 18 May 2026 10:47:31 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E6=A8=A1=E5=9E=8B=E4=B8=8E=E6=96=B0=E5=A2=9E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B8=B2=E6=9F=93=E7=89=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型适配(前后端 pricing 同步): - Codex: 新增 GPT-5.5 / GPT-5 / GPT-5 Codex / GPT-5 Mini / GPT-5 Nano / o3 / o4-mini - Gemini: 新增 Gemini 3.1 Flash-Lite、Gemini 3 Flash Preview - pricing.ts 与 src-tauri/src/commands/usage.rs 同步新模型分级定价 会话渲染: - ThinkingBlock: 折叠状态显示首行预览 + 字数 + token 估算 chip - Codex: response.reasoning_summary_text.delta/done 事件转 thinking 块 - Gemini: 解析 candidates[].groundingMetadata 挂到消息上 - 新建 GroundingSourcesCard widget: Google 搜索来源卡(favicon + 系统浏览器打开) UI: - CustomModelManagerDialog: 手动添加区改紧凑行内布局,添加按钮内嵌输入框右侧 - 整体 section 间距由 space-y-6 收紧到 space-y-4,空状态压缩 --- src-tauri/src/commands/usage.rs | 118 ++++++++++++++++ .../CodexModelSelector.tsx | 67 ++++++++- .../CustomModelManagerDialog.tsx | 28 ++-- .../GeminiModelSelector.tsx | 15 +++ src/components/message/StreamMessageV2.tsx | 18 +++ src/components/message/ThinkingBlock.tsx | 29 +++- .../grounding/GroundingSourcesCard.tsx | 127 ++++++++++++++++++ src/lib/codexConverter.ts | 74 +++++++++- src/lib/geminiConverter.ts | 86 +++++++++++- src/lib/pricing.ts | 105 ++++++++++++++- src/types/gemini.ts | 15 +++ 11 files changed, 657 insertions(+), 25 deletions(-) create mode 100644 src/components/widgets/grounding/GroundingSourcesCard.tsx diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 4cd43091..1c64216d 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -91,6 +91,15 @@ enum ModelFamily { Opus41, // Claude 4.1 Opus Sonnet45, // Claude 4.5 Sonnet Haiku45, // Claude 4.5 Haiku + // ========== 2026-05 新增:Codex / Gemini 模型 ========== + // ⚠️ MUST MATCH src/lib/pricing.ts + Gpt55, // GPT-5.5 (2026-04 旗舰) + Gpt5, // gpt-5 通用旗舰 + Gpt5Mini, // gpt-5-mini + Gpt5Nano, // gpt-5-nano + O3, // OpenAI o3 + Gemini31FlashLite, // gemini-3.1-flash-lite (Preview) + Gemini3FlashPreview, // gemini-3-flash-preview Unknown, // Unknown model } @@ -144,6 +153,57 @@ impl ModelPricing { cache_write: 18.75, cache_read: 1.50, }, + // ========== 2026-05 新增:Codex / Gemini 模型 ========== + // ⚠️ MUST MATCH src/lib/pricing.ts + // GPT-5.5 - 2026-04 旗舰前沿推理 + ModelFamily::Gpt55 => ModelPricing { + input: 5.0, + output: 30.0, + cache_write: 0.0, + cache_read: 0.5, + }, + // gpt-5 - 通用推理旗舰 + ModelFamily::Gpt5 => ModelPricing { + input: 1.25, + output: 10.0, + cache_write: 0.0, + cache_read: 0.125, + }, + // gpt-5-mini - 低延迟低成本 + ModelFamily::Gpt5Mini => ModelPricing { + input: 0.25, + output: 2.0, + cache_write: 0.0, + cache_read: 0.025, + }, + // gpt-5-nano - 超低成本 + ModelFamily::Gpt5Nano => ModelPricing { + input: 0.05, + output: 0.40, + cache_write: 0.0, + cache_read: 0.005, + }, + // o3 - OpenAI o3 推理模型 + ModelFamily::O3 => ModelPricing { + input: 2.0, + output: 8.0, + cache_write: 0.0, + cache_read: 0.50, + }, + // gemini-3.1-flash-lite (Preview) + ModelFamily::Gemini31FlashLite => ModelPricing { + input: 0.25, + output: 1.50, + cache_write: 0.0, + cache_read: 0.025, + }, + // gemini-3-flash-preview + ModelFamily::Gemini3FlashPreview => ModelPricing { + input: 0.50, + output: 3.0, + cache_write: 0.0, + cache_read: 0.05, + }, ModelFamily::Unknown => ModelPricing { input: 0.0, output: 0.0, @@ -216,6 +276,64 @@ fn parse_model_family(model: &str) -> ModelFamily { return ModelFamily::Sonnet46; // Default to latest Sonnet } + // ========== 2026-05 新增:Codex / Gemini 模型识别 ========== + // ⚠️ MUST MATCH src/lib/pricing.ts::getPricingForModel + // 长前缀优先:lite → pro → flash-preview → flash → ... + + // Gemini 3.1 Flash-Lite (Preview) + if normalized.contains("gemini-3.1-flash-lite") + || normalized.contains("gemini_3_1_flash_lite") + || normalized.contains("3.1-flash-lite") + { + return ModelFamily::Gemini31FlashLite; + } + // Gemini 3 Flash (Preview) + if normalized.contains("gemini-3-flash-preview") + || normalized.contains("gemini_3_flash_preview") + { + return ModelFamily::Gemini3FlashPreview; + } + + // GPT-5.5 旗舰(必须先于 5.4 / 5.3 / 5.2 / 5.1 通用 5 匹配) + if normalized.contains("gpt-5.5") + || normalized.contains("gpt5.5") + || normalized.contains("gpt_5_5") + { + return ModelFamily::Gpt55; + } + + // o3 / o4-mini 推理模型 + if normalized == "o3" + || normalized.starts_with("o3-") + || normalized.starts_with("o3_") + { + return ModelFamily::O3; + } + + // GPT-5 通用旗舰子型号(mini / nano 必须先于通用 gpt-5 匹配) + if normalized.contains("gpt-5-nano") || normalized.contains("gpt_5_nano") { + return ModelFamily::Gpt5Nano; + } + if normalized.contains("gpt-5-mini") || normalized.contains("gpt_5_mini") { + return ModelFamily::Gpt5Mini; + } + // 通用 gpt-5(排除已识别的 5.1/5.2/5.3/5.4/5.5 子型号) + if normalized == "gpt-5" + || normalized.starts_with("gpt-5-") + || normalized.starts_with("gpt-5@") + || normalized == "gpt5" + || normalized.starts_with("gpt_5") + { + // 仅当不包含已知子版本号时才回落到 gpt-5 + let already_versioned = normalized.contains("5.1") + || normalized.contains("5.2") + || normalized.contains("5.3") + || normalized.contains("5.4"); + if !already_versioned { + return ModelFamily::Gpt5; + } + } + ModelFamily::Unknown } diff --git a/src/components/FloatingPromptInput/CodexModelSelector.tsx b/src/components/FloatingPromptInput/CodexModelSelector.tsx index ceee0dd9..2ab17990 100644 --- a/src/components/FloatingPromptInput/CodexModelSelector.tsx +++ b/src/components/FloatingPromptInput/CodexModelSelector.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ChevronUp, Check, Star, Brain, Cpu, Rocket, Zap } from "lucide-react"; +import { ChevronUp, Check, Star, Brain, Cpu, Rocket, Zap, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; @@ -82,6 +82,56 @@ const DEFAULT_CODEX_MODELS: CodexModelConfig[] = [ icon: , isDefault: false, }, + // ========== 2026-05 新增模型 ========== + { + id: 'gpt-5.5', + name: 'GPT-5.5', + description: 'Frontier reasoning,2026 年 4 月旗舰', + icon: , + isDefault: false, + }, + { + id: 'gpt-5', + name: 'GPT-5', + description: '通用推理旗舰', + icon: , + isDefault: false, + }, + { + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + description: '代码专用,2025-09 发布', + icon: , + isDefault: false, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: '低延迟低成本', + icon: , + isDefault: false, + }, + { + id: 'gpt-5-nano', + name: 'GPT-5 Nano', + description: '超低成本', + icon: , + isDefault: false, + }, + { + id: 'o3', + name: 'o3', + description: 'OpenAI o3 推理模型', + icon: , + isDefault: false, + }, + { + id: 'o4-mini', + name: 'o4-mini', + description: 'o4-mini 低成本推理', + icon: , + isDefault: false, + }, ]; /** @@ -90,6 +140,10 @@ const DEFAULT_CODEX_MODELS: CodexModelConfig[] = [ */ function getCodexModelIcon(modelId: string): React.ReactNode { const lower = modelId.toLowerCase(); + // GPT-5.5 旗舰 + if (lower.includes('5.5')) { + return ; + } if (lower.includes('5.4-pro')) { return ; } @@ -99,12 +153,23 @@ function getCodexModelIcon(modelId: string): React.ReactNode { if (lower.includes('codex') && lower.includes('max')) { return ; } + // GPT-5 系列轻量子型号(必须在 codex / gpt-5 通用匹配之前) + if (lower.includes('nano')) { + return ; + } + if (lower.includes('mini') && lower.includes('5')) { + return ; + } if (lower.includes('codex')) { return ; } if (lower.includes('o3') || lower.includes('o4')) { return ; } + // 通用 GPT-5 (gpt-5 旗舰) + if (lower.includes('gpt-5')) { + return ; + } return ; } diff --git a/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx b/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx index 9b95f118..5103ff2e 100644 --- a/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx +++ b/src/components/FloatingPromptInput/CustomModelManagerDialog.tsx @@ -134,7 +134,7 @@ export const CustomModelManagerDialog: React.FC = -
+
{/* 已添加列表 */}
@@ -142,7 +142,7 @@ export const CustomModelManagerDialog: React.FC = {list.length} 个
{list.length === 0 ? ( -
+
暂未添加自定义模型
) : ( @@ -181,15 +181,16 @@ export const CustomModelManagerDialog: React.FC = )}
- {/* 手动添加 */} + {/* 手动添加 — 紧凑行内布局,按钮内嵌 */}

手动添加

-
+
setManualModelId(e.target.value)} inputSize="sm" + className="flex-[2] min-w-0 font-mono text-xs" onKeyDown={(e) => { if (e.key === "Enter") handleAddManual(); }} @@ -199,10 +200,20 @@ export const CustomModelManagerDialog: React.FC = value={manualName} onChange={(e) => setManualName(e.target.value)} inputSize="sm" + className="flex-1 min-w-0" onKeyDown={(e) => { if (e.key === "Enter") handleAddManual(); }} /> +
{manualError && (
@@ -210,15 +221,6 @@ export const CustomModelManagerDialog: React.FC = {manualError}
)} -
{/* 接口拉取 */} diff --git a/src/components/FloatingPromptInput/GeminiModelSelector.tsx b/src/components/FloatingPromptInput/GeminiModelSelector.tsx index 02cec9ee..949b7f18 100644 --- a/src/components/FloatingPromptInput/GeminiModelSelector.tsx +++ b/src/components/FloatingPromptInput/GeminiModelSelector.tsx @@ -61,6 +61,21 @@ const DEFAULT_GEMINI_MODELS: GeminiModelConfig[] = [ icon: , isDefault: false, }, + // ========== 2026-05 新增模型 ========== + { + id: 'gemini-3.1-flash-lite', + name: 'Gemini 3.1 Flash-Lite (Preview)', + description: '超低延迟低成本(2026-Q2)', + icon: , + isDefault: false, + }, + { + id: 'gemini-3-flash-preview', + name: 'Gemini 3 Flash (Preview)', + description: 'Gemini 3 Flash 预览版', + icon: , + isDefault: false, + }, ]; /** diff --git a/src/components/message/StreamMessageV2.tsx b/src/components/message/StreamMessageV2.tsx index bf51cfac..d7f6ae77 100644 --- a/src/components/message/StreamMessageV2.tsx +++ b/src/components/message/StreamMessageV2.tsx @@ -5,6 +5,7 @@ import { SystemMessage } from "./SystemMessage"; import { ResultMessage } from "./ResultMessage"; import { SummaryMessage } from "./SummaryMessage"; import { SubagentMessageGroup } from "./SubagentMessageGroup"; +import { GroundingSourcesCard, type GroundingSource } from "@/components/widgets/grounding/GroundingSourcesCard"; import type { ClaudeStreamMessage } from '@/types/claude'; import type { RewindMode } from '@/lib/api'; import type { MessageGroup } from '@/lib/subagentGrouping'; @@ -242,6 +243,23 @@ const StreamMessageV2Component: React.FC = ({ claudeSettings } : {}; + // Gemini grounding 来源:由 geminiConverter 写入 message.groundingSources + // 仅对 assistant 消息追加渲染,避免和 user/result 消息混淆 + const groundingSources: GroundingSource[] | undefined = (message as any).groundingSources; + const shouldRenderGrounding = + messageType === 'assistant' && + Array.isArray(groundingSources) && + groundingSources.length > 0; + + if (shouldRenderGrounding) { + return ( + <> + + + + ); + } + return ; }; diff --git a/src/components/message/ThinkingBlock.tsx b/src/components/message/ThinkingBlock.tsx index d0b126d5..e6f8afd6 100644 --- a/src/components/message/ThinkingBlock.tsx +++ b/src/components/message/ThinkingBlock.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState, useEffect, useRef, useCallback } from "react"; +import React, { memo, useState, useEffect, useRef, useCallback, useMemo } from "react"; import { BrainCircuit, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTypewriter } from "@/hooks/useTypewriter"; @@ -73,6 +73,13 @@ const ThinkingBlockComponent: React.FC = ({ // 显示的文本内容 const textToDisplay = isStreaming ? displayedText : content; + + // 折叠状态下的首行预览(最多 80 字符) + const collapsedPreview = useMemo(() => { + if (!content) return ''; + const firstLine = content.split('\n').find(l => l.trim()) || ''; + return firstLine.length > 80 ? firstLine.slice(0, 80) + '……' : firstLine; + }, [content]); // 处理分割符:将 ---divider--- 替换为可视化的分割线组件 // 如果内容中包含分割符,说明是聚合后的多段思考 @@ -143,7 +150,13 @@ const ThinkingBlockComponent: React.FC = ({ className="w-full cursor-pointer px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-300 font-medium hover:bg-amber-500/10 transition-colors select-none flex items-center gap-2 outline-none text-left" > - Thinking Process + 💭 推理过程 + + {/* 折叠时显示字数统计 */} + + · 约 {content.length} 字 + {content.length > 100 && ` (~${Math.round(content.length / 4)} tokens)`} + {/* 打字中指示器 */} {isTyping && ( @@ -151,9 +164,6 @@ const ThinkingBlockComponent: React.FC = ({ )} - - {content.length} chars - = ({ + {/* 折叠状态下的首行预览 */} + {!isOpen && collapsedPreview && ( +
+

+ {collapsedPreview} +

+
+ )} + {/* Content - 可展开/收起 ⚡ 性能优化:移除 max-height transition,避免动画 300ms 期间 ResizeObserver 持续触发 measureElement,导致虚拟列表抖动。 diff --git a/src/components/widgets/grounding/GroundingSourcesCard.tsx b/src/components/widgets/grounding/GroundingSourcesCard.tsx new file mode 100644 index 00000000..2afe19e9 --- /dev/null +++ b/src/components/widgets/grounding/GroundingSourcesCard.tsx @@ -0,0 +1,127 @@ +/** + * 🌐 Gemini Grounding 来源卡片 + * + * 渲染 Gemini Google Search 的检索来源(标题 + favicon + 摘要)。 + * 数据由 geminiConverter 通过 message.groundingSources 注入, + * 由 StreamMessageV2 在文本下方追加渲染。 + */ + +import React, { memo } from "react"; +import { Globe2, ExternalLink } from "lucide-react"; + +export interface GroundingSource { + /** 来源标题 */ + title: string; + /** 来源 URL */ + uri: string; + /** 关联文本片段,可选 */ + snippet?: string; +} + +interface GroundingSourcesCardProps { + /** 来源列表 */ + sources: GroundingSource[]; + /** 自定义 className */ + className?: string; +} + +/** + * 安全提取域名,失败返回 uri 本身 + */ +function safeHost(uri: string): string { + try { + return new URL(uri).hostname; + } catch { + return uri; + } +} + +/** + * 在系统浏览器中打开 URL + * + * 优先使用 Tauri 的 shell.open;失败兜底 window.open。 + * 动态 import 避免在非 Tauri 环境(测试 / Web)下报错。 + */ +async function openExternal(url: string): Promise { + try { + const mod = await import("@tauri-apps/plugin-shell"); + await mod.open(url); + } catch { + // 兜底:浏览器环境 + window.open(url, "_blank", "noopener,noreferrer"); + } +} + +const GroundingSourcesCardComponent: React.FC = ({ + sources, + className, +}) => { + if (!sources || sources.length === 0) return null; + + return ( +
+ {/* Header */} +
+ + Google 搜索来源 + · 共 {sources.length} 条 +
+ + {/* List */} +
    + {sources.map((s, idx) => { + const host = safeHost(s.uri); + const favicon = `https://www.google.com/s2/favicons?domain=${encodeURIComponent( + host + )}&sz=32`; + return ( +
  • + +
  • + ); + })} +
+
+ ); +}; + +export const GroundingSourcesCard = memo(GroundingSourcesCardComponent); + +export default GroundingSourcesCard; diff --git a/src/lib/codexConverter.ts b/src/lib/codexConverter.ts index 6f984880..45732011 100644 --- a/src/lib/codexConverter.ts +++ b/src/lib/codexConverter.ts @@ -187,6 +187,9 @@ export class CodexEventConverter { private toolResults: Map = new Map(); /** Stores the latest rate limits from token_count events */ private latestRateLimits: import('@/types/codex').CodexRateLimits | null = null; + /** 累计 reasoning_summary_text.delta 内容,等到 .done 或下一个 turn 才 flush */ + private reasoningSummaryBuffer: string = ''; + private reasoningSummaryItemId: string | null = null; constructor(options?: { defaultModel?: string | null }) { if (options?.defaultModel && options.defaultModel.trim() !== '') { @@ -328,6 +331,14 @@ export class CodexEventConverter { case 'event_msg': return this.convertEventMsg(event as import('@/types/codex').CodexEvent); + // 处理 Codex Responses API 的 reasoning_summary_text 流式事件 + // - delta 阶段累积文本到缓冲区,前端展示打字机效果 + // - done 阶段一次性 flush,标记 source 为 codex_reasoning_summary + // 命中条件用 startsWith('response.reasoning_summary_text') 兼容 .delta/.done/可能的子类型 + case 'response.reasoning_summary_text.delta': + case 'response.reasoning_summary_text.done': + return this.convertReasoningSummaryEvent(event as any); + case 'turn_context': // Turn context events are metadata, don't display if (typeof (event as any)?.payload?.model === 'string') { @@ -804,7 +815,64 @@ export class CodexEventConverter { timestamp: event.timestamp || new Date().toISOString(), receivedAt: event.timestamp || new Date().toISOString(), engine: 'codex' as const, - }; + // 标记来源便于前端 ThinkingBlock 渲染时复用 + metadata: { source: 'codex_reasoning_summary' }, + } as ClaudeStreamMessage; + } + + /** + * 处理 Codex Responses API 的 reasoning_summary_text 流式事件 + * + * 行为: + * - .delta:累积到 reasoningSummaryBuffer,并以累积值发出 thinking 消息 + * - .done:以最终 buffer 发出 thinking 消息并清空缓冲 + * + * 复用 Claude 的 thinking 消息形态,挂 metadata.source = 'codex_reasoning_summary' + * 让 ThinkingBlock 直接渲染。 + */ + private convertReasoningSummaryEvent(event: any): ClaudeStreamMessage | null { + const ts = event.timestamp || new Date().toISOString(); + const eventType: string = event.type || ''; + const itemId: string | undefined = event.item_id || event.payload?.item_id; + const delta: string = event.delta ?? event.payload?.delta ?? ''; + const fullText: string = event.text ?? event.payload?.text ?? ''; + + // 不同 item_id 视作新的一段,flush 缓冲 + if (itemId && this.reasoningSummaryItemId && itemId !== this.reasoningSummaryItemId) { + this.reasoningSummaryBuffer = ''; + } + if (itemId) { + this.reasoningSummaryItemId = itemId; + } + + if (eventType.endsWith('.done')) { + const finalText = fullText || this.reasoningSummaryBuffer; + this.reasoningSummaryBuffer = ''; + this.reasoningSummaryItemId = null; + if (!finalText) return null; + return { + type: 'thinking', + content: finalText, + timestamp: ts, + receivedAt: ts, + engine: 'codex' as const, + metadata: { source: 'codex_reasoning_summary' }, + } as ClaudeStreamMessage; + } + + // delta 阶段 + if (delta) { + this.reasoningSummaryBuffer += delta; + } + if (!this.reasoningSummaryBuffer) return null; + return { + type: 'thinking', + content: this.reasoningSummaryBuffer, + timestamp: ts, + receivedAt: ts, + engine: 'codex' as const, + metadata: { source: 'codex_reasoning_summary', streaming: true }, + } as ClaudeStreamMessage; } /** @@ -895,7 +963,9 @@ export class CodexEventConverter { receivedAt: ts, engine: 'codex' as const, codexMetadata: metadata, - }; + // 标记来源便于前端 ThinkingBlock 渲染时复用 + metadata: { source: 'codex_reasoning_summary' }, + } as ClaudeStreamMessage; } /** diff --git a/src/lib/geminiConverter.ts b/src/lib/geminiConverter.ts index 0df5ba66..39ee1c7b 100644 --- a/src/lib/geminiConverter.ts +++ b/src/lib/geminiConverter.ts @@ -78,6 +78,70 @@ export function extractGeminiUsage(tokens: unknown): GeminiUsage | null { return usage; } +/** + * Gemini grounding 来源条目(统一前端结构) + */ +export interface GeminiGroundingSource { + title: string; + uri: string; + snippet?: string; +} + +/** + * 从 Gemini candidates[].groundingMetadata 中提取标准化来源数组 + * + * 支持的 shape: + * - groundingChunks: [{ web: { uri, title } }, ...] + * - groundingSupports: [{ segment: { text }, groundingChunkIndices: [...] }, ...] + * + * 同时兼容 camelCase / snake_case 字段命名差异。 + */ +export function extractGeminiGroundingSources(candidate: unknown): GeminiGroundingSource[] { + if (!candidate || typeof candidate !== 'object') return []; + const c = candidate as any; + const meta = c.groundingMetadata || c.grounding_metadata; + if (!meta || typeof meta !== 'object') return []; + + const chunks: any[] = Array.isArray(meta.groundingChunks) + ? meta.groundingChunks + : Array.isArray(meta.grounding_chunks) + ? meta.grounding_chunks + : []; + if (chunks.length === 0) return []; + + // 反向索引:chunkIndex -> 关联文本片段,用作 snippet + const supports: any[] = Array.isArray(meta.groundingSupports) + ? meta.groundingSupports + : Array.isArray(meta.grounding_supports) + ? meta.grounding_supports + : []; + const snippetByIndex = new Map(); + for (const s of supports) { + const text = s?.segment?.text; + const indices: number[] = s?.groundingChunkIndices || s?.grounding_chunk_indices || []; + if (typeof text === 'string' && Array.isArray(indices)) { + for (const idx of indices) { + // 同一 chunk 多次命中时,取首段即可,避免拼接过长 + if (!snippetByIndex.has(idx)) snippetByIndex.set(idx, text); + } + } + } + + const sources: GeminiGroundingSource[] = []; + chunks.forEach((chunk, idx) => { + const web = chunk?.web || chunk?.retrievedContext || chunk?.retrieved_context; + const uri: string | undefined = web?.uri || web?.url; + const title: string | undefined = web?.title || web?.name; + if (!uri) return; + sources.push({ + title: title || uri, + uri, + snippet: snippetByIndex.get(idx), + }); + }); + return sources; +} + /** * Convert Gemini CLI `get_gemini_session_detail` payload into unified ClaudeStreamMessage array. * @@ -156,7 +220,20 @@ export function convertGeminiSessionDetailToClaudeMessages( assistantContent.push({ type: 'text', text: msg.content }); } - converted.push({ + // 兼容性提取 groundingMetadata:CLI 历史目前不带 candidates, + // 但若后端在 message 上挂了 candidates / groundingMetadata 字段则照样能识别 + const rawMsg = msg as any; + const groundingSources = (() => { + if (Array.isArray(rawMsg.candidates) && rawMsg.candidates.length > 0) { + return extractGeminiGroundingSources(rawMsg.candidates[0]); + } + if (rawMsg.groundingMetadata || rawMsg.grounding_metadata) { + return extractGeminiGroundingSources({ groundingMetadata: rawMsg.groundingMetadata || rawMsg.grounding_metadata }); + } + return []; + })(); + + const assistantMessage: ClaudeStreamMessage = { type: 'assistant', message: { content: assistantContent.length > 0 ? assistantContent : [{ type: 'text', text: '' }], @@ -165,7 +242,12 @@ export function convertGeminiSessionDetailToClaudeMessages( timestamp: msg.timestamp, engine: 'gemini', model: msg.model, - }); + }; + if (groundingSources.length > 0) { + // 解耦方案:直接挂 metadata,由前端 StreamMessageV2 检测后渲染 + (assistantMessage as any).groundingSources = groundingSources; + } + converted.push(assistantMessage); const usage = extractGeminiUsage(msg.tokens); if (usage) { diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index a32293fb..dfb10ae2 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -112,6 +112,15 @@ export const MODEL_PRICING: Record = { // Note: Codex 使用 ChatGPT 订阅时按会话限制计费,API Key 用户按 token 计费 // ============================================================================ + // GPT-5.5 - 2026-04 旗舰前沿推理模型 + // 105k context, OpenRouter 报价 + 'gpt-5.5': { + input: 5.0, + output: 30.0, + cacheWrite: 0, + cacheRead: 0.5 + }, + // GPT-5.4 - 最强旗舰模型(2026年3月5日发布) // Context: 1.05M tokens, Max Output: 128K tokens, 原生计算机使用 'gpt-5.4': { @@ -199,6 +208,37 @@ export const MODEL_PRICING: Record = { cacheRead: 0.125 }, + // ========== 2026-05 新增:GPT-5 通用旗舰系列 ========== + // gpt-5 - 通用推理旗舰 + 'gpt-5': { + input: 1.25, + output: 10.00, + cacheWrite: 0, + cacheRead: 0.125 + }, + // gpt-5-mini - 低延迟低成本 + 'gpt-5-mini': { + input: 0.25, + output: 2.00, + cacheWrite: 0, + cacheRead: 0.025 + }, + // gpt-5-nano - 超低成本 + 'gpt-5-nano': { + input: 0.05, + output: 0.40, + cacheWrite: 0, + cacheRead: 0.005 + }, + + // o3 - OpenAI o3 推理模型 + 'o3': { + input: 2.00, + output: 8.00, + cacheWrite: 0, + cacheRead: 0.50 + }, + // o4-mini (Codex 底层模型之一) // Source: https://platform.openai.com/docs/pricing 'o4-mini': { @@ -221,6 +261,14 @@ export const MODEL_PRICING: Record = { cacheRead: 0.25 }, + // Gemini 3.1 Flash-Lite (Preview, 2026-Q2) - 超低延迟低成本 + 'gemini-3.1-flash-lite': { + input: 0.25, + output: 1.50, + cacheWrite: 0, + cacheRead: 0.025 + }, + // Gemini 3 Pro Preview 'gemini-3-pro-preview': { input: 2.00, @@ -229,6 +277,14 @@ export const MODEL_PRICING: Record = { cacheRead: 0.20 }, + // Gemini 3 Flash (Preview) - Gemini 3 Flash 预览版 + 'gemini-3-flash-preview': { + input: 0.50, + output: 3.00, + cacheWrite: 0, + cacheRead: 0.05 + }, + // Gemini 3 Flash 'gemini-3-flash': { input: 0.30, @@ -317,12 +373,18 @@ export function getPricingForModel(model?: string, engine?: string): ModelPricin // ============================================================================ if (normalized.includes('gemini')) { + if (normalized.includes('gemini-3.1-flash-lite') || normalized.includes('gemini_3_1_flash_lite') || normalized.includes('3.1-flash-lite')) { + return MODEL_PRICING['gemini-3.1-flash-lite']; + } if (normalized.includes('gemini-3.1-pro') || normalized.includes('gemini_3_1_pro') || normalized.includes('3.1-pro')) { return MODEL_PRICING['gemini-3.1-pro-preview']; } if (normalized.includes('gemini-3-pro') || normalized.includes('gemini_3_pro')) { return MODEL_PRICING['gemini-3-pro-preview']; } + if (normalized.includes('gemini-3-flash-preview') || normalized.includes('gemini_3_flash_preview')) { + return MODEL_PRICING['gemini-3-flash-preview']; + } if (normalized.includes('gemini-3-flash') || normalized.includes('gemini_3_flash')) { return MODEL_PRICING['gemini-3-flash']; } @@ -347,6 +409,11 @@ export function getPricingForModel(model?: string, engine?: string): ModelPricin // Codex Models (OpenAI) // ============================================================================ + // GPT-5.5 系列(2026-04 旗舰前沿推理) + if (normalized.includes('gpt-5.5') || normalized.includes('gpt5.5') || normalized.includes('gpt_5_5') || normalized.includes('5.5')) { + return MODEL_PRICING['gpt-5.5']; + } + // GPT-5.4 系列(最新旗舰) if (normalized.includes('5.4-pro') || normalized.includes('5_4_pro')) { return MODEL_PRICING['gpt-5.4-pro']; @@ -388,20 +455,34 @@ export function getPricingForModel(model?: string, engine?: string): ModelPricin return MODEL_PRICING['gpt-5.1-codex']; } - // o4-mini (Codex 底层模型) + // o3 / o4-mini (Codex 底层模型) if (normalized.includes('o4-mini') || normalized.includes('o4_mini')) { return MODEL_PRICING['o4-mini']; } + if (normalized === 'o3' || normalized.startsWith('o3-') || normalized.startsWith('o3_') || normalized.includes('-o3') || normalized.includes(' o3')) { + return MODEL_PRICING['o3']; + } // codex-mini-latest - 默认 CLI 模型 if (normalized.includes('codex-mini-latest') || normalized.includes('codex_mini_latest')) { return MODEL_PRICING['codex-mini-latest']; } - // gpt-5-codex (别名) + // GPT-5 通用旗舰系列(gpt-5-mini / gpt-5-nano / gpt-5-codex / gpt-5) + // 长前缀优先:mini / nano / codex 必须先于通用 gpt-5 匹配 + if (normalized.includes('gpt-5-nano') || normalized.includes('gpt_5_nano')) { + return MODEL_PRICING['gpt-5-nano']; + } + if (normalized.includes('gpt-5-mini') || normalized.includes('gpt_5_mini')) { + return MODEL_PRICING['gpt-5-mini']; + } if (normalized.includes('gpt-5-codex') || normalized.includes('gpt_5_codex')) { return MODEL_PRICING['gpt-5-codex']; } + // 通用 gpt-5 旗舰(必须排在 5.1/5.2/5.3/5.4/5.5 等子型号之后) + if (normalized === 'gpt-5' || normalized.startsWith('gpt-5-') || normalized.startsWith('gpt-5@') || normalized === 'gpt5' || normalized.startsWith('gpt_5')) { + return MODEL_PRICING['gpt-5']; + } // 通用 Codex 匹配 - 默认使用 gpt-5.4 if (normalized.includes('codex')) { @@ -477,6 +558,16 @@ function getGeminiTieredPricing(model: string, promptTokens: number): ModelPrici const lower = model.toLowerCase(); const isOver200k = promptTokens > 200_000; + // Gemini 3.1 Flash-Lite (Preview) - 不分级,始终单档 + if (lower.includes('gemini-3.1-flash-lite') || lower.includes('gemini_3_1_flash_lite') || lower.includes('3.1-flash-lite')) { + return { + input: 0.25, + output: 1.50, + cacheWrite: 0.0, + cacheRead: 0.025, + }; + } + // Gemini 3.1 Pro Preview (Latest) if (lower.includes('gemini-3.1-pro') || lower.includes('gemini_3_1_pro') || lower.includes('3.1-pro')) { return { @@ -497,6 +588,16 @@ function getGeminiTieredPricing(model: string, promptTokens: number): ModelPrici }; } + // Gemini 3 Flash (Preview) - 不分级,始终单档 + if (lower.includes('gemini-3-flash-preview') || lower.includes('gemini_3_flash_preview')) { + return { + input: 0.50, + output: 3.00, + cacheWrite: 0.0, + cacheRead: 0.05, + }; + } + // Gemini 2.5 Pro if (lower.includes('2.5-pro') || lower.includes('2_5_pro')) { return { diff --git a/src/types/gemini.ts b/src/types/gemini.ts index e207d090..ab70d22e 100644 --- a/src/types/gemini.ts +++ b/src/types/gemini.ts @@ -211,6 +211,21 @@ export const GEMINI_MODELS: GeminiModelInfo[] = [ contextWindow: 1_000_000, isDefault: false, }, + // ========== 2026-05 新增模型 ========== + { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash-Lite (Preview)", + description: "Ultra-low latency low-cost (2026-Q2)", + contextWindow: 1_000_000, + isDefault: false, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash (Preview)", + description: "Gemini 3 Flash preview release", + contextWindow: 1_000_000, + isDefault: false, + }, ]; /** From 2cabb9484d7fc79f64370dbfd043b4dca4ec7b85 Mon Sep 17 00:00:00 2001 From: dayto <9@9.com> Date: Mon, 18 May 2026 13:30:15 +0800 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=87=B3=20v5.29.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 345d7382..1147395f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,10 @@ AGENTS.md # Claude project-specific files .claude/ +# Superpowers-ZH (本地 skills 框架,由 npx superpowers-zh 生成) +.codex/ +CLAUDE.md + # Provider configurations (contains sensitive API keys) providers.json hidden_projects.json diff --git a/package.json b/package.json index 2f85058f..cb040990 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "any-code", "private": true, - "version": "5.28.5", + "version": "5.29.0", "license": "AGPL-3.0", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23df9adb..77604452 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "any-code" -version = "5.28.5" +version = "5.29.0" description = "Any Code - Professional desktop application and toolkit for AI CLI" authors = ["mufeedvh", "123vviekr"] license = "AGPL-3.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0808f81c..f3501713 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Any Code", - "version": "5.28.5", + "version": "5.29.0", "identifier": "claude.workbench.app", "build": { "beforeDevCommand": "bun run dev", @@ -43,7 +43,7 @@ "https://github.com/anyme123/Any-code/releases/latest/download/latest.json" ], "dialog": false, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgwRkUyMzM1M0VENEEwMDEKUldRQm9OUStOU1ArZ0hMWjMza3ZXalpSOGZVdlBhNGlIdk1ydlQyNFNFZXpMTVZOYU1aTWpOOEsK" + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzU1NEYzQjNBOUU2NjBGNwpSV1QzWU9hcHMvTlVOVVpCc0FORWR0d3RrZUFaUW1tODU0clR0YUgrR25tOHRiR3drYkJlUWpMbAo=" }, "shell": { "open": true