From 5017002938162352c34f51e370e18763d6f641c5 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 3 Apr 2026 23:10:17 +0800 Subject: [PATCH 001/128] feat: add auto-fetch models from provider's /v1/models endpoint Add ability to fetch available models from third-party aggregation providers (SiliconFlow, OpenRouter, etc.) via OpenAI-compatible GET /v1/models endpoint. Users can click "Fetch Models" button in the provider form, then select models from a dropdown on each model input field. - Backend: new model_fetch service + Tauri command (Rust) - Frontend: ModelInputWithFetch shared component - Integrated into all 5 app forms (Claude/Codex/Gemini/OpenCode/OpenClaw) - i18n support for zh/en/ja --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/model_fetch.rs | 18 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/model_fetch.rs | 179 ++++++++++++++++++ .../providers/forms/ClaudeFormFields.tsx | 65 ++++++- .../providers/forms/CodexFormFields.tsx | 71 +++++-- .../providers/forms/GeminiFormFields.tsx | 67 ++++++- .../providers/forms/OpenClawFormFields.tsx | 152 +++++++++++++-- .../providers/forms/OpenCodeFormFields.tsx | 150 +++++++++++++-- .../forms/shared/ModelInputWithFetch.tsx | 146 ++++++++++++++ .../providers/forms/shared/index.ts | 1 + src/i18n/locales/en.json | 7 +- src/i18n/locales/ja.json | 7 +- src/i18n/locales/zh.json | 7 +- src/lib/api/model-fetch.ts | 20 ++ 16 files changed, 825 insertions(+), 70 deletions(-) create mode 100644 src-tauri/src/commands/model_fetch.rs create mode 100644 src-tauri/src/services/model_fetch.rs create mode 100644 src/components/providers/forms/shared/ModelInputWithFetch.tsx create mode 100644 src/lib/api/model-fetch.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 04bb6da3a..a751fb54a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod global_proxy; mod import_export; mod mcp; mod misc; +mod model_fetch; mod omo; mod openclaw; mod plugin; @@ -37,6 +38,7 @@ pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; +pub use model_fetch::*; pub use omo::*; pub use openclaw::*; pub use plugin::*; diff --git a/src-tauri/src/commands/model_fetch.rs b/src-tauri/src/commands/model_fetch.rs new file mode 100644 index 000000000..f152da002 --- /dev/null +++ b/src-tauri/src/commands/model_fetch.rs @@ -0,0 +1,18 @@ +//! 模型列表获取命令 +//! +//! 提供 Tauri 命令,供前端在供应商表单中获取可用模型列表。 + +use crate::services::model_fetch::{self, FetchedModel}; + +/// 获取供应商的可用模型列表 +/// +/// 使用 OpenAI 兼容的 GET /v1/models 端点。 +/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。 +#[tauri::command(rename_all = "camelCase")] +pub async fn fetch_models_for_config( + base_url: String, + api_key: String, + is_full_url: Option, +) -> Result, String> { + model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1be714b44..cf06fa896 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -906,6 +906,8 @@ pub fn run() { commands::enable_prompt, commands::import_prompt_from_file, commands::get_current_prompt_file_content, + // model list fetch (OpenAI-compatible /v1/models) + commands::fetch_models_for_config, // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, commands::get_custom_endpoints, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c13d2880b..e84de582e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod env_checker; pub mod env_manager; pub mod mcp; +pub mod model_fetch; pub mod omo; pub mod prompt; pub mod provider; diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs new file mode 100644 index 000000000..25c192867 --- /dev/null +++ b/src-tauri/src/services/model_fetch.rs @@ -0,0 +1,179 @@ +//! 模型列表获取服务 +//! +//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。 +//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。 + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// 获取到的模型信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchedModel { + pub id: String, + pub owned_by: Option, +} + +/// OpenAI 兼容的 /v1/models 响应格式 +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + id: String, + owned_by: Option, +} + +const FETCH_TIMEOUT_SECS: u64 = 15; + +/// 获取供应商的可用模型列表 +/// +/// 使用 OpenAI 兼容的 GET /v1/models 端点。 +pub async fn fetch_models( + base_url: &str, + api_key: &str, + is_full_url: bool, +) -> Result, String> { + if api_key.is_empty() { + return Err("API Key is required to fetch models".to_string()); + } + + let models_url = build_models_url(base_url, is_full_url)?; + let client = crate::proxy::http_client::get_for_provider(None); + + let response = client + .get(&models_url) + .header("Authorization", format!("Bearer {api_key}")) + .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + let resp: ModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + let mut models: Vec = resp + .data + .unwrap_or_default() + .into_iter() + .map(|m| FetchedModel { + id: m.id, + owned_by: m.owned_by, + }) + .collect(); + + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) +} + +/// 构造 /v1/models 的完整 URL +fn build_models_url(base_url: &str, is_full_url: bool) -> Result { + let trimmed = base_url.trim().trim_end_matches('/'); + + if trimmed.is_empty() { + return Err("Base URL is empty".to_string()); + } + + if is_full_url { + // 尝试从完整端点 URL 推导 API 根路径 + // 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models + if let Some(idx) = trimmed.find("/v1/") { + return Ok(format!("{}/v1/models", &trimmed[..idx])); + } + // 如果没有 /v1/ 路径,直接去掉最后一段路径 + if let Some(idx) = trimmed.rfind('/') { + let root = &trimmed[..idx]; + if root.contains("://") && root.len() > root.find("://").unwrap() + 3 { + return Ok(format!("{root}/v1/models")); + } + } + return Err("Cannot derive models endpoint from full URL".to_string()); + } + + // 常规情况: base_url 是 API 根路径 + // 如果已经包含 /v1 路径,直接追加 /models + if trimmed.ends_with("/v1") { + return Ok(format!("{trimmed}/models")); + } + + Ok(format!("{trimmed}/v1/models")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_models_url_basic() { + assert_eq!( + build_models_url("https://api.siliconflow.cn", false).unwrap(), + "https://api.siliconflow.cn/v1/models" + ); + } + + #[test] + fn test_build_models_url_trailing_slash() { + assert_eq!( + build_models_url("https://api.example.com/", false).unwrap(), + "https://api.example.com/v1/models" + ); + } + + #[test] + fn test_build_models_url_with_v1() { + assert_eq!( + build_models_url("https://api.example.com/v1", false).unwrap(), + "https://api.example.com/v1/models" + ); + } + + #[test] + fn test_build_models_url_full_url() { + assert_eq!( + build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(), + "https://proxy.example.com/v1/models" + ); + } + + #[test] + fn test_build_models_url_empty() { + assert!(build_models_url("", false).is_err()); + } + + #[test] + fn test_parse_response() { + let json = r#"{"object":"list","data":[{"id":"gpt-4","object":"model","owned_by":"openai"},{"id":"claude-3-sonnet","object":"model","owned_by":"anthropic"}]}"#; + let resp: ModelsResponse = serde_json::from_str(json).unwrap(); + let data = resp.data.unwrap(); + assert_eq!(data.len(), 2); + assert_eq!(data[0].id, "gpt-4"); + assert_eq!(data[0].owned_by.as_deref(), Some("openai")); + assert_eq!(data[1].id, "claude-3-sonnet"); + } + + #[test] + fn test_parse_response_no_owned_by() { + let json = r#"{"object":"list","data":[{"id":"my-model","object":"model"}]}"#; + let resp: ModelsResponse = serde_json::from_str(json).unwrap(); + let data = resp.data.unwrap(); + assert_eq!(data[0].id, "my-model"); + assert!(data[0].owned_by.is_none()); + } + + #[test] + fn test_parse_response_empty_data() { + let json = r#"{"object":"list","data":[]}"#; + let resp: ModelsResponse = serde_json::from_str(json).unwrap(); + assert!(resp.data.unwrap().is_empty()); + } +} diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index bdcfb8ebb..c916de3ac 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -24,15 +24,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; -import { ApiKeySection, EndpointField } from "./shared"; +import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; import { CopilotAuthSection } from "./CopilotAuthSection"; import { copilotGetModels, copilotGetModelsForAccount, } from "@/lib/api/copilot"; import type { CopilotModel } from "@/lib/api/copilot"; +import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; import type { ProviderCategory, ClaudeApiFormat, @@ -179,6 +180,34 @@ export function ClaudeFormFields({ const [copilotModels, setCopilotModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); + // 通用模型获取(非 Copilot 供应商) + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + + const handleFetchModels = useCallback(() => { + if (!baseUrl || !apiKey) { + toast.error(t("providerForm.fetchModelsFailed")); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(baseUrl, apiKey, isFullUrl) + .then((models) => { + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + toast.error(t("providerForm.fetchModelsFailed")); + }) + .finally(() => setIsFetchingModels(false)); + }, [baseUrl, apiKey, isFullUrl, t]); + // 当 Copilot 预设且已认证时,加载可用模型 useEffect(() => { // 如果不是 Copilot 预设或未认证,清空模型列表 @@ -298,14 +327,15 @@ export function ClaudeFormFields({ ); } + // 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁) return ( - onModelChange(field, e.target.value)} + onChange={(v) => onModelChange(field, v)} placeholder={placeholder} - autoComplete="off" + fetchedModels={fetchedModels} + isLoading={isFetchingModels} /> ); }; @@ -502,7 +532,26 @@ export function ClaudeFormFields({ {/* 模型映射 */}
- {t("providerForm.modelMappingLabel")} +
+ {t("providerForm.modelMappingLabel")} + {!isCopilotPreset && ( + + )} +

{t("providerForm.modelMappingHint")}

diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 38434fc69..dea13df71 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -1,6 +1,11 @@ +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Download, Loader2 } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; -import { ApiKeySection, EndpointField } from "./shared"; +import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; +import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; import type { ProviderCategory } from "@/types"; interface EndpointCandidate { @@ -65,6 +70,33 @@ export function CodexFormFields({ }: CodexFormFieldsProps) { const { t } = useTranslation(); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + + const handleFetchModels = useCallback(() => { + if (!codexBaseUrl || !codexApiKey) { + toast.error(t("providerForm.fetchModelsFailed")); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl) + .then((models) => { + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + toast.error(t("providerForm.fetchModelsFailed")); + }) + .finally(() => setIsFetchingModels(false)); + }, [codexBaseUrl, codexApiKey, isFullUrl, t]); + return ( <> {/* Codex API Key 输入框 */} @@ -107,21 +139,38 @@ export function CodexFormFields({ {/* Codex Model Name 输入框 */} {shouldShowModelField && onModelNameChange && (
- - + + +
+ onModelNameChange(e.target.value)} + onChange={(v) => onModelNameChange!(v)} placeholder={t("codexConfig.modelNamePlaceholder", { defaultValue: "例如: gpt-5.4", })} - className="w-full px-3 py-2 border border-border-default bg-background text-foreground rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors" + fetchedModels={fetchedModels} + isLoading={isFetchingModels} />

{modelName.trim() diff --git a/src/components/providers/forms/GeminiFormFields.tsx b/src/components/providers/forms/GeminiFormFields.tsx index b0400ec66..293b974f8 100644 --- a/src/components/providers/forms/GeminiFormFields.tsx +++ b/src/components/providers/forms/GeminiFormFields.tsx @@ -1,9 +1,12 @@ +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Info } from "lucide-react"; +import { Download, Info, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import EndpointSpeedTest from "./EndpointSpeedTest"; -import { ApiKeySection, EndpointField } from "./shared"; +import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; +import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; import type { ProviderCategory } from "@/types"; interface EndpointCandidate { @@ -66,6 +69,33 @@ export function GeminiFormFields({ }: GeminiFormFieldsProps) { const { t } = useTranslation(); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + + const handleFetchModels = useCallback(() => { + if (!baseUrl || !apiKey) { + toast.error(t("providerForm.fetchModelsFailed")); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(baseUrl, apiKey) + .then((models) => { + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + toast.error(t("providerForm.fetchModelsFailed")); + }) + .finally(() => setIsFetchingModels(false)); + }, [baseUrl, apiKey, t]); + // 检测是否为 Google 官方(使用 OAuth) const isGoogleOfficial = partnerPromotionKey?.toLowerCase() === "google-official"; @@ -123,15 +153,34 @@ export function GeminiFormFields({ {/* Model 输入框 */} {shouldShowModelField && ( -

- - {t("provider.form.gemini.model", { defaultValue: "模型" })} - - +
+ + {t("provider.form.gemini.model", { defaultValue: "模型" })} + + +
+ onModelChange(e.target.value)} + onChange={onModelChange} placeholder="gemini-3-pro-preview" + fetchedModels={fetchedModels} + isLoading={isFetchingModels} />
)} diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 314f7024a..4291a73f5 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -16,9 +16,26 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { toast } from "sonner"; +import { + Download, + Plus, + Trash2, + ChevronDown, + ChevronRight, + Loader2, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { ApiKeySection } from "./shared"; +import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; import { openclawApiProtocols } from "@/config/openclawProviderPresets"; import type { ProviderCategory, OpenClawModel } from "@/types"; @@ -70,6 +87,8 @@ export function OpenClawFormFields({ const [expandedModels, setExpandedModels] = useState>( {}, ); + const [fetchedModels, setFetchedModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); // Stable key tracking for models list const modelKeysRef = useRef([]); @@ -107,6 +126,31 @@ export function OpenClawFormFields({ ]); }; + // Fetch models from API + const handleFetchModels = useCallback(() => { + if (!baseUrl || !apiKey) { + toast.error(t("providerForm.fetchModelsFailed")); + return; + } + setIsFetchingModels(true); + fetchModelsForConfig(baseUrl, apiKey) + .then((models) => { + setFetchedModels(models); + if (models.length === 0) { + toast.info(t("providerForm.fetchModelsEmpty")); + } else { + toast.success( + t("providerForm.fetchModelsSuccess", { count: models.length }), + ); + } + }) + .catch((err) => { + console.warn("[ModelFetch] Failed:", err); + toast.error(t("providerForm.fetchModelsFailed")); + }) + .finally(() => setIsFetchingModels(false)); + }, [baseUrl, apiKey, t]); + // Remove a model entry const handleRemoveModel = (index: number) => { modelKeysRef.current.splice(index, 1); @@ -234,16 +278,33 @@ export function OpenClawFormFields({ {t("openclaw.models", { defaultValue: "模型列表" })} - +
+ + +
{models.length === 0 ? ( @@ -283,15 +344,66 @@ export function OpenClawFormFields({ - - handleModelChange(index, "id", e.target.value) - } - placeholder={t("openclaw.modelIdPlaceholder", { - defaultValue: "claude-3-sonnet", - })} - /> +
+ + handleModelChange(index, "id", e.target.value) + } + placeholder={t("openclaw.modelIdPlaceholder", { + defaultValue: "claude-3-sonnet", + })} + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + + + + + {Object.entries( + fetchedModels.reduce( + (acc, m) => { + const v = m.ownedBy || "Other"; + if (!acc[v]) acc[v] = []; + acc[v].push(m); + return acc; + }, + {} as Record, + ), + ) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([vendor, vModels], vi) => ( +
+ {vi > 0 && } + + {vendor} + + {vModels.map((m) => ( + + handleModelChange(index, "id", m.id) + } + > + {m.id} + + ))} +
+ ))} +
+
+ )} +
{Object.keys(models).length === 0 ? ( @@ -600,13 +704,21 @@ export function OpenCodeFormFields({ )} /> - handleModelIdChange(key, newId)} - placeholder={t("opencode.modelId", { - defaultValue: "Model ID", - })} - /> +
+ handleModelIdChange(key, newId)} + placeholder={t("opencode.modelId", { + defaultValue: "Model ID", + })} + /> + {fetchedModels.length > 0 && ( + handleModelIdChange(key, id)} + /> + )} +
handleModelNameChange(key, e.target.value)} diff --git a/src/components/providers/forms/shared/ModelInputWithFetch.tsx b/src/components/providers/forms/shared/ModelInputWithFetch.tsx new file mode 100644 index 000000000..2cba10d16 --- /dev/null +++ b/src/components/providers/forms/shared/ModelInputWithFetch.tsx @@ -0,0 +1,146 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDown, Download, Loader2 } from "lucide-react"; +import type { FetchedModel } from "@/lib/api/model-fetch"; + +interface ModelInputWithFetchProps { + id: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + fetchedModels: FetchedModel[]; + isLoading: boolean; + /** 传入时显示获取按钮;不传时只在有数据后显示下拉 */ + onFetch?: () => void; +} + +export function ModelInputWithFetch({ + id, + value, + onChange, + placeholder, + fetchedModels, + isLoading, + onFetch, +}: ModelInputWithFetchProps) { + const { t } = useTranslation(); + + // 有模型数据: Input + DropdownMenu + if (fetchedModels.length > 0) { + const grouped: Record = {}; + for (const model of fetchedModels) { + const vendor = model.ownedBy || "Other"; + if (!grouped[vendor]) grouped[vendor] = []; + grouped[vendor].push(model); + } + const vendors = Object.keys(grouped).sort(); + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + + + + + + {vendors.map((vendor, vi) => ( +
+ {vi > 0 && } + {vendor} + {grouped[vendor].map((model) => ( + onChange(model.id)} + > + {model.id} + + ))} +
+ ))} +
+
+
+ ); + } + + // 加载中: Input + Spinner + if (isLoading) { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + +
+ ); + } + + // 有 onFetch: Input + 获取按钮 + if (onFetch) { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + +
+ ); + } + + // 无 onFetch: 纯 Input + return ( + onChange(e.target.value)} + placeholder={placeholder} + autoComplete="off" + /> + ); +} diff --git a/src/components/providers/forms/shared/index.ts b/src/components/providers/forms/shared/index.ts index f9b85fb3a..e5be59876 100644 --- a/src/components/providers/forms/shared/index.ts +++ b/src/components/providers/forms/shared/index.ts @@ -1,2 +1,3 @@ export { ApiKeySection } from "./ApiKeySection"; export { EndpointField } from "./EndpointField"; +export { ModelInputWithFetch } from "./ModelInputWithFetch"; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 228b474e3..ccd174bd5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -820,7 +820,12 @@ "retry": "Retry", "copyCode": "Copy code", "migrationFailed": "Legacy auth migration failed: {{error}}", - "loadModelsFailed": "Failed to load Copilot models" + "loadModelsFailed": "Failed to load Copilot models", + "fetchModels": "Fetch Models", + "fetchingModels": "Fetching...", + "fetchModelsSuccess": "Found {{count}} models", + "fetchModelsFailed": "Failed to fetch models", + "fetchModelsEmpty": "No models found" }, "endpointTest": { "title": "API Endpoint Management", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bf3189a0c..d59da46bf 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -820,7 +820,12 @@ "retry": "再試行", "copyCode": "コードをコピー", "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", - "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" + "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました", + "fetchModels": "モデル一覧を取得", + "fetchingModels": "取得中...", + "fetchModelsSuccess": "{{count}}件のモデルを取得", + "fetchModelsFailed": "モデル一覧の取得に失敗しました", + "fetchModelsEmpty": "モデルが見つかりません" }, "endpointTest": { "title": "API エンドポイント管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 88b6ce9ad..596b405a9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -820,7 +820,12 @@ "retry": "重试", "copyCode": "复制代码", "migrationFailed": "旧认证数据迁移失败:{{error}}", - "loadModelsFailed": "加载 Copilot 模型列表失败" + "loadModelsFailed": "加载 Copilot 模型列表失败", + "fetchModels": "获取模型列表", + "fetchingModels": "正在获取...", + "fetchModelsSuccess": "获取到 {{count}} 个模型", + "fetchModelsFailed": "获取模型列表失败", + "fetchModelsEmpty": "未找到可用模型" }, "endpointTest": { "title": "请求地址管理", diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts new file mode 100644 index 000000000..c63add582 --- /dev/null +++ b/src/lib/api/model-fetch.ts @@ -0,0 +1,20 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface FetchedModel { + id: string; + ownedBy: string | null; +} + +/** + * 从供应商获取可用模型列表 + * + * 使用 OpenAI 兼容的 GET /v1/models 端点。 + * 主要面向第三方聚合站(硅基流动、OpenRouter 等)。 + */ +export async function fetchModelsForConfig( + baseUrl: string, + apiKey: string, + isFullUrl?: boolean, +): Promise { + return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl }); +} From f200feebe49658c792b0b5bfc5cb7160de19b3d5 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 3 Apr 2026 23:32:46 +0800 Subject: [PATCH 002/128] fix(i18n): move fetchModels keys from copilot to providerForm namespace --- src/i18n/locales/en.json | 14 +++++++------- src/i18n/locales/ja.json | 14 +++++++------- src/i18n/locales/zh.json | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ccd174bd5..be231601c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -790,7 +790,12 @@ "categoryOfficial": "Official", "categoryCnOfficial": "Opensource Official", "categoryAggregation": "Aggregation", - "categoryThirdParty": "Third Party" + "categoryThirdParty": "Third Party", + "fetchModels": "Fetch Models", + "fetchingModels": "Fetching...", + "fetchModelsSuccess": "Found {{count}} models", + "fetchModelsFailed": "Failed to fetch models", + "fetchModelsEmpty": "No models found" }, "copilot": { "authSection": "GitHub Copilot Authentication", @@ -820,12 +825,7 @@ "retry": "Retry", "copyCode": "Copy code", "migrationFailed": "Legacy auth migration failed: {{error}}", - "loadModelsFailed": "Failed to load Copilot models", - "fetchModels": "Fetch Models", - "fetchingModels": "Fetching...", - "fetchModelsSuccess": "Found {{count}} models", - "fetchModelsFailed": "Failed to fetch models", - "fetchModelsEmpty": "No models found" + "loadModelsFailed": "Failed to load Copilot models" }, "endpointTest": { "title": "API Endpoint Management", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d59da46bf..d8aac311f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -790,7 +790,12 @@ "categoryOfficial": "公式", "categoryCnOfficial": "オープンソース公式", "categoryAggregation": "アグリゲーター", - "categoryThirdParty": "サードパーティ" + "categoryThirdParty": "サードパーティ", + "fetchModels": "モデル一覧を取得", + "fetchingModels": "取得中...", + "fetchModelsSuccess": "{{count}}件のモデルを取得", + "fetchModelsFailed": "モデル一覧の取得に失敗しました", + "fetchModelsEmpty": "モデルが見つかりません" }, "copilot": { "authSection": "GitHub Copilot 認証", @@ -820,12 +825,7 @@ "retry": "再試行", "copyCode": "コードをコピー", "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", - "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました", - "fetchModels": "モデル一覧を取得", - "fetchingModels": "取得中...", - "fetchModelsSuccess": "{{count}}件のモデルを取得", - "fetchModelsFailed": "モデル一覧の取得に失敗しました", - "fetchModelsEmpty": "モデルが見つかりません" + "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, "endpointTest": { "title": "API エンドポイント管理", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 596b405a9..a92fac2c2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -790,7 +790,12 @@ "categoryOfficial": "官方", "categoryCnOfficial": "开源官方", "categoryAggregation": "聚合服务", - "categoryThirdParty": "第三方" + "categoryThirdParty": "第三方", + "fetchModels": "获取模型列表", + "fetchingModels": "正在获取...", + "fetchModelsSuccess": "获取到 {{count}} 个模型", + "fetchModelsFailed": "获取模型列表失败", + "fetchModelsEmpty": "未找到可用模型" }, "copilot": { "authSection": "GitHub Copilot 认证", @@ -820,12 +825,7 @@ "retry": "重试", "copyCode": "复制代码", "migrationFailed": "旧认证数据迁移失败:{{error}}", - "loadModelsFailed": "加载 Copilot 模型列表失败", - "fetchModels": "获取模型列表", - "fetchingModels": "正在获取...", - "fetchModelsSuccess": "获取到 {{count}} 个模型", - "fetchModelsFailed": "获取模型列表失败", - "fetchModelsEmpty": "未找到可用模型" + "loadModelsFailed": "加载 Copilot 模型列表失败" }, "endpointTest": { "title": "请求地址管理", From 84998aa217a65aee12c331cf5d0fe5b4100cee64 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 3 Apr 2026 23:40:49 +0800 Subject: [PATCH 003/128] feat: differentiate fetch models error messages by failure type Distinguish between missing API key, missing endpoint, auth failure, unsupported provider (404/405), and timeout errors instead of showing a generic failure toast for all cases. --- .../providers/forms/ClaudeFormFields.tsx | 13 +++-- .../providers/forms/CodexFormFields.tsx | 13 +++-- .../providers/forms/GeminiFormFields.tsx | 13 +++-- .../providers/forms/OpenClawFormFields.tsx | 13 +++-- .../providers/forms/OpenCodeFormFields.tsx | 13 +++-- src/i18n/locales/en.json | 8 +++- src/i18n/locales/ja.json | 8 +++- src/i18n/locales/zh.json | 8 +++- src/lib/api/model-fetch.ts | 48 +++++++++++++++++++ 9 files changed, 119 insertions(+), 18 deletions(-) diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index c916de3ac..033a0874c 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -33,7 +33,11 @@ import { copilotGetModelsForAccount, } from "@/lib/api/copilot"; import type { CopilotModel } from "@/lib/api/copilot"; -import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; import type { ProviderCategory, ClaudeApiFormat, @@ -186,7 +190,10 @@ export function ClaudeFormFields({ const handleFetchModels = useCallback(() => { if (!baseUrl || !apiKey) { - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); return; } setIsFetchingModels(true); @@ -203,7 +210,7 @@ export function ClaudeFormFields({ }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [baseUrl, apiKey, isFullUrl, t]); diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index dea13df71..55faec6ca 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -5,7 +5,11 @@ import { toast } from "sonner"; import { Download, Loader2 } from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; -import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; import type { ProviderCategory } from "@/types"; interface EndpointCandidate { @@ -75,7 +79,10 @@ export function CodexFormFields({ const handleFetchModels = useCallback(() => { if (!codexBaseUrl || !codexApiKey) { - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(null, t, { + hasApiKey: !!codexApiKey, + hasBaseUrl: !!codexBaseUrl, + }); return; } setIsFetchingModels(true); @@ -92,7 +99,7 @@ export function CodexFormFields({ }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [codexBaseUrl, codexApiKey, isFullUrl, t]); diff --git a/src/components/providers/forms/GeminiFormFields.tsx b/src/components/providers/forms/GeminiFormFields.tsx index 293b974f8..a01a16c56 100644 --- a/src/components/providers/forms/GeminiFormFields.tsx +++ b/src/components/providers/forms/GeminiFormFields.tsx @@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; -import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; import type { ProviderCategory } from "@/types"; interface EndpointCandidate { @@ -74,7 +78,10 @@ export function GeminiFormFields({ const handleFetchModels = useCallback(() => { if (!baseUrl || !apiKey) { - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); return; } setIsFetchingModels(true); @@ -91,7 +98,7 @@ export function GeminiFormFields({ }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [baseUrl, apiKey, t]); diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 4291a73f5..7253c1697 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -35,7 +35,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { ApiKeySection } from "./shared"; -import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; import { openclawApiProtocols } from "@/config/openclawProviderPresets"; import type { ProviderCategory, OpenClawModel } from "@/types"; @@ -129,7 +133,10 @@ export function OpenClawFormFields({ // Fetch models from API const handleFetchModels = useCallback(() => { if (!baseUrl || !apiKey) { - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); return; } setIsFetchingModels(true); @@ -146,7 +153,7 @@ export function OpenClawFormFields({ }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [baseUrl, apiKey, t]); diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 024b7baae..717b166d0 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -28,7 +28,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ApiKeySection } from "./shared"; -import { fetchModelsForConfig, type FetchedModel } from "@/lib/api/model-fetch"; +import { + fetchModelsForConfig, + showFetchModelsError, + type FetchedModel, +} from "@/lib/api/model-fetch"; import { opencodeNpmPackages } from "@/config/opencodeProviderPresets"; import { cn } from "@/lib/utils"; import { @@ -247,7 +251,10 @@ export function OpenCodeFormFields({ const handleFetchModels = useCallback(() => { if (!baseUrl || !apiKey) { - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(null, t, { + hasApiKey: !!apiKey, + hasBaseUrl: !!baseUrl, + }); return; } setIsFetchingModels(true); @@ -264,7 +271,7 @@ export function OpenCodeFormFields({ }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); - toast.error(t("providerForm.fetchModelsFailed")); + showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [baseUrl, apiKey, t]); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index be231601c..ce540b17a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -795,7 +795,13 @@ "fetchingModels": "Fetching...", "fetchModelsSuccess": "Found {{count}} models", "fetchModelsFailed": "Failed to fetch models", - "fetchModelsEmpty": "No models found" + "fetchModelsEmpty": "No models found", + "fetchModelsNeedApiKey": "Please fill in API Key first", + "fetchModelsNeedEndpoint": "Please fill in API endpoint first", + "fetchModelsNeedConfig": "Please fill in API endpoint and API Key first", + "fetchModelsAuthFailed": "API Key is invalid or lacks permission", + "fetchModelsNotSupported": "This provider does not support fetching model list", + "fetchModelsTimeout": "Request timed out, please check network connection" }, "copilot": { "authSection": "GitHub Copilot Authentication", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d8aac311f..6ddc13dbc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -795,7 +795,13 @@ "fetchingModels": "取得中...", "fetchModelsSuccess": "{{count}}件のモデルを取得", "fetchModelsFailed": "モデル一覧の取得に失敗しました", - "fetchModelsEmpty": "モデルが見つかりません" + "fetchModelsEmpty": "モデルが見つかりません", + "fetchModelsNeedApiKey": "先に API Key を入力してください", + "fetchModelsNeedEndpoint": "先に API エンドポイントを入力してください", + "fetchModelsNeedConfig": "先に API エンドポイントと API Key を入力してください", + "fetchModelsAuthFailed": "API Key が無効か、権限がありません", + "fetchModelsNotSupported": "このプロバイダーはモデル一覧の取得に対応していません", + "fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください" }, "copilot": { "authSection": "GitHub Copilot 認証", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a92fac2c2..61174120b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -795,7 +795,13 @@ "fetchingModels": "正在获取...", "fetchModelsSuccess": "获取到 {{count}} 个模型", "fetchModelsFailed": "获取模型列表失败", - "fetchModelsEmpty": "未找到可用模型" + "fetchModelsEmpty": "未找到可用模型", + "fetchModelsNeedApiKey": "请先填写 API Key", + "fetchModelsNeedEndpoint": "请先填写 API 端点", + "fetchModelsNeedConfig": "请先填写 API 端点和 API Key", + "fetchModelsAuthFailed": "API Key 无效或无权限", + "fetchModelsNotSupported": "该供应商不支持获取模型列表", + "fetchModelsTimeout": "请求超时,请检查网络连接" }, "copilot": { "authSection": "GitHub Copilot 认证", diff --git a/src/lib/api/model-fetch.ts b/src/lib/api/model-fetch.ts index c63add582..2595587d6 100644 --- a/src/lib/api/model-fetch.ts +++ b/src/lib/api/model-fetch.ts @@ -1,4 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; +import type { TFunction } from "i18next"; +import { toast } from "sonner"; export interface FetchedModel { id: string; @@ -18,3 +20,49 @@ export async function fetchModelsForConfig( ): Promise { return invoke("fetch_models_for_config", { baseUrl, apiKey, isFullUrl }); } + +/** + * 根据错误类型显示对应的 toast 提示 + */ +export function showFetchModelsError( + err: unknown, + t: TFunction, + opts?: { hasApiKey: boolean; hasBaseUrl: boolean }, +): void { + // 前端预检:缺少必填字段 + if (opts && !opts.hasBaseUrl && !opts.hasApiKey) { + toast.error(t("providerForm.fetchModelsNeedConfig")); + return; + } + if (opts && !opts.hasApiKey) { + toast.error(t("providerForm.fetchModelsNeedApiKey")); + return; + } + if (opts && !opts.hasBaseUrl) { + toast.error(t("providerForm.fetchModelsNeedEndpoint")); + return; + } + + // 解析后端错误字符串 + const msg = String(err); + + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + toast.error(t("providerForm.fetchModelsAuthFailed")); + return; + } + if (msg.includes("HTTP 404") || msg.includes("HTTP 405")) { + toast.error(t("providerForm.fetchModelsNotSupported")); + return; + } + if (msg.includes("timeout") || msg.includes("timed out")) { + toast.error(t("providerForm.fetchModelsTimeout")); + return; + } + if (msg.includes("Failed to parse")) { + toast.error(t("providerForm.fetchModelsNotSupported")); + return; + } + + // 通用兜底 + toast.error(t("providerForm.fetchModelsFailed")); +} From fe525891d4d0986360fb9aeb28875570dfe3888b Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 3 Apr 2026 23:54:45 +0800 Subject: [PATCH 004/128] feat(tray): collapse providers into submenus to prevent menu overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each app type (Claude/Codex/Gemini) now renders as a submenu instead of flat items, keeping the top-level tray menu compact regardless of provider count. The submenu label shows the current provider name (e.g. "Claude · OpenRouter") for at-a-glance visibility. --- src-tauri/src/tray.rs | 137 ++++++++++++++++-------------------------- 1 file changed, 53 insertions(+), 84 deletions(-) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 6ac3d2046..72f8821ed 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -2,7 +2,7 @@ //! //! 负责系统托盘图标和菜单的创建、更新和事件处理。 -use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem}; +use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::{Emitter, Manager}; use crate::app_config::AppType; @@ -13,7 +13,7 @@ use crate::store::AppState; #[derive(Clone, Copy)] pub struct TrayTexts { pub show_main: &'static str, - pub no_provider_hint: &'static str, + pub no_providers_label: &'static str, pub lightweight_mode: &'static str, pub quit: &'static str, pub _auto_label: &'static str, @@ -24,22 +24,21 @@ impl TrayTexts { match language { "en" => Self { show_main: "Open main window", - no_provider_hint: " (No providers yet, please add them from the main window)", + no_providers_label: "(no providers)", lightweight_mode: "Lightweight Mode", quit: "Quit", _auto_label: "Auto (Failover)", }, "ja" => Self { show_main: "メインウィンドウを開く", - no_provider_hint: - " (プロバイダーがまだありません。メイン画面から追加してください)", + no_providers_label: "(プロバイダーなし)", lightweight_mode: "軽量モード", quit: "終了", _auto_label: "自動 (フェイルオーバー)", }, _ => Self { show_main: "打开主界面", - no_provider_hint: " (无供应商,请在主界面添加)", + no_providers_label: "(无供应商)", lightweight_mode: "轻量模式", quit: "退出", _auto_label: "自动 (故障转移)", @@ -52,7 +51,6 @@ impl TrayTexts { pub struct TrayAppSection { pub app_type: AppType, pub prefix: &'static str, - pub header_id: &'static str, pub empty_id: &'static str, pub header_label: &'static str, pub log_name: &'static str, @@ -65,7 +63,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { app_type: AppType::Claude, prefix: "claude_", - header_id: "claude_header", empty_id: "claude_empty", header_label: "Claude", log_name: "Claude", @@ -73,7 +70,6 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { app_type: AppType::Codex, prefix: "codex_", - header_id: "codex_header", empty_id: "codex_empty", header_label: "Codex", log_name: "Codex", @@ -81,54 +77,18 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [ TrayAppSection { app_type: AppType::Gemini, prefix: "gemini_", - header_id: "gemini_header", empty_id: "gemini_empty", header_label: "Gemini", log_name: "Gemini", }, ]; -/// 添加供应商分区到菜单 -fn append_provider_section<'a>( - app: &'a tauri::AppHandle, - mut menu_builder: MenuBuilder<'a, tauri::Wry, tauri::AppHandle>, - manager: Option<&crate::provider::ProviderManager>, - section: &TrayAppSection, - tray_texts: &TrayTexts, - _app_state: &AppState, -) -> Result>, AppError> { - let Some(manager) = manager else { - return Ok(menu_builder); - }; - - let header = MenuItem::with_id( - app, - section.header_id, - section.header_label, - false, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}标题失败: {e}", section.log_name)))?; - menu_builder = menu_builder.item(&header); - - if manager.providers.is_empty() { - let empty_hint = MenuItem::with_id( - app, - section.empty_id, - tray_texts.no_provider_hint, - false, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)))?; - return Ok(menu_builder.item(&empty_hint)); - } - - // Auto (Failover) menu item is hidden from tray; the feature is still - // accessible from the Settings page. Keep the surrounding code intact so - // it can be re-enabled easily in the future. - - let mut sorted_providers: Vec<_> = manager.providers.iter().collect(); - sorted_providers.sort_by(|(_, a), (_, b)| { +/// 对供应商列表排序:sort_index → created_at → name +fn sort_providers( + providers: &indexmap::IndexMap, +) -> Vec<(&String, &crate::provider::Provider)> { + let mut sorted: Vec<_> = providers.iter().collect(); + sorted.sort_by(|(_, a), (_, b)| { match (a.sort_index, b.sort_index) { (Some(idx_a), Some(idx_b)) => return idx_a.cmp(&idx_b), (Some(_), None) => return std::cmp::Ordering::Less, @@ -145,22 +105,7 @@ fn append_provider_section<'a>( a.name.cmp(&b.name) }); - - for (id, provider) in sorted_providers { - let is_current = manager.current == *id; - let item = CheckMenuItem::with_id( - app, - format!("{}{}", section.prefix, id), - &provider.name, - true, - is_current, - None::<&str>, - ) - .map_err(|e| AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)))?; - menu_builder = menu_builder.item(&item); - } - - Ok(menu_builder) + sorted } /// 处理供应商托盘事件 @@ -352,10 +297,8 @@ pub fn create_tray_menu( .map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?; menu_builder = menu_builder.item(&show_main_item).separator(); - // 直接添加所有供应商到主菜单(扁平化结构,更简单可靠) - // Only add visible app sections + // 每个应用类型折叠为子菜单,避免供应商过多时菜单过长 for section in TRAY_SECTIONS.iter() { - // Skip hidden apps if !visible_apps.is_visible(§ion.app_type) { continue; } @@ -363,26 +306,52 @@ pub fn create_tray_menu( let app_type_str = section.app_type.as_str(); let providers = app_state.db.get_all_providers(app_type_str)?; - // 使用有效的当前供应商 ID(验证存在性,自动清理失效 ID) let current_id = crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)? .unwrap_or_default(); - let manager = crate::provider::ProviderManager { - providers, - current: current_id, - }; + if providers.is_empty() { + // 空供应商:显示禁用的菜单项 + let label = format!("{} {}", section.header_label, tray_texts.no_providers_label); + let empty_item = + MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>) + .map_err(|e| { + AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)) + })?; + menu_builder = menu_builder.item(&empty_item); + } else { + // 有供应商:构建子菜单 + let current_name = providers.get(¤t_id).map(|p| p.name.as_str()); + let submenu_label = match current_name { + Some(name) => format!("{} · {}", section.header_label, name), + None => section.header_label.to_string(), + }; + let submenu_id = format!("submenu_{}", app_type_str); - menu_builder = append_provider_section( - app, - menu_builder, - Some(&manager), - section, - &tray_texts, - app_state, - )?; + let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label); + + for (id, provider) in sort_providers(&providers) { + let is_current = current_id == *id; + let item = CheckMenuItem::with_id( + app, + format!("{}{}", section.prefix, id), + &provider.name, + true, + is_current, + None::<&str>, + ) + .map_err(|e| { + AppError::Message(format!("创建{}菜单项失败: {e}", section.log_name)) + })?; + submenu_builder = submenu_builder.item(&item); + } + + let submenu = submenu_builder.build().map_err(|e| { + AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name)) + })?; + menu_builder = menu_builder.item(&submenu); + } - // 在每个 section 后添加分隔符 menu_builder = menu_builder.separator(); } From d9c0e4c4529923bcdd2f1bb7a2ea7ddba6a9f920 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 12:20:13 +0800 Subject: [PATCH 005/128] docs: update user manual to v3.12.3 with new features coverage (en/zh/ja) Add documentation for features introduced since v3.12.0: New docs: - 3.4 Session Manager: browse, search, resume, delete sessions - 3.5 Workspace & Daily Memory: OpenClaw workspace file editing Updated docs: - Add Lightweight Mode to interface overview and FAQ - Add tray submenu structure (providers grouped by app) - Add API Format selection (Anthropic/OpenAI Chat/OpenAI Responses) - Add Auto-Fetch Models button documentation - Add Claude Common Config quick toggles - Add Codex 1M Context Window toggle - Add Skill backup/restore lifecycle - Expand Backup Management panel documentation - Update WebDAV sync to v2 protocol with dual-layer versioning - Add OpenCode/OpenClaw to quickstart activation table - Update README version to v3.12.3 All changes synced across en, zh, and ja locales. --- .../en/1-getting-started/1.3-interface.md | 19 ++- .../en/1-getting-started/1.4-quickstart.md | 10 ++ .../en/1-getting-started/1.5-settings.md | 67 +++++++-- docs/user-manual/en/2-providers/2.1-add.md | 55 ++++++++ docs/user-manual/en/2-providers/2.2-switch.md | 4 +- docs/user-manual/en/2-providers/2.3-edit.md | 14 ++ .../user-manual/en/3-extensions/3.3-skills.md | 23 ++- .../en/3-extensions/3.4-sessions.md | 131 ++++++++++++++++++ .../en/3-extensions/3.5-workspace.md | 85 ++++++++++++ docs/user-manual/en/4-proxy/4.1-service.md | 14 ++ docs/user-manual/en/5-faq/5.1-config-files.md | 9 +- docs/user-manual/en/5-faq/5.2-questions.md | 10 ++ docs/user-manual/en/README.md | 12 +- .../ja/1-getting-started/1.3-interface.md | 19 ++- .../ja/1-getting-started/1.4-quickstart.md | 12 +- .../ja/1-getting-started/1.5-settings.md | 67 +++++++-- docs/user-manual/ja/2-providers/2.1-add.md | 55 ++++++++ docs/user-manual/ja/2-providers/2.2-switch.md | 4 +- docs/user-manual/ja/2-providers/2.3-edit.md | 14 ++ .../user-manual/ja/3-extensions/3.3-skills.md | 23 ++- .../ja/3-extensions/3.4-sessions.md | 131 ++++++++++++++++++ .../ja/3-extensions/3.5-workspace.md | 85 ++++++++++++ docs/user-manual/ja/4-proxy/4.1-service.md | 14 ++ docs/user-manual/ja/5-faq/5.1-config-files.md | 9 +- docs/user-manual/ja/5-faq/5.2-questions.md | 10 ++ docs/user-manual/ja/README.md | 12 +- .../zh/1-getting-started/1.3-interface.md | 19 ++- .../zh/1-getting-started/1.4-quickstart.md | 12 +- .../zh/1-getting-started/1.5-settings.md | 81 ++++++++--- docs/user-manual/zh/2-providers/2.1-add.md | 55 ++++++++ docs/user-manual/zh/2-providers/2.2-switch.md | 6 +- docs/user-manual/zh/2-providers/2.3-edit.md | 14 ++ .../user-manual/zh/3-extensions/3.3-skills.md | 23 ++- .../zh/3-extensions/3.4-sessions.md | 131 ++++++++++++++++++ .../zh/3-extensions/3.5-workspace.md | 85 ++++++++++++ docs/user-manual/zh/4-proxy/4.1-service.md | 14 ++ docs/user-manual/zh/5-faq/5.1-config-files.md | 9 +- docs/user-manual/zh/5-faq/5.2-questions.md | 10 ++ docs/user-manual/zh/README.md | 12 +- 39 files changed, 1282 insertions(+), 97 deletions(-) create mode 100644 docs/user-manual/en/3-extensions/3.4-sessions.md create mode 100644 docs/user-manual/en/3-extensions/3.5-workspace.md create mode 100644 docs/user-manual/ja/3-extensions/3.4-sessions.md create mode 100644 docs/user-manual/ja/3-extensions/3.5-workspace.md create mode 100644 docs/user-manual/zh/3-extensions/3.4-sessions.md create mode 100644 docs/user-manual/zh/3-extensions/3.5-workspace.md diff --git a/docs/user-manual/en/1-getting-started/1.3-interface.md b/docs/user-manual/en/1-getting-started/1.3-interface.md index df78b84be..70a26a132 100644 --- a/docs/user-manual/en/1-getting-started/1.3-interface.md +++ b/docs/user-manual/en/1-getting-started/1.3-interface.md @@ -100,10 +100,13 @@ CC Switch displays an icon in the system tray, providing quick access to operati | Menu Item | Function | |-----------|----------| | Open Main Window | Show and focus the main window | -| App Groups | Providers grouped by Claude/Codex/Gemini/OpenCode/OpenClaw | -| Provider List | Click to switch; currently active one shows a checkmark | +| App Submenus | Collapsible submenus grouped by Claude/Codex/Gemini (e.g., "Claude · PackyCode") | +| Provider List | Inside each submenu, click to switch; currently active shows a checkmark | +| Lightweight Mode | Toggle checkbox to enter/exit tray-only mode | | Quit | Fully exit the application | +> **Note**: Each app submenu title shows the current provider name (e.g., "Claude · PackyCode"). Apps with no configured providers show a disabled "(no providers)" entry. App visibility is controlled by the App Visibility setting. + ### Multi-language Support The tray menu supports three languages, automatically switching based on settings: @@ -114,6 +117,17 @@ The tray menu supports three languages, automatically switching based on setting | English | Open main window | Quit | | Japanese | Open main window | Quit | +### Lightweight Mode + +The tray menu includes a **Lightweight Mode** toggle (checkbox). When enabled: + +- The main window is closed to free up resources +- The app continues running in the system tray only +- You can still switch providers via the tray submenus +- On macOS, the Dock icon is also hidden + +To exit Lightweight Mode, uncheck the toggle or click "Open main window" — the main window will be rebuilt and shown. + ### Use Cases Switching providers via the tray menu doesn't require opening the main window, suitable for: @@ -121,6 +135,7 @@ Switching providers via the tray menu doesn't require opening the main window, s - Frequently switching providers - Quick operations when the main window is minimized - Managing configurations while running in the background +- Running in Lightweight Mode for minimal resource usage ## Settings Page diff --git a/docs/user-manual/en/1-getting-started/1.4-quickstart.md b/docs/user-manual/en/1-getting-started/1.4-quickstart.md index 37ab305f4..23ec47251 100644 --- a/docs/user-manual/en/1-getting-started/1.4-quickstart.md +++ b/docs/user-manual/en/1-getting-started/1.4-quickstart.md @@ -35,6 +35,8 @@ After switching providers, each CLI tool activates differently: | Claude Code | Instant effect (supports hot reload) | | Codex | Requires closing and reopening the terminal | | Gemini | Instant effect (re-reads config on each request) | +| OpenCode | Requires closing and reopening the terminal | +| OpenClaw | Requires closing and reopening the terminal | ### Claude Code First Launch Prompt @@ -64,6 +66,14 @@ codex # Gemini - enter a test question after launching gemini > Hello, please briefly introduce yourself + +# OpenCode - enter a test question after launching +opencode +> Hello, please briefly introduce yourself + +# OpenClaw - enter a test question after launching +openclaw +> Hello, please briefly introduce yourself ``` If the AI responds normally, the configuration is successful. diff --git a/docs/user-manual/en/1-getting-started/1.5-settings.md b/docs/user-manual/en/1-getting-started/1.5-settings.md index 3d29bf3c7..99b5032aa 100644 --- a/docs/user-manual/en/1-getting-started/1.5-settings.md +++ b/docs/user-manual/en/1-getting-started/1.5-settings.md @@ -167,37 +167,74 @@ Import/export configuration backups. See the "Data Management" section above for ### Backup & Restore -Manage automatic backups: +The Backup Management panel provides full control over database backups. -| Setting | Description | -|---------|-------------| -| Backup Interval | Time interval for automatic backups (hours) | -| Retention Count | Number of backups to retain | +#### Auto-Backup Settings -Supports viewing the backup list and restoring from backups. +| Setting | Options | Default | +|---------|---------|---------| +| Backup Interval | Disabled, 6h, 12h, 24h, 48h, 7d | 24 hours | +| Retention Count | 3, 5, 10, 15, 20, 30, 50 | 10 backups | + +When an interval is set, CC Switch automatically backs up the database on schedule. Older backups beyond the retention count are automatically removed. + +#### Backup List + +The panel displays all existing backups with: +- **Display name** (auto-generated from timestamp, e.g., `db_backup_20260315_143000`) +- **Creation time** +- **File size** (e.g., "1.5 MB") + +#### Backup Operations + +| Action | Description | +|--------|-------------| +| **Backup Now** | Create a backup immediately | +| **Restore** | Restore the database from a selected backup. A safety backup of the current database is created automatically before restoring | +| **Rename** | Change the backup's display name | +| **Delete** | Permanently remove a backup (with confirmation) | + +> **Important**: Restoring a backup overwrites the current database. A safety backup is always created before the restore operation, so you can recover if needed. ### Cloud Sync (WebDAV) -Sync configurations across multiple devices via the WebDAV protocol. +Sync configurations across multiple devices via the WebDAV protocol. Uses **v2 protocol** with dual-layer versioning for improved reliability. | Setting | Description | |---------|-------------| | Service Preset | Jianguoyun / Nextcloud / Synology / Custom | | Server URL | WebDAV server URL | | Username | Login username | -| Password | Login password (app-specific password) | +| Password | Login password (app-specific password; saved credentials are preserved if left unchanged) | | Remote Directory | Remote storage path (default: `cc-switch-sync`) | | Profile Name | Device profile name (default: `default`) | -| Auto Sync | Automatically upload changes when enabled | +| Auto Sync | Enable automatic synchronization on a configurable interval | -Operations: +#### Operations -- **Test Connection**: Verify WebDAV configuration is correct -- **Save**: Save configuration and auto-test -- **Upload**: Upload local data to the remote server -- **Download**: Download data from the remote server to local +| Action | Description | +|--------|-------------| +| **Test Connection** | Verify WebDAV URL, username, and password are valid | +| **Upload** | Upload local database to remote. Shows progress spinner | +| **Download** | Download remote database. Shows remote snapshot info (protocol version, DB version, timestamp, size) before confirming. A safety backup of the local database is created automatically before overwriting | -> **Note**: Upload will overwrite remote data, and download will overwrite local data. Please confirm before proceeding. +#### Auto-Sync + +When **Auto Sync** is enabled: +- A confirmation dialog is shown on first activation +- CC Switch automatically syncs the database to WebDAV at the configured interval +- Sync status is displayed in the panel + +#### Remote Snapshot Info + +Before downloading, CC Switch displays details about the remote snapshot: +- Protocol version (v2) +- Database compatibility version +- Timestamp of the remote backup +- File size +- Compatibility status (warning if incompatible) + +> **Note**: Upload overwrites remote data, and download overwrites local data. A safety backup is always created before downloading. ### Log Configuration diff --git a/docs/user-manual/en/2-providers/2.1-add.md b/docs/user-manual/en/2-providers/2.1-add.md index 76d0f368f..2fa07ce68 100644 --- a/docs/user-manual/en/2-providers/2.1-add.md +++ b/docs/user-manual/en/2-providers/2.1-add.md @@ -152,6 +152,22 @@ Presets are pre-configured provider templates that only require an API Key to us | AWS Bedrock | AWS Bedrock service | | OpenAI Compatible | OpenAI-compatible interface | +## Auto-Fetch Models + +When adding or editing a provider, you can auto-fetch available models from the provider's endpoint: + +1. Ensure the **API Key** and **Endpoint URL** are filled in +2. Click the **Fetch Models** button (download icon) next to the model input field +3. CC Switch calls the provider's `/v1/models` endpoint to retrieve the model list +4. Select a model from the dropdown, grouped by vendor + +This feature works for any provider that supports the OpenAI-compatible `/v1/models` API. It is available for Claude, Codex, Gemini, OpenCode, and OpenClaw providers. + +**Common errors:** +- **Authentication failed (401/403)**: Check your API Key +- **Endpoint not supported (404/405)**: The provider does not expose a `/v1/models` endpoint +- **Timeout**: The endpoint is slow to respond; try again later + ## Custom Configuration After selecting the "Custom" preset, you need to manually edit the JSON configuration. @@ -316,6 +332,45 @@ Batch import from SQL backup files: ## Advanced Options +### API Format (Claude Only) + +When adding a Claude provider that uses a third-party API, you may need to select the correct **API Format** in the Advanced Options section: + +| Format | Description | When to Use | +|--------|-------------|-------------| +| **Anthropic Messages** | Native Anthropic API format (default) | Direct Anthropic API or compatible proxies | +| **OpenAI Chat Completions** | OpenAI Chat API format, auto-converted by proxy | Provider only supports OpenAI Chat format | +| **OpenAI Responses API** | OpenAI Responses API format, auto-converted by proxy | Provider only supports OpenAI Responses format | + +> **Note**: API format conversion is handled by the proxy service. When using non-Anthropic formats, the proxy must be running with takeover enabled for correct request/response conversion. See [4.1 Proxy Service](../4-proxy/4.1-service.md) for details. + +The Advanced Options section auto-expands when a non-default API format is configured. + +### Claude Common Config Toggles + +When editing Claude providers, a set of **quick toggles** is available above the JSON editor: + +| Toggle | Effect | Config Change | +|--------|--------|---------------| +| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` | +| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` | +| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` | +| **High Effort** | Sets effort level to high | Sets `effortLevel = "high"` | +| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` | + +When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time. + +Additionally, the **Write Common Config** checkbox enables merging a global config snippet into the provider. Click **Edit Common Config** to customize the shared snippet. + +### Codex 1M Context Window + +When adding a Codex provider, an **Enable 1M Context Window** toggle is available: + +- **When enabled**: Sets `model_context_window = 1000000` and auto-fills `model_auto_compact_token_limit = 900000` in config.toml +- **When disabled**: Removes both fields + +The auto-compact limit can be customized in the text field that appears when the toggle is on. + ### Custom Icon Click the icon area to the left of the name to: diff --git a/docs/user-manual/en/2-providers/2.2-switch.md b/docs/user-manual/en/2-providers/2.2-switch.md index 7dced2215..5b131ac71 100644 --- a/docs/user-manual/en/2-providers/2.2-switch.md +++ b/docs/user-manual/en/2-providers/2.2-switch.md @@ -26,10 +26,12 @@ Quickly switch providers via the system tray without opening the main interface. ### Steps 1. Right-click the CC Switch icon in the system tray -2. Find the corresponding app (Claude/Codex/Gemini/OpenCode) in the menu +2. Hover over the corresponding app submenu (e.g., "Claude · CurrentProvider") 3. Click the provider name you want to switch to 4. Switching completes with a brief tray notification +> Providers are organized into collapsible submenus by app type (Claude/Codex/Gemini). The submenu title shows the currently active provider name. + ### Tray Menu Structure ![image-20260108004348993](../../assets/image-20260108004348993.png) diff --git a/docs/user-manual/en/2-providers/2.3-edit.md b/docs/user-manual/en/2-providers/2.3-edit.md index 9f148266e..82e4693ba 100644 --- a/docs/user-manual/en/2-providers/2.3-edit.md +++ b/docs/user-manual/en/2-providers/2.3-edit.md @@ -52,6 +52,20 @@ When editing the currently active provider, a special "backfill" mechanism appli This ensures CC Switch and CLI tool configurations stay in sync. +## Auto-Fetch Models + +When editing a provider, you can auto-fetch the available model list from the provider's endpoint: + +1. Ensure the API Key and endpoint URL are filled in +2. Click the **Fetch Models** button (download icon) next to the model input field +3. Select a model from the grouped dropdown + +See [2.1 Add Provider — Auto-Fetch Models](./2.1-add.md#auto-fetch-models) for full details. + +## Common Config Toggles (Claude) + +When editing a Claude provider, quick toggle switches are available above the JSON editor for common settings like Tool Search, Disable Auto Upgrade, Teammates, and High Effort. See [2.1 Add Provider — Claude Common Config Toggles](./2.1-add.md#claude-common-config-toggles) for details. + ## Modify API Key When editing a provider, you can modify the key directly in the **API Key** input field: diff --git a/docs/user-manual/en/3-extensions/3.3-skills.md b/docs/user-manual/en/3-extensions/3.3-skills.md index 43dd84854..80736c57e 100644 --- a/docs/user-manual/en/3-extensions/3.3-skills.md +++ b/docs/user-manual/en/3-extensions/3.3-skills.md @@ -118,8 +118,27 @@ Installation copies the skill folder to your local machine: ### Uninstall Effect -- Deletes the local skill folder -- Updates installation status +- **Automatic backup**: Before deletion, the skill is backed up to `~/.cc-switch/skill-backups/` +- Removes the skill from all app directories (Claude, Codex, Gemini, OpenCode) +- Removes the skill from the SSOT directory (`~/.cc-switch/skills/`) +- Deletes the skill record from the database + +### Restore from Backup + +If you need to restore a previously uninstalled skill: + +1. Open the Skills page +2. Click the **Restore from Backup** button +3. Select the backup you want to restore from the list (shows skill name and backup date) +4. The skill is restored and enabled for the current app + +### Delete Backups + +To remove old skill backups: + +1. In the restore dialog, find the backup you want to remove +2. Click the **Delete** button next to the backup entry +3. Confirm deletion — this cannot be undone ## Repository Management diff --git a/docs/user-manual/en/3-extensions/3.4-sessions.md b/docs/user-manual/en/3-extensions/3.4-sessions.md new file mode 100644 index 000000000..73afde0b8 --- /dev/null +++ b/docs/user-manual/en/3-extensions/3.4-sessions.md @@ -0,0 +1,131 @@ +# 3.4 Session Manager + +The Session Manager lets you browse, search, and manage conversation sessions from all supported CLI tools in one place. + +## Supported Applications + +| Application | Session Storage Location | +|-------------|--------------------------| +| Claude Code | `~/.cache/claude/projects/*.jsonl` | +| Codex | Codex config sessions directory | +| OpenCode | `~/.local/share/opencode/` (JSON or SQLite) | +| OpenClaw | `~/.openclaw/agents//sessions/*.jsonl` | +| Gemini CLI | `~/.cache/gemini/tmp//chats/` | + +## Opening the Session Manager + +Click the **Sessions** button in the main navigation bar toolbar. + +> **Note**: The Sessions button is visible for all five supported applications. + +## Interface Layout + +The Session Manager uses a **two-column layout**: + +- **Left panel**: Session list with search and filter toolbar +- **Right panel**: Selected session details with conversation history + +### Session List (Left Panel) + +Each session entry displays: +- Provider icon +- Session title +- Last active time (relative format, e.g., "5 min ago") + +### Session Details (Right Panel) + +When a session is selected, the right panel shows: +- **Title**: Derived from session title, project directory name, or session ID +- **Last active date/time**: Full timestamp +- **Project directory**: Clickable to copy full path (shows basename with tooltip for full path) +- **Resume command**: Displayed in monospace style when available +- **Conversation history**: Full message transcript + +## Search & Filtering + +### Full-Text Search + +Use the search box at the top of the left panel to search across: +- Session ID +- Title +- Summary +- Project directory +- Source file path + +The search supports prefix matching and filters results in real-time. Press **Esc** to clear the search. + +### Provider Filtering + +Click the provider filter dropdown (top-right of left panel) to filter by application: +- **All** — Show sessions from all providers +- **Claude Code** +- **Codex** +- **OpenCode** +- **OpenClaw** +- **Gemini CLI** + +The filter can be combined with search. + +### Refresh + +Click the refresh button (circular arrow icon) to re-scan all provider directories for new or deleted sessions. + +## Session Actions + +### Resume Session + +Click the **Resume** button (play icon) on a selected session to continue the conversation. + +**On macOS:** +- CC Switch launches your preferred terminal with the resume command +- The terminal opens in the session's project directory +- If terminal launch fails, the command is copied to your clipboard instead + +**Supported terminals (macOS):** Terminal.app, iTerm2, Ghostty, Kitty, WezTerm, Alacritty + +**On other platforms:** +- The resume command is copied to your clipboard +- Paste it into your terminal to resume + +> The Resume button is disabled if the session has no resume command available. + +### Delete Session + +Click the **Delete** button (trash icon) to permanently remove a session file. A confirmation dialog is shown before deletion. + +> Sessions without a local source path (e.g., immutable sessions) cannot be deleted. + +### Batch Operations + +For managing multiple sessions at once: + +1. Click the **Batch Mode** button (checkbox icon) in the left panel toolbar +2. Select sessions using the checkboxes that appear +3. Use **Select All** to select all filtered results, or **Clear** to deselect +4. Click **Batch Delete** (red trash icon) to delete all selected sessions + +A confirmation dialog shows the count before deletion. Results report the number of successful deletions and any failures. + +## Conversation History + +### Message Display + +Messages are color-coded by role: +- **User** messages: Green, left-aligned +- **AI** (Assistant) messages: Blue, right-aligned +- **System** messages: Amber +- **Tool** messages: Purple + +### Table of Contents + +For longer conversations, a Table of Contents is available: +- **Desktop (XL+ screens)**: Sidebar on the right showing user message previews +- **Smaller screens**: Floating button (list icon) at bottom-right that opens a dialog + +Click any entry to scroll to that message, which is briefly highlighted. + +## Tips + +- Sessions are sorted by last activity time (newest first) +- The session count badge updates as you search and filter +- OpenCode sessions may come from both JSON files and SQLite database — duplicates are automatically deduplicated diff --git a/docs/user-manual/en/3-extensions/3.5-workspace.md b/docs/user-manual/en/3-extensions/3.5-workspace.md new file mode 100644 index 000000000..4ebae8a98 --- /dev/null +++ b/docs/user-manual/en/3-extensions/3.5-workspace.md @@ -0,0 +1,85 @@ +# 3.5 Workspace Files & Daily Memory + +## Overview + +The Workspace panel provides file management and daily memory features for **OpenClaw**. It allows you to edit workspace configuration files and maintain a daily memory journal. + +> This feature is specific to OpenClaw. The Workspace button appears in the navigation bar when OpenClaw is the selected application. + +## Workspace Files + +### File Location + +All workspace files are stored in `~/.openclaw/workspace/`. + +Click the directory path at the top of the panel to open it in your file manager. + +### Available Files + +CC Switch manages 9 workspace files, each serving a specific purpose: + +| File | Description | +|------|-------------| +| **AGENTS.md** | Agents definition and configuration | +| **SOUL.md** | System soul/personality settings | +| **USER.md** | User profile information | +| **IDENTITY.md** | Identity and role definition | +| **TOOLS.md** | Available tools configuration | +| **MEMORY.md** | System memory | +| **HEARTBEAT.md** | Heartbeat configuration | +| **BOOTSTRAP.md** | Bootstrap sequence | +| **BOOT.md** | Boot configuration | + +### File Status + +Each file shows a status indicator: +- **Green checkmark**: File exists on disk +- **Empty circle**: File does not exist yet (will be created on first save) + +### Editing Files + +1. Click any file card to open the Markdown editor +2. Edit the content +3. Click **Save** to write changes to disk + +If the file doesn't exist yet, it will be created on first save. + +## Daily Memory + +The Daily Memory feature provides a date-organized journal system stored in `~/.openclaw/workspace/memory/`. + +### Accessing Daily Memory + +Click the **Daily Memory** card in the Workspace Files grid to open the memory panel. + +### File List + +The panel displays all daily memory files sorted by date (newest first). Each entry shows: +- **Date** (formatted from filename, e.g., `2026-04-01.md`) +- **File size** +- **Preview** (first 2 lines of content) + +### Create Today's Note + +Click the **Create Today** button to: +- Open a new note with today's date (`YYYY-MM-DD.md`) +- If today's note already exists, it opens for editing +- The file is persisted only after you click Save + +### Search + +Search across all daily memory files: + +1. Press **Cmd/Ctrl+F** or click the search icon +2. Enter your search term +3. Results show matching files with: + - Match count per file + - Snippet preview from the matching line + - File date and size + +Press **Esc** to close the search. + +### Edit & Delete + +- **Edit**: Click a file entry to open it in the Markdown editor +- **Delete**: Hover over a file entry and click the delete icon. A confirmation dialog is shown — deletion cannot be undone. diff --git a/docs/user-manual/en/4-proxy/4.1-service.md b/docs/user-manual/en/4-proxy/4.1-service.md index 05b99597e..7b01ad9f9 100644 --- a/docs/user-manual/en/4-proxy/4.1-service.md +++ b/docs/user-manual/en/4-proxy/4.1-service.md @@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1" GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721 ``` +## API Format Conversion + +The proxy supports automatic API format conversion for providers configured with non-Anthropic formats. This allows you to use providers that only support OpenAI-compatible APIs with Claude Code. + +| Provider API Format | Proxy Behavior | +|---------------------|----------------| +| **Anthropic Messages** | Pass-through (no conversion) | +| **OpenAI Chat Completions** | Converts Anthropic requests to OpenAI Chat format and responses back | +| **OpenAI Responses API** | Converts Anthropic requests to OpenAI Responses format and responses back | + +The API format is configured per-provider in the [Advanced Options](../2-providers/2.1-add.md#api-format-claude-only) when adding or editing a Claude provider. + +> **Note**: Format conversion requires the proxy to be running with app takeover enabled. The conversion handles both streaming and non-streaming requests. + ## Stop the Proxy ### Option 1: Main Interface Toggle diff --git a/docs/user-manual/en/5-faq/5.1-config-files.md b/docs/user-manual/en/5-faq/5.1-config-files.md index 4ca20f3f8..4f1dfe041 100644 --- a/docs/user-manual/en/5-faq/5.1-config-files.md +++ b/docs/user-manual/en/5-faq/5.1-config-files.md @@ -12,12 +12,11 @@ Customizable location in settings (for cloud sync). ``` ~/.cc-switch/ -├── cc-switch.db # SQLite database +├── cc-switch.db # SQLite database (SSOT) ├── settings.json # Device-level settings -└── backups/ # Automatic backups - ├── backup-20251230-120000.json - ├── backup-20251229-180000.json - └── ... +├── skills/ # Skill SSOT directory +├── skill-backups/ # Skill backups (created on uninstall) +└── db_backup_*.db # Database backups ``` ### Database Contents diff --git a/docs/user-manual/en/5-faq/5.2-questions.md b/docs/user-manual/en/5-faq/5.2-questions.md index 624950be5..7695aebe1 100644 --- a/docs/user-manual/en/5-faq/5.2-questions.md +++ b/docs/user-manual/en/5-faq/5.2-questions.md @@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage 2. Manually download and install the latest version 3. If using Homebrew: `brew upgrade --cask cc-switch` +## Lightweight Mode + +### How to Enter Lightweight Mode? + +Toggle "Lightweight Mode" from the system tray menu. The main window closes, and CC Switch runs as a tray-only app. Toggle again or click "Open main window" to exit. + +### App Uses Less Memory in Lightweight Mode? + +Yes. Lightweight Mode destroys the main window and its web view, reducing memory usage significantly while keeping tray menu functionality available. + ## Getting Help ### Submit an Issue diff --git a/docs/user-manual/en/README.md b/docs/user-manual/en/README.md index 228c77b4c..8c786f11c 100644 --- a/docs/user-manual/en/README.md +++ b/docs/user-manual/en/README.md @@ -24,7 +24,9 @@ CC Switch User Manual ├── 3. Extensions │ ├── 3.1 MCP Server Management │ ├── 3.2 Prompts Management -│ └── 3.3 Skills Management +│ ├── 3.3 Skills Management +│ ├── 3.4 Session Manager +│ └── 3.5 Workspace & Memory │ ├── 4. Proxy & High Availability │ ├── 4.1 Proxy Service @@ -69,6 +71,8 @@ CC Switch User Manual | [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP protocol, add servers, app binding | | [3.2-prompts.md](./3-extensions/3.2-prompts.md) | Create presets, activate/switch, smart backfill | | [3.3-skills.md](./3-extensions/3.3-skills.md) | Discover skills, install/uninstall, repository management | +| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | Session Manager: browse, search, resume, delete sessions | +| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | Workspace files and daily memory (OpenClaw) | ### 4. Proxy & High Availability @@ -99,9 +103,9 @@ CC Switch User Manual ## Version Information -- Documentation version: v3.12.0 -- Last updated: 2026-03-09 -- Applicable to CC Switch v3.12.0+ +- Documentation version: v3.12.3 +- Last updated: 2026-04-04 +- Applicable to CC Switch v3.12.3+ ## Contributing diff --git a/docs/user-manual/ja/1-getting-started/1.3-interface.md b/docs/user-manual/ja/1-getting-started/1.3-interface.md index cbd07094f..c177bc55f 100644 --- a/docs/user-manual/ja/1-getting-started/1.3-interface.md +++ b/docs/user-manual/ja/1-getting-started/1.3-interface.md @@ -100,10 +100,13 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操 | メニュー項目 | 機能 | |--------|------| | メインウィンドウを開く | メインウィンドウを表示してフォーカス | -| アプリグループ | Claude/Codex/Gemini/OpenCode/OpenClaw ごとにプロバイダーを表示 | -| プロバイダーリスト | クリックで切り替え、現在有効なものにはチェックマークを表示 | +| アプリサブメニュー | Claude/Codex/Gemini/OpenCode/OpenClaw ごとの折りたたみサブメニュー(例:「Claude · PackyCode」) | +| プロバイダーリスト | 各サブメニュー内でクリックして切り替え、現在有効なものにはチェックマークを表示 | +| ライトウェイトモード | トグルチェックボックスでトレイ専用モードの開始/終了 | | 終了 | アプリを完全に終了 | +> **注意**:各アプリのサブメニュータイトルには現在のプロバイダー名が表示されます(例:「Claude · PackyCode」)。プロバイダーが設定されていないアプリでは、無効化された「(プロバイダーなし)」エントリが表示されます。アプリの表示はアプリの表示設定で制御されます。 + ### 多言語対応 トレイメニューは 3 つの言語に対応し、設定に応じて自動的に切り替わります: @@ -114,6 +117,17 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操 | English | Open main window | Quit | | 日本語 | メインウィンドウを開く | 終了 | +### ライトウェイトモード + +トレイメニューには **ライトウェイトモード** のトグル(チェックボックス)があります。有効にすると: + +- メインウィンドウが閉じられ、リソースが解放される +- アプリはシステムトレイのみで動作を継続 +- トレイのサブメニューからプロバイダーの切り替えが可能 +- macOS では Dock アイコンも非表示になる + +ライトウェイトモードを終了するには、トグルのチェックを外すか「メインウィンドウを開く」をクリックします。メインウィンドウが再構築されて表示されます。 + ### 使用シーン トレイからのプロバイダー切り替えはメイン画面を開く必要がなく、以下の場面に適しています: @@ -121,6 +135,7 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操 - 頻繁にプロバイダーを切り替える場合 - メインウィンドウが最小化されているときの素早い操作 - バックグラウンド実行中の設定管理 +- ライトウェイトモードでリソース使用量を最小化 ## 設定ページ diff --git a/docs/user-manual/ja/1-getting-started/1.4-quickstart.md b/docs/user-manual/ja/1-getting-started/1.4-quickstart.md index 024f0e854..ef5d060d2 100644 --- a/docs/user-manual/ja/1-getting-started/1.4-quickstart.md +++ b/docs/user-manual/ja/1-getting-started/1.4-quickstart.md @@ -33,8 +33,10 @@ | アプリ | 反映方法 | |------|----------| | Claude Code | 即時反映(ホットリロード対応) | -| Codex | ターミナルを閉じて再度開く必要あり | +| Codex | ターミナルの再起動が必要 | | Gemini | 即時反映(リクエストごとに設定を再読み込み) | +| OpenCode | ターミナルの再起動が必要 | +| OpenClaw | ターミナルの再起動が必要 | ### Claude Code の初回インストール時の注意 @@ -64,6 +66,14 @@ codex # Gemini - 起動後にテスト質問を入力 gemini > こんにちは、簡単に自己紹介してください + +# OpenCode - 起動後にテスト質問を入力 +opencode +> こんにちは、簡単に自己紹介してください + +# OpenClaw - 起動後にテスト質問を入力 +openclaw +> こんにちは、簡単に自己紹介してください ``` AI が正常に回答すれば、設定は成功です。 diff --git a/docs/user-manual/ja/1-getting-started/1.5-settings.md b/docs/user-manual/ja/1-getting-started/1.5-settings.md index 2630b6ca2..fa2f9e466 100644 --- a/docs/user-manual/ja/1-getting-started/1.5-settings.md +++ b/docs/user-manual/ja/1-getting-started/1.5-settings.md @@ -167,37 +167,74 @@ CC Switch の送信 HTTP/HTTPS プロキシを設定します。外部 API に ### バックアップと復元 -自動バックアップの管理: +バックアップ管理パネルでは、データベースバックアップを完全に管理できます。 -| 設定 | 説明 | -| -------- | -------------------------- | -| バックアップ間隔 | 自動バックアップの時間間隔(時間) | -| 保持数量 | 保持するバックアップの数 | +#### 自動バックアップ設定 -バックアップリストの表示とバックアップからの復元をサポートします。 +| 設定 | オプション | デフォルト | +|------|---------|---------| +| バックアップ間隔 | 無効、6時間、12時間、24時間、48時間、7日 | 24時間 | +| 保持数 | 3、5、10、15、20、30、50 | 10件 | + +間隔を設定すると、CC Switch はスケジュールに従って自動的にデータベースをバックアップします。保持数を超えた古いバックアップは自動的に削除されます。 + +#### バックアップリスト + +パネルには既存のすべてのバックアップが以下の情報とともに表示されます: +- **表示名**(タイムスタンプから自動生成、例:`db_backup_20260315_143000`) +- **作成日時** +- **ファイルサイズ**(例:「1.5 MB」) + +#### バックアップ操作 + +| 操作 | 説明 | +|------|------| +| **今すぐバックアップ** | 即座にバックアップを作成 | +| **復元** | 選択したバックアップからデータベースを復元。復元前に現在のデータベースの安全バックアップが自動的に作成される | +| **名前変更** | バックアップの表示名を変更 | +| **削除** | バックアップを完全に削除(確認あり) | + +> **重要**:バックアップの復元は現在のデータベースを上書きします。復元前に安全バックアップが自動作成されるため、必要に応じて元に戻すことができます。 ### クラウド同期(WebDAV) -WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。 +WebDAV プロトコルを使用して複数のデバイス間で設定を同期します。信頼性の向上のため、デュアルレイヤーバージョニングを備えた **v2 プロトコル** を使用しています。 | 設定項目 | 説明 | | -------- | ------------------------------------- | | サービスプリセット | 坚果云 / Nextcloud / Synology / カスタム | | サーバー URL | WebDAV サーバー URL | | ユーザー名 | ログインユーザー名 | -| パスワード | ログインパスワード(アプリ専用パスワード) | +| パスワード | ログインパスワード(アプリ専用パスワード、保存済みの場合は未変更で保持) | | リモートディレクトリ | リモート保存パス(デフォルト `cc-switch-sync`) | | プロファイル名 | デバイスプロファイル名(デフォルト `default`) | -| 自動同期 | 有効にすると変更を自動アップロード | +| 自動同期 | 設定可能な間隔で自動同期を有効化 | -操作: +#### 操作 -- **接続テスト**:WebDAV 設定が正しいか確認 -- **保存**:設定を保存して自動テスト -- **アップロード**:ローカルデータをリモートにアップロード -- **ダウンロード**:リモートからローカルにデータをダウンロード +| 操作 | 説明 | +|------|------| +| **接続テスト** | WebDAV の URL、ユーザー名、パスワードが有効か確認 | +| **アップロード** | ローカルデータベースをリモートにアップロード。進捗スピナーを表示 | +| **ダウンロード** | リモートデータベースをダウンロード。確認前にリモートスナップショット情報(プロトコルバージョン、DB バージョン、タイムスタンプ、サイズ)を表示。上書き前にローカルデータベースの安全バックアップを自動作成 | -> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。操作前にご確認ください。 +#### 自動同期 + +**自動同期** を有効にすると: +- 初回有効化時に確認ダイアログが表示される +- CC Switch は設定された間隔で自動的にデータベースを WebDAV に同期 +- 同期ステータスがパネルに表示される + +#### リモートスナップショット情報 + +ダウンロード前に、CC Switch はリモートスナップショットの詳細を表示します: +- プロトコルバージョン(v2) +- データベース互換バージョン +- リモートバックアップのタイムスタンプ +- ファイルサイズ +- 互換性ステータス(非互換の場合は警告) + +> **注意**:アップロードはリモートデータを、ダウンロードはローカルデータを上書きします。ダウンロード前に安全バックアップが自動作成されます。 ### ログ設定 diff --git a/docs/user-manual/ja/2-providers/2.1-add.md b/docs/user-manual/ja/2-providers/2.1-add.md index d7d42b186..1dfc096de 100644 --- a/docs/user-manual/ja/2-providers/2.1-add.md +++ b/docs/user-manual/ja/2-providers/2.1-add.md @@ -152,6 +152,22 @@ | AWS Bedrock | AWS Bedrock サービス | | OpenAI Compatible | OpenAI 互換インターフェース | +## モデル自動取得 + +プロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動取得できます: + +1. **API Key** と **エンドポイントアドレス** が入力されていることを確認 +2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック +3. CC Switch がプロバイダーの `/v1/models` エンドポイントを呼び出してモデルリストを取得 +4. ベンダー別にグループ化されたドロップダウンからモデルを選択 + +この機能は、OpenAI 互換の `/v1/models` API をサポートするすべてのプロバイダーで動作します。Claude、Codex、Gemini、OpenCode、OpenClaw のプロバイダーで利用可能です。 + +**よくあるエラー:** +- **認証失敗(401/403)**:API Key を確認してください +- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません +- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行してください + ## カスタム設定 「カスタム」プリセットを選択した場合、JSON 設定を手動で編集する必要があります。 @@ -316,6 +332,45 @@ SQL バックアップファイルから一括インポート: ## 高度なオプション +### API フォーマット(Claude のみ) + +サードパーティ API を使用する Claude プロバイダーを追加する際、高度なオプションセクションで正しい **API フォーマット** を選択する必要がある場合があります: + +| フォーマット | 説明 | 使用場面 | +|------|------|------| +| **Anthropic Messages** | ネイティブ Anthropic API フォーマット(デフォルト) | Anthropic API に直接接続、または互換プロキシ | +| **OpenAI Chat Completions** | OpenAI Chat API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Chat フォーマットのみ対応 | +| **OpenAI Responses API** | OpenAI Responses API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Responses フォーマットのみ対応 | + +> **注意**:API フォーマットの変換はプロキシサービスが担当します。Anthropic 以外のフォーマットを使用する場合、リクエスト/レスポンスの正しい変換のためにプロキシが接管有効の状態で稼働している必要があります。詳しくは [4.1 プロキシサービス](../4-proxy/4.1-service.md) をご覧ください。 + +デフォルト以外の API フォーマットが設定されている場合、高度なオプションセクションが自動展開されます。 + +### Claude 共通設定クイックトグル + +Claude プロバイダーの編集時、JSON エディタの上部に **クイックトグル** が利用できます: + +| トグル | 効果 | 設定変更 | +|------|------|------| +| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 | +| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 | +| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 | +| **高強度** | エフォートレベルを高に設定 | `effortLevel = "high"` を設定 | +| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 | + +トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。 + +また、**共通設定を書き込み** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。 + +### Codex 1M コンテキストウィンドウ + +Codex プロバイダーの追加時、**1M コンテキストウィンドウを有効化** トグルが利用できます: + +- **有効時**:config.toml に `model_context_window = 1000000` を設定し、`model_auto_compact_token_limit = 900000` を自動入力 +- **無効時**:両方のフィールドを削除 + +トグルがオンの場合に表示されるテキストフィールドで、自動コンパクト制限をカスタマイズできます。 + ### カスタムアイコン 名前の左側にあるアイコンエリアをクリックすると: diff --git a/docs/user-manual/ja/2-providers/2.2-switch.md b/docs/user-manual/ja/2-providers/2.2-switch.md index 60525875b..fda323cde 100644 --- a/docs/user-manual/ja/2-providers/2.2-switch.md +++ b/docs/user-manual/ja/2-providers/2.2-switch.md @@ -26,10 +26,12 @@ ### 操作手順 1. システムトレイの CC Switch アイコンを右クリック -2. メニューで対応するアプリ(Claude/Codex/Gemini/OpenCode)を見つける +2. 対応するアプリのサブメニュー(例:「Claude · 現在のプロバイダー」)にマウスを合わせる 3. 切り替えたいプロバイダー名をクリック 4. 切り替え完了、トレイに短い通知が表示 +> プロバイダーはアプリタイプ(Claude/Codex/Gemini)ごとに折りたたみサブメニューに整理されています。サブメニューのタイトルには現在有効なプロバイダー名が表示されます。 + ### トレイメニュー構造 ![image-20260108004348993](../../assets/image-20260108004348993.png) diff --git a/docs/user-manual/ja/2-providers/2.3-edit.md b/docs/user-manual/ja/2-providers/2.3-edit.md index 1f80f72e5..68eb675ce 100644 --- a/docs/user-manual/ja/2-providers/2.3-edit.md +++ b/docs/user-manual/ja/2-providers/2.3-edit.md @@ -52,6 +52,20 @@ JSON 形式の設定内容(以下を含む): これにより、CC Switch と CLI ツールの設定が常に同期されます。 +## モデル自動取得 + +プロバイダーの編集時に、プロバイダーのエンドポイントから利用可能なモデルリストを自動取得できます: + +1. API Key とエンドポイントアドレスが入力されていることを確認 +2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック +3. グループ化されたドロップダウンからモデルを選択 + +詳しくは [2.1 プロバイダーの追加 - モデル自動取得](./2.1-add.md#モデル自動取得) をご覧ください。 + +## 共通設定クイックトグル(Claude) + +Claude プロバイダーの編集時、JSON エディタの上部にツール検索、自動アップグレード無効化、チームメイト、高強度などの共通設定のクイックトグルスイッチが利用できます。詳しくは [2.1 プロバイダーの追加 - Claude 共通設定クイックトグル](./2.1-add.md#claude-共通設定クイックトグル) をご覧ください。 + ## API Key の変更 プロバイダーの編集時に、**API Key** 入力ボックスから直接変更できます: diff --git a/docs/user-manual/ja/3-extensions/3.3-skills.md b/docs/user-manual/ja/3-extensions/3.3-skills.md index 79c2abe4b..1045ea8e5 100644 --- a/docs/user-manual/ja/3-extensions/3.3-skills.md +++ b/docs/user-manual/ja/3-extensions/3.3-skills.md @@ -118,8 +118,27 @@ CC Switch は強力な検索とフィルタリング機能を提供していま ### アンインストールの効果 -- ローカルのスキルフォルダを削除 -- インストール状態を更新 +- **自動バックアップ**:削除前にスキルが `~/.cc-switch/skill-backups/` にバックアップされる +- すべてのアプリディレクトリ(Claude、Codex、Gemini、OpenCode)からスキルを削除 +- SSOT ディレクトリ(`~/.cc-switch/skills/`)からスキルを削除 +- データベースからスキルレコードを削除 + +### バックアップから復元 + +以前アンインストールしたスキルを復元する場合: + +1. Skills ページを開く +2. **バックアップから復元** ボタンをクリック +3. リスト(スキル名とバックアップ日が表示)から復元したいバックアップを選択 +4. スキルが復元され、現在のアプリで有効化される + +### バックアップの削除 + +古いスキルバックアップを削除するには: + +1. 復元ダイアログで削除したいバックアップを見つける +2. バックアップエントリの横にある **削除** ボタンをクリック +3. 削除を確認(この操作は取り消せません) ## リポジトリ管理 diff --git a/docs/user-manual/ja/3-extensions/3.4-sessions.md b/docs/user-manual/ja/3-extensions/3.4-sessions.md new file mode 100644 index 000000000..7290fbeec --- /dev/null +++ b/docs/user-manual/ja/3-extensions/3.4-sessions.md @@ -0,0 +1,131 @@ +# 3.4 セッションマネージャー + +セッションマネージャーでは、対応するすべての CLI ツールの会話セッションを一か所で閲覧、検索、管理できます。 + +## 対応アプリ + +| アプリ | セッション保存場所 | +|------|----------| +| Claude Code | `~/.cache/claude/projects/*.jsonl` | +| Codex | Codex 設定のセッションディレクトリ | +| OpenCode | `~/.local/share/opencode/`(JSON または SQLite) | +| OpenClaw | `~/.openclaw/agents//sessions/*.jsonl` | +| Gemini CLI | `~/.cache/gemini/tmp//chats/` | + +## セッションマネージャーを開く + +メインナビゲーションバーの **セッション** ボタンをクリックします。 + +> **注意**:セッションボタンは対応する 5 つのアプリすべてで表示されます。 + +## インターフェースのレイアウト + +セッションマネージャーは **2 カラムレイアウト** を採用しています: + +- **左パネル**:検索とフィルターツールバー付きのセッションリスト +- **右パネル**:選択したセッションの詳細と会話履歴 + +### セッションリスト(左パネル) + +各セッションエントリには以下が表示されます: +- プロバイダーアイコン +- セッションタイトル +- 最終アクティブ時間(相対形式、例:「5分前」) + +### セッション詳細(右パネル) + +セッションを選択すると、右パネルに以下が表示されます: +- **タイトル**:セッションタイトル、プロジェクトディレクトリ名、またはセッション ID から取得 +- **最終アクティブ日時**:完全なタイムスタンプ +- **プロジェクトディレクトリ**:クリックでフルパスをコピー(ベース名とツールチップでフルパスを表示) +- **再開コマンド**:利用可能な場合、モノスペースフォントで表示 +- **会話履歴**:メッセージの全文記録 + +## 検索とフィルタリング + +### 全文検索 + +左パネル上部の検索ボックスを使用して、以下の項目を横断的に検索できます: +- セッション ID +- タイトル +- サマリー +- プロジェクトディレクトリ +- ソースファイルパス + +前方一致検索に対応し、リアルタイムで結果をフィルタリングします。**Esc** で検索をクリアできます。 + +### プロバイダーフィルター + +左パネル右上のプロバイダーフィルタードロップダウンをクリックして、アプリ別にフィルタリングします: +- **すべて** — すべてのプロバイダーのセッションを表示 +- **Claude Code** +- **Codex** +- **OpenCode** +- **OpenClaw** +- **Gemini CLI** + +フィルターは検索と組み合わせて使用できます。 + +### 更新 + +更新ボタン(循環矢印アイコン)をクリックすると、すべてのプロバイダーディレクトリを再スキャンして新しいセッションや削除済みセッションを検出します。 + +## セッション操作 + +### セッションの再開 + +選択したセッションの **再開** ボタン(再生アイコン)をクリックして、会話を続行します。 + +**macOS の場合:** +- CC Switch は設定済みのターミナルで再開コマンドを起動します +- ターミナルはセッションのプロジェクトディレクトリで開きます +- ターミナルの起動に失敗した場合、コマンドがクリップボードにコピーされます + +**対応ターミナル(macOS):** Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty + +**その他のプラットフォーム:** +- 再開コマンドがクリップボードにコピーされます +- ターミナルに貼り付けてセッションを再開してください + +> 再開コマンドが利用できないセッションでは、再開ボタンは無効になります。 + +### セッションの削除 + +**削除** ボタン(ゴミ箱アイコン)をクリックすると、セッションファイルが完全に削除されます。削除前に確認ダイアログが表示されます。 + +> ローカルソースパスのないセッション(不変のセッションなど)は削除できません。 + +### 一括操作 + +複数のセッションを一度に管理するには: + +1. 左パネルツールバーの **一括モード** ボタン(チェックボックスアイコン)をクリック +2. 表示されるチェックボックスでセッションを選択 +3. **すべて選択** でフィルタリング結果をすべて選択、または **クリア** で選択解除 +4. **一括削除**(赤いゴミ箱アイコン)をクリックして選択したすべてのセッションを削除 + +削除前に件数を表示する確認ダイアログが表示されます。結果には成功した削除件数と失敗件数が報告されます。 + +## 会話履歴 + +### メッセージの表示 + +メッセージは役割ごとに色分けされます: +- **ユーザー** メッセージ:緑、左寄せ +- **AI**(アシスタント)メッセージ:青、右寄せ +- **システム** メッセージ:アンバー +- **ツール** メッセージ:パープル + +### 目次 + +長い会話の場合、目次機能が利用できます: +- **デスクトップ(XL+ 画面)**:右側のサイドバーにユーザーメッセージのプレビューを表示 +- **小さい画面**:右下のフローティングボタン(リストアイコン)をクリックするとダイアログが開く + +エントリをクリックすると該当メッセージにスクロールし、一時的にハイライト表示されます。 + +## ヒント + +- セッションは最終アクティブ時間の新しい順にソートされます +- セッション数バッジは検索やフィルタリングに応じて更新されます +- OpenCode のセッションは JSON ファイルと SQLite データベースの両方から取得される場合があります(重複は自動的に除去されます) diff --git a/docs/user-manual/ja/3-extensions/3.5-workspace.md b/docs/user-manual/ja/3-extensions/3.5-workspace.md new file mode 100644 index 000000000..ae31a17c4 --- /dev/null +++ b/docs/user-manual/ja/3-extensions/3.5-workspace.md @@ -0,0 +1,85 @@ +# 3.5 ワークスペースファイルとデイリーメモリー + +## 概要 + +ワークスペースパネルは、**OpenClaw** 向けのファイル管理とデイリーメモリー機能を提供します。ワークスペース設定ファイルの編集やデイリーメモリージャーナルの管理が可能です。 + +> この機能は OpenClaw 専用です。ワークスペースボタンは、OpenClaw が選択されている場合にナビゲーションバーに表示されます。 + +## ワークスペースファイル + +### ファイルの保存場所 + +すべてのワークスペースファイルは `~/.openclaw/workspace/` に保存されます。 + +パネル上部のディレクトリパスをクリックすると、ファイルマネージャーで開きます。 + +### 利用可能なファイル + +CC Switch は 9 つのワークスペースファイルを管理しており、それぞれ特定の役割を持っています: + +| ファイル | 説明 | +|------|------| +| **AGENTS.md** | エージェントの定義と設定 | +| **SOUL.md** | システムのソウル/パーソナリティ設定 | +| **USER.md** | ユーザープロファイル情報 | +| **IDENTITY.md** | アイデンティティとロールの定義 | +| **TOOLS.md** | 利用可能なツールの設定 | +| **MEMORY.md** | システムメモリー | +| **HEARTBEAT.md** | ハートビート設定 | +| **BOOTSTRAP.md** | ブートストラップシーケンス | +| **BOOT.md** | ブート設定 | + +### ファイルステータス + +各ファイルにはステータスインジケーターが表示されます: +- **緑のチェックマーク**:ファイルがディスク上に存在 +- **空の円**:ファイルがまだ存在しない(初回保存時に作成) + +### ファイルの編集 + +1. ファイルカードをクリックして Markdown エディタを開く +2. コンテンツを編集 +3. **保存** をクリックしてディスクに書き込み + +ファイルがまだ存在しない場合は、初回保存時に作成されます。 + +## デイリーメモリー + +デイリーメモリー機能は、`~/.openclaw/workspace/memory/` に保存される日付別のジャーナルシステムを提供します。 + +### デイリーメモリーへのアクセス + +ワークスペースファイルグリッドの **デイリーメモリー** カードをクリックして、メモリーパネルを開きます。 + +### ファイルリスト + +パネルには日付の新しい順にすべてのデイリーメモリーファイルが表示されます。各エントリには以下が表示されます: +- **日付**(ファイル名から変換、例:`2026-04-01.md`) +- **ファイルサイズ** +- **プレビュー**(コンテンツの最初の 2 行) + +### 今日のノートを作成 + +**今日のノートを作成** ボタンをクリックすると: +- 今日の日付(`YYYY-MM-DD.md`)で新しいノートを開く +- 今日のノートがすでに存在する場合は、編集用に開く +- ファイルは保存をクリックした後に保存される + +### 検索 + +すべてのデイリーメモリーファイルを横断検索できます: + +1. **Cmd/Ctrl+F** を押すか、検索アイコンをクリック +2. 検索語を入力 +3. 結果には以下が表示されます: + - ファイルごとのマッチ数 + - マッチした行のスニペットプレビュー + - ファイルの日付とサイズ + +**Esc** で検索を閉じます。 + +### 編集と削除 + +- **編集**:ファイルエントリをクリックして Markdown エディタで開く +- **削除**:ファイルエントリにマウスをホバーして削除アイコンをクリック。確認ダイアログが表示されます(削除は取り消せません)。 diff --git a/docs/user-manual/ja/4-proxy/4.1-service.md b/docs/user-manual/ja/4-proxy/4.1-service.md index 32b217bad..35169c63a 100644 --- a/docs/user-manual/ja/4-proxy/4.1-service.md +++ b/docs/user-manual/ja/4-proxy/4.1-service.md @@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1" GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721 ``` +## API フォーマット変換 + +プロキシは、Anthropic 以外のフォーマットが設定されたプロバイダーに対して、API フォーマットの自動変換をサポートします。これにより、OpenAI 互換 API のみをサポートするプロバイダーを Claude Code で使用できます。 + +| プロバイダー API フォーマット | プロキシの動作 | +|------|------| +| **Anthropic Messages** | パススルー(変換なし) | +| **OpenAI Chat Completions** | Anthropic リクエストを OpenAI Chat フォーマットに変換し、レスポンスを逆変換 | +| **OpenAI Responses API** | Anthropic リクエストを OpenAI Responses フォーマットに変換し、レスポンスを逆変換 | + +API フォーマットはプロバイダーごとに、Claude プロバイダーの追加・編集時の[高度なオプション](../2-providers/2.1-add.md#api-フォーマットclaude-のみ)で設定します。 + +> **注意**:フォーマット変換にはプロキシがアプリ接管有効の状態で稼働している必要があります。変換はストリーミングと非ストリーミングの両方のリクエストに対応しています。 + ## プロキシの停止 ### 方法 1:メイン画面のスイッチ diff --git a/docs/user-manual/ja/5-faq/5.1-config-files.md b/docs/user-manual/ja/5-faq/5.1-config-files.md index 135a0fc56..09df32eec 100644 --- a/docs/user-manual/ja/5-faq/5.1-config-files.md +++ b/docs/user-manual/ja/5-faq/5.1-config-files.md @@ -12,12 +12,11 @@ ``` ~/.cc-switch/ -├── cc-switch.db # SQLite データベース +├── cc-switch.db # SQLite データベース(SSOT) ├── settings.json # デバイスレベルの設定 -└── backups/ # 自動バックアップ - ├── backup-20251230-120000.json - ├── backup-20251229-180000.json - └── ... +├── skills/ # スキル SSOT ディレクトリ +├── skill-backups/ # スキルバックアップ(アンインストール時に作成) +└── db_backup_*.db # データベースバックアップ ``` ### データベースの内容 diff --git a/docs/user-manual/ja/5-faq/5.2-questions.md b/docs/user-manual/ja/5-faq/5.2-questions.md index 71eb74b4c..313348b5a 100644 --- a/docs/user-manual/ja/5-faq/5.2-questions.md +++ b/docs/user-manual/ja/5-faq/5.2-questions.md @@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage 2. 最新版を手動でダウンロードしてインストール 3. Homebrew を使用する場合:`brew upgrade --cask cc-switch` +## ライトウェイトモード + +### ライトウェイトモードに入るには? + +システムトレイメニューから「ライトウェイトモード」をトグルします。メインウィンドウが閉じ、CC Switch はトレイ専用アプリとして動作します。再度トグルするか「メインウィンドウを開く」をクリックすると終了します。 + +### ライトウェイトモードではメモリ使用量が少なくなる? + +はい。ライトウェイトモードではメインウィンドウとその Web ビューを破棄するため、トレイメニュー機能を維持しながらメモリ使用量を大幅に削減します。 + ## ヘルプの入手 ### Issue の提出 diff --git a/docs/user-manual/ja/README.md b/docs/user-manual/ja/README.md index 7c5f11afe..f6a738449 100644 --- a/docs/user-manual/ja/README.md +++ b/docs/user-manual/ja/README.md @@ -24,7 +24,9 @@ CC Switch ユーザーマニュアル ├── 3. 拡張機能 │ ├── 3.1 MCP サーバー管理 │ ├── 3.2 Prompts プロンプト管理 -│ └── 3.3 Skills スキル管理 +│ ├── 3.3 Skills スキル管理 +│ ├── 3.4 セッションマネージャー +│ └── 3.5 ワークスペースとメモリー │ ├── 4. プロキシと高可用性 │ ├── 4.1 プロキシサービス @@ -69,6 +71,8 @@ CC Switch ユーザーマニュアル | [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP プロトコル、サーバーの追加、アプリバインド | | [3.2-prompts.md](./3-extensions/3.2-prompts.md) | プリセットの作成、有効化の切り替え、スマートバックフィル | | [3.3-skills.md](./3-extensions/3.3-skills.md) | スキルの発見、インストール・アンインストール、リポジトリ管理 | +| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | セッションマネージャー:閲覧、検索、再開、削除 | +| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | ワークスペースファイルとデイリーメモリー(OpenClaw) | ### 4. プロキシと高可用性 @@ -99,9 +103,9 @@ CC Switch ユーザーマニュアル ## バージョン情報 -- ドキュメントバージョン:v3.12.0 -- 最終更新:2026-03-09 -- CC Switch v3.12.0+ 対応 +- ドキュメントバージョン:v3.12.3 +- 最終更新:2026-04-04 +- CC Switch v3.12.3+ 対応 ## コントリビュート diff --git a/docs/user-manual/zh/1-getting-started/1.3-interface.md b/docs/user-manual/zh/1-getting-started/1.3-interface.md index 11554a1a0..e7a1eb252 100644 --- a/docs/user-manual/zh/1-getting-started/1.3-interface.md +++ b/docs/user-manual/zh/1-getting-started/1.3-interface.md @@ -100,10 +100,13 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。 | 菜单项 | 功能 | |--------|------| | 打开主界面 | 显示主窗口并聚焦 | -| 应用分组 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组显示供应商 | -| 供应商列表 | 点击切换,当前启用的显示勾选标记 | +| 应用子菜单 | 按 Claude/Codex/Gemini/OpenCode/OpenClaw 分组的折叠子菜单(如 "Claude · PackyCode") | +| 供应商列表 | 在每个子菜单内,点击切换,当前启用的显示勾选标记 | +| 轻量模式 | 勾选框切换,进入/退出仅托盘运行模式 | | 退出 | 完全退出应用 | +> **注意**:每个应用子菜单的标题会显示当前供应商名称(如 "Claude · PackyCode")。没有配置供应商的应用会显示禁用的"(无供应商)"条目。应用可见性由设置中的"应用可见性"选项控制。 + ### 多语言支持 托盘菜单支持三种语言,根据设置自动切换: @@ -114,6 +117,17 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。 | English | Open main window | Quit | | 日本語 | メインウィンドウを開く | 終了 | +### 轻量模式 + +托盘菜单包含 **轻量模式** 切换开关(勾选框)。启用后: + +- 主窗口关闭以释放资源 +- 应用仅在系统托盘中运行 +- 你仍可通过托盘子菜单切换供应商 +- 在 macOS 上,Dock 图标也会隐藏 + +要退出轻量模式,取消勾选该选项或点击"打开主界面" — 主窗口将被重建并显示。 + ### 使用场景 托盘切换供应商无需打开主界面,适合: @@ -121,6 +135,7 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。 - 频繁切换供应商 - 主窗口最小化时快速操作 - 后台运行时管理配置 +- 使用轻量模式以最小化资源占用 ## 设置页面 diff --git a/docs/user-manual/zh/1-getting-started/1.4-quickstart.md b/docs/user-manual/zh/1-getting-started/1.4-quickstart.md index 63c4b3f67..f79b6a047 100644 --- a/docs/user-manual/zh/1-getting-started/1.4-quickstart.md +++ b/docs/user-manual/zh/1-getting-started/1.4-quickstart.md @@ -33,8 +33,10 @@ | 应用 | 生效方式 | |------|----------| | Claude Code | ✅ 即时生效(支持热重载) | -| Codex | 需关闭并重新打开终端 | +| Codex | 需要关闭并重新打开终端 | | Gemini | ✅ 即时生效(每次请求重新读取配置) | +| OpenCode | 需要关闭并重新打开终端 | +| OpenClaw | 需要关闭并重新打开终端 | ### Claude Code 首次安装提示 @@ -64,6 +66,14 @@ codex # Gemini - 启动后输入测试问题 gemini > 你好,请简单介绍一下自己 + +# OpenCode - 启动后输入测试问题 +opencode +> 你好,请简单介绍一下自己 + +# OpenClaw - 启动后输入测试问题 +openclaw +> 你好,请简单介绍一下自己 ``` 如果 AI 能正常回复,说明配置成功。 diff --git a/docs/user-manual/zh/1-getting-started/1.5-settings.md b/docs/user-manual/zh/1-getting-started/1.5-settings.md index 3f9a591cb..2ac4748b4 100644 --- a/docs/user-manual/zh/1-getting-started/1.5-settings.md +++ b/docs/user-manual/zh/1-getting-started/1.5-settings.md @@ -167,37 +167,74 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。 ### 备份与恢复 -管理自动备份: +备份管理面板提供对数据库备份的全面控制。 -| 配置 | 说明 | -| -------- | -------------------------- | -| 备份间隔 | 自动备份的时间间隔(小时) | -| 保留数量 | 保留的备份份数 | +#### 自动备份设置 -支持查看备份列表和从备份恢复。 +| 配置 | 选项 | 默认值 | +| -------- | ---------------------------------- | --------- | +| 备份间隔 | 禁用、6h、12h、24h、48h、7d | 24 小时 | +| 保留数量 | 3、5、10、15、20、30、50 | 10 个备份 | + +设置间隔后,CC Switch 会按计划自动备份数据库。超出保留数量的旧备份会自动删除。 + +#### 备份列表 + +面板显示所有现有备份,包含: +- **显示名称**(根据时间戳自动生成,如 `db_backup_20260315_143000`) +- **创建时间** +- **文件大小**(如 "1.5 MB") + +#### 备份操作 + +| 操作 | 说明 | +| ------------ | ------------------------------------------------------------------------ | +| **立即备份** | 立即创建一个备份 | +| **恢复** | 从选定的备份恢复数据库。恢复前会自动创建当前数据库的安全备份 | +| **重命名** | 修改备份的显示名称 | +| **删除** | 永久删除备份(需确认) | + +> ⚠️ **重要**:恢复备份会覆盖当前数据库。恢复操作前始终会自动创建安全备份,以便在需要时恢复。 ### 云同步(WebDAV) -通过 WebDAV 协议在多台设备间同步配置。 +通过 WebDAV 协议在多台设备间同步配置。使用 **v2 协议**,支持双层版本控制,提高可靠性。 -| 配置项 | 说明 | -| -------- | ------------------------------------- | -| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 | -| 服务地址 | WebDAV 服务器 URL | -| 用户名 | 登录用户名 | -| 密码 | 登录密码(应用专用密码) | -| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) | -| 配置名称 | 设备配置文件名(默认 `default`) | -| 自动同步 | 开启后自动上传变更 | +| 配置项 | 说明 | +| -------- | ---------------------------------------------------------------------------- | +| 服务预设 | 坚果云 / Nextcloud / 群晖 / 自定义 | +| 服务地址 | WebDAV 服务器 URL | +| 用户名 | 登录用户名 | +| 密码 | 登录密码(应用专用密码;已保存的凭据在未修改时会被保留) | +| 远程目录 | 远程存储路径(默认 `cc-switch-sync`) | +| 配置名称 | 设备配置文件名(默认 `default`) | +| 自动同步 | 开启后按配置的间隔自动同步 | -操作: +#### 操作 -- **测试连接**:验证 WebDAV 配置是否正确 -- **保存**:保存配置并自动测试 -- **上传**:将本地数据上传到远程 -- **下载**:从远程下载数据到本地 +| 操作 | 说明 | +| ------------ | -------------------------------------------------------------------------------------------- | +| **测试连接** | 验证 WebDAV URL、用户名和密码是否有效 | +| **上传** | 将本地数据库上传到远程,显示进度指示器 | +| **下载** | 下载远程数据库。下载前显示远程快照信息(协议版本、数据库版本、时间戳、大小)供确认。覆盖本地数据库前会自动创建安全备份 | -> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。操作前请确认。 +#### 自动同步 + +启用 **自动同步** 后: +- 首次激活时会显示确认对话框 +- CC Switch 按配置的间隔自动将数据库同步到 WebDAV +- 面板中显示同步状态 + +#### 远程快照信息 + +下载前,CC Switch 会显示远程快照的详细信息: +- 协议版本(v2) +- 数据库兼容版本 +- 远程备份的时间戳 +- 文件大小 +- 兼容性状态(不兼容时显示警告) + +> ⚠️ **注意**:上传会覆盖远程数据,下载会覆盖本地数据。下载前始终会自动创建安全备份。 ### 日志配置 diff --git a/docs/user-manual/zh/2-providers/2.1-add.md b/docs/user-manual/zh/2-providers/2.1-add.md index dec61e122..563476837 100644 --- a/docs/user-manual/zh/2-providers/2.1-add.md +++ b/docs/user-manual/zh/2-providers/2.1-add.md @@ -152,6 +152,22 @@ | AWS Bedrock | AWS Bedrock 服务 | | OpenAI Compatible | OpenAI 兼容接口 | +## 自动获取模型 + +添加或编辑供应商时,可以自动从供应商端点获取可用模型列表: + +1. 确保已填写 **API Key** 和 **端点地址** +2. 点击模型输入框旁的 **获取模型** 按钮(下载图标) +3. CC Switch 调用供应商的 `/v1/models` 端点获取模型列表 +4. 从按供应商分组的下拉菜单中选择模型 + +此功能适用于所有支持 OpenAI 兼容 `/v1/models` API 的供应商。Claude、Codex、Gemini、OpenCode 和 OpenClaw 供应商均可使用。 + +**常见错误**: +- **认证失败(401/403)**:检查你的 API Key +- **端点不支持(404/405)**:该供应商未提供 `/v1/models` 端点 +- **超时**:端点响应缓慢,请稍后重试 + ## 自定义配置 选择「自定义」预设后,需要手动编辑 JSON 配置。 @@ -316,6 +332,45 @@ CC Switch 支持两种方式导入供应商配置: ## 高级选项 +### API 格式(仅 Claude) + +添加使用第三方 API 的 Claude 供应商时,可能需要在高级选项中选择正确的 **API 格式**: + +| 格式 | 说明 | 适用场景 | +|------|------|----------| +| **Anthropic Messages** | 原生 Anthropic API 格式(默认) | 直接 Anthropic API 或兼容代理 | +| **OpenAI Chat Completions** | OpenAI Chat API 格式,由代理自动转换 | 供应商仅支持 OpenAI Chat 格式 | +| **OpenAI Responses API** | OpenAI Responses API 格式,由代理自动转换 | 供应商仅支持 OpenAI Responses 格式 | + +> **注意**:API 格式转换由代理服务处理。使用非 Anthropic 格式时,需要开启代理并启用应用接管才能正确转换请求/响应。详见 [4.1 代理服务](../4-proxy/4.1-service.md)。 + +当配置了非默认 API 格式时,高级选项区域会自动展开。 + +### Claude 通用配置快捷开关 + +编辑 Claude 供应商时,JSON 编辑器上方提供一组 **快捷开关**: + +| 开关 | 效果 | 配置变更 | +|------|------|----------| +| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` | +| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` | +| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` | +| **高效能模式** | 将 effort 级别设为 high | 设置 `effortLevel = "high"` | +| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` | + +取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。 + +此外,**写入通用配置** 复选框可将全局配置片段合并到供应商中。点击 **编辑通用配置** 可自定义共享的配置片段。 + +### Codex 1M 上下文窗口 + +添加 Codex 供应商时,提供 **启用 1M 上下文窗口** 开关: + +- **启用时**:在 config.toml 中设置 `model_context_window = 1000000` 并自动填充 `model_auto_compact_token_limit = 900000` +- **禁用时**:移除这两个字段 + +开关开启后显示的文本框可自定义自动压缩限制值。 + ### 自定义图标 点击名称左侧的图标区域,可以: diff --git a/docs/user-manual/zh/2-providers/2.2-switch.md b/docs/user-manual/zh/2-providers/2.2-switch.md index e61998f67..fcc79a27f 100644 --- a/docs/user-manual/zh/2-providers/2.2-switch.md +++ b/docs/user-manual/zh/2-providers/2.2-switch.md @@ -26,10 +26,12 @@ ### 操作步骤 1. 右键点击系统托盘的 CC Switch 图标 -2. 在菜单中找到对应应用(Claude/Codex/Gemini/OpenCode) -3. 点击要切换的供应商名称 +2. 将鼠标悬停在对应应用的子菜单上(如 "Claude · 当前供应商") +3. 点击要切换到的供应商名称 4. 切换完成,托盘会短暂提示 +> 供应商按应用类型(Claude/Codex/Gemini)组织到折叠子菜单中。子菜单标题显示当前激活的供应商名称。 + ### 托盘菜单结构 ![image-20260108004348993](../../assets/image-20260108004348993.png) diff --git a/docs/user-manual/zh/2-providers/2.3-edit.md b/docs/user-manual/zh/2-providers/2.3-edit.md index cc8c494ad..4909c7a4b 100644 --- a/docs/user-manual/zh/2-providers/2.3-edit.md +++ b/docs/user-manual/zh/2-providers/2.3-edit.md @@ -52,6 +52,20 @@ JSON 格式的配置内容,包括: 这确保了 CC Switch 和 CLI 工具的配置始终同步。 +## 自动获取模型 + +编辑供应商时,可以自动从供应商端点获取可用模型列表: + +1. 确保已填写 API Key 和端点地址 +2. 点击模型输入框旁的 **获取模型** 按钮(下载图标) +3. 从分组下拉菜单中选择模型 + +详细说明请参阅 [2.1 添加供应商 — 自动获取模型](./2.1-add.md#自动获取模型)。 + +## 通用配置快捷开关(Claude) + +编辑 Claude 供应商时,JSON 编辑器上方提供常用设置的快捷开关,包括工具搜索、禁用自动更新、Teammates、高效能模式等。详见 [2.1 添加供应商 — Claude 通用配置快捷开关](./2.1-add.md#claude-通用配置快捷开关)。 + ## 修改 API Key 编辑供应商时,可以直接在 **API Key** 输入框中修改: diff --git a/docs/user-manual/zh/3-extensions/3.3-skills.md b/docs/user-manual/zh/3-extensions/3.3-skills.md index ce48fdb06..5354f9235 100644 --- a/docs/user-manual/zh/3-extensions/3.3-skills.md +++ b/docs/user-manual/zh/3-extensions/3.3-skills.md @@ -118,8 +118,27 @@ CC Switch 提供强大的搜索和过滤功能: ### 卸载效果 -- 删除本地技能文件夹 -- 更新安装状态 +- **自动备份**:删除前,技能会被备份到 `~/.cc-switch/skill-backups/` +- 从所有应用目录(Claude、Codex、Gemini、OpenCode)移除技能 +- 从 SSOT 目录(`~/.cc-switch/skills/`)移除技能 +- 从数据库删除技能记录 + +### 从备份恢复 + +如需恢复之前卸载的技能: + +1. 打开 Skills 页面 +2. 点击 **从备份恢复** 按钮 +3. 从列表中选择要恢复的备份(显示技能名称和备份日期) +4. 技能将被恢复并为当前应用启用 + +### 删除备份 + +如需删除旧的技能备份: + +1. 在恢复对话框中,找到要删除的备份 +2. 点击备份条目旁的 **删除** 按钮 +3. 确认删除 — 此操作不可撤销 ## 仓库管理 diff --git a/docs/user-manual/zh/3-extensions/3.4-sessions.md b/docs/user-manual/zh/3-extensions/3.4-sessions.md new file mode 100644 index 000000000..ecd4c7759 --- /dev/null +++ b/docs/user-manual/zh/3-extensions/3.4-sessions.md @@ -0,0 +1,131 @@ +# 3.4 会话管理器 + +会话管理器可让你在一处浏览、搜索和管理所有支持的 CLI 工具的对话会话。 + +## 支持的应用 + +| 应用 | 会话存储位置 | +|------|-------------| +| Claude Code | `~/.cache/claude/projects/*.jsonl` | +| Codex | Codex 配置会话目录 | +| OpenCode | `~/.local/share/opencode/`(JSON 或 SQLite) | +| OpenClaw | `~/.openclaw/agents//sessions/*.jsonl` | +| Gemini CLI | `~/.cache/gemini/tmp//chats/` | + +## 打开会话管理器 + +点击主导航栏中的 **会话** 按钮。 + +> **注意**:会话按钮在所有五种应用模式下均可见。 + +## 界面布局 + +会话管理器采用 **双栏布局**: + +- **左侧面板**:会话列表,包含搜索和过滤工具栏 +- **右侧面板**:选中会话的详情和对话历史 + +### 会话列表(左侧面板) + +每个会话条目显示: +- 供应商图标 +- 会话标题 +- 最后活跃时间(相对格式,如"5 分钟前") + +### 会话详情(右侧面板) + +选中会话后,右侧面板显示: +- **标题**:来自会话标题、项目目录名称或会话 ID +- **最后活跃日期/时间**:完整时间戳 +- **项目目录**:可点击复制完整路径(显示目录名,完整路径以提示框显示) +- **恢复命令**:以等宽字体显示(如可用) +- **对话历史**:完整消息记录 + +## 搜索与过滤 + +### 全文搜索 + +使用左侧面板顶部的搜索框,可搜索以下内容: +- 会话 ID +- 标题 +- 摘要 +- 项目目录 +- 源文件路径 + +搜索支持前缀匹配,实时过滤结果。按 **Esc** 清除搜索。 + +### 供应商过滤 + +点击左侧面板右上角的供应商过滤下拉菜单,按应用过滤: +- **全部** — 显示所有供应商的会话 +- **Claude Code** +- **Codex** +- **OpenCode** +- **OpenClaw** +- **Gemini CLI** + +过滤可与搜索组合使用。 + +### 刷新 + +点击刷新按钮(圆形箭头图标),重新扫描所有供应商目录以发现新增或已删除的会话。 + +## 会话操作 + +### 恢复会话 + +点击选中会话上的 **恢复** 按钮(播放图标)继续对话。 + +**macOS 上**: +- CC Switch 使用你偏好的终端启动恢复命令 +- 终端会在会话的项目目录中打开 +- 如果终端启动失败,命令会被复制到剪贴板 + +**支持的终端(macOS)**:Terminal.app、iTerm2、Ghostty、Kitty、WezTerm、Alacritty + +**其他平台**: +- 恢复命令会被复制到剪贴板 +- 在终端中粘贴即可恢复会话 + +> 如果会话没有可用的恢复命令,恢复按钮将被禁用。 + +### 删除会话 + +点击 **删除** 按钮(垃圾桶图标)永久删除会话文件。删除前会显示确认对话框。 + +> 没有本地源路径的会话(如不可变会话)无法删除。 + +### 批量操作 + +批量管理多个会话: + +1. 点击左侧面板工具栏中的 **批量模式** 按钮(复选框图标) +2. 使用出现的复选框选择会话 +3. 使用 **全选** 选择所有过滤结果,或使用 **清除** 取消选择 +4. 点击 **批量删除**(红色垃圾桶图标)删除所有选中的会话 + +删除前会显示确认对话框并展示数量。结果会报告成功删除的数量和失败的数量。 + +## 对话历史 + +### 消息显示 + +消息按角色以颜色区分: +- **用户** 消息:绿色,左对齐 +- **AI**(助手)消息:蓝色,右对齐 +- **系统** 消息:琥珀色 +- **工具** 消息:紫色 + +### 目录导航 + +对于较长的对话,提供目录导航功能: +- **桌面端(XL+ 屏幕)**:右侧边栏显示用户消息预览 +- **较小屏幕**:右下角浮动按钮(列表图标),点击打开对话框 + +点击任意条目可滚动到对应消息,消息会短暂高亮显示。 + +## 提示 + +- 会话按最后活跃时间排序(最新在前) +- 会话数量徽章会随搜索和过滤实时更新 +- OpenCode 会话可能来自 JSON 文件和 SQLite 数据库 — 重复项会自动去重 diff --git a/docs/user-manual/zh/3-extensions/3.5-workspace.md b/docs/user-manual/zh/3-extensions/3.5-workspace.md new file mode 100644 index 000000000..679f88aeb --- /dev/null +++ b/docs/user-manual/zh/3-extensions/3.5-workspace.md @@ -0,0 +1,85 @@ +# 3.5 工作区文件与每日记忆 + +## 概述 + +工作区面板为 **OpenClaw** 提供文件管理和每日记忆功能。你可以编辑工作区配置文件并维护每日记忆日志。 + +> 此功能为 OpenClaw 专属。当 OpenClaw 为当前选中的应用时,导航栏中会显示工作区按钮。 + +## 工作区文件 + +### 文件位置 + +所有工作区文件存储在 `~/.openclaw/workspace/`。 + +点击面板顶部的目录路径可在文件管理器中打开。 + +### 可用文件 + +CC Switch 管理 9 个工作区文件,每个文件有特定用途: + +| 文件 | 说明 | +|------|------| +| **AGENTS.md** | Agent 定义与配置 | +| **SOUL.md** | 系统灵魂/性格设置 | +| **USER.md** | 用户资料信息 | +| **IDENTITY.md** | 身份与角色定义 | +| **TOOLS.md** | 可用工具配置 | +| **MEMORY.md** | 系统记忆 | +| **HEARTBEAT.md** | 心跳配置 | +| **BOOTSTRAP.md** | 引导序列 | +| **BOOT.md** | 启动配置 | + +### 文件状态 + +每个文件显示状态指示器: +- **绿色对勾**:文件已存在 +- **空心圆圈**:文件尚未创建(首次保存时创建) + +### 编辑文件 + +1. 点击任意文件卡片打开 Markdown 编辑器 +2. 编辑内容 +3. 点击 **保存** 将更改写入磁盘 + +如果文件尚不存在,将在首次保存时创建。 + +## 每日记忆 + +每日记忆功能提供按日期组织的日志系统,存储在 `~/.openclaw/workspace/memory/`。 + +### 访问每日记忆 + +点击工作区文件网格中的 **每日记忆** 卡片,打开记忆面板。 + +### 文件列表 + +面板显示所有每日记忆文件,按日期排序(最新在前)。每个条目显示: +- **日期**(从文件名格式化,如 `2026-04-01.md`) +- **文件大小** +- **预览**(内容的前 2 行) + +### 创建今日笔记 + +点击 **创建今日** 按钮: +- 打开以今日日期命名的新笔记(`YYYY-MM-DD.md`) +- 如果今日笔记已存在,则打开进行编辑 +- 文件仅在点击保存后才会持久化 + +### 搜索 + +搜索所有每日记忆文件: + +1. 按 **Cmd/Ctrl+F** 或点击搜索图标 +2. 输入搜索词 +3. 结果显示匹配的文件,包含: + - 每个文件的匹配数量 + - 匹配行的片段预览 + - 文件日期和大小 + +按 **Esc** 关闭搜索。 + +### 编辑与删除 + +- **编辑**:点击文件条目在 Markdown 编辑器中打开 +- **删除**:将鼠标悬停在文件条目上,点击删除图标。会显示确认对话框 — 删除操作不可撤销。 diff --git a/docs/user-manual/zh/4-proxy/4.1-service.md b/docs/user-manual/zh/4-proxy/4.1-service.md index 7da918447..0dd12b6ef 100644 --- a/docs/user-manual/zh/4-proxy/4.1-service.md +++ b/docs/user-manual/zh/4-proxy/4.1-service.md @@ -150,6 +150,20 @@ base_url = "http://127.0.0.1:15721/v1" GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721 ``` +## API 格式转换 + +代理支持为配置了非 Anthropic 格式的供应商自动进行 API 格式转换。这使你可以将仅支持 OpenAI 兼容 API 的供应商用于 Claude Code。 + +| 供应商 API 格式 | 代理行为 | +|-----------------|----------| +| **Anthropic Messages** | 透传(不转换) | +| **OpenAI Chat Completions** | 将 Anthropic 请求转换为 OpenAI Chat 格式,响应反向转换 | +| **OpenAI Responses API** | 将 Anthropic 请求转换为 OpenAI Responses 格式,响应反向转换 | + +API 格式在添加或编辑 Claude 供应商时于[高级选项](../2-providers/2.1-add.md#api-格式仅-claude)中按供应商配置。 + +> **注意**:格式转换需要代理运行并启用应用接管。转换同时支持流式和非流式请求。 + ## 停止代理 ### 方式一:主界面开关 diff --git a/docs/user-manual/zh/5-faq/5.1-config-files.md b/docs/user-manual/zh/5-faq/5.1-config-files.md index 83ec2e743..5d19c8425 100644 --- a/docs/user-manual/zh/5-faq/5.1-config-files.md +++ b/docs/user-manual/zh/5-faq/5.1-config-files.md @@ -12,12 +12,11 @@ ``` ~/.cc-switch/ -├── cc-switch.db # SQLite 数据库 +├── cc-switch.db # SQLite 数据库(SSOT) ├── settings.json # 设备级设置 -└── backups/ # 自动备份 - ├── backup-20251230-120000.json - ├── backup-20251229-180000.json - └── ... +├── skills/ # 技能 SSOT 目录 +├── skill-backups/ # 技能备份(卸载时创建) +└── db_backup_*.db # 数据库备份 ``` ### 数据库内容 diff --git a/docs/user-manual/zh/5-faq/5.2-questions.md b/docs/user-manual/zh/5-faq/5.2-questions.md index 150038af6..3f0a5e82d 100644 --- a/docs/user-manual/zh/5-faq/5.2-questions.md +++ b/docs/user-manual/zh/5-faq/5.2-questions.md @@ -183,6 +183,16 @@ chmod +x CC-Switch-*.AppImage 2. 手动下载最新版本安装 3. 如使用 Homebrew:`brew upgrade --cask cc-switch` +## 轻量模式 + +### 如何进入轻量模式? + +从系统托盘菜单切换"轻量模式"。主窗口关闭,CC Switch 仅作为托盘应用运行。再次切换或点击"打开主界面"即可退出。 + +### 轻量模式下应用占用更少内存? + +是的。轻量模式会销毁主窗口及其 Web 视图,显著减少内存占用,同时保留托盘菜单功能。 + ## 获取帮助 ### 提交 Issue diff --git a/docs/user-manual/zh/README.md b/docs/user-manual/zh/README.md index 02e312402..918194bde 100644 --- a/docs/user-manual/zh/README.md +++ b/docs/user-manual/zh/README.md @@ -24,7 +24,9 @@ ├── 3. 扩展功能 │ ├── 3.1 MCP 服务器管理 │ ├── 3.2 Prompts 提示词管理 -│ └── 3.3 Skills 技能管理 +│ ├── 3.3 Skills 技能管理 +│ ├── 3.4 会话管理器 +│ └── 3.5 工作区文件与每日记忆 │ ├── 4. 代理与高可用 │ ├── 4.1 代理服务 @@ -69,6 +71,8 @@ | [3.1-mcp.md](./3-extensions/3.1-mcp.md) | MCP 协议、添加服务器、应用绑定 | | [3.2-prompts.md](./3-extensions/3.2-prompts.md) | 创建预设、激活切换、智能回填 | | [3.3-skills.md](./3-extensions/3.3-skills.md) | 发现技能、安装卸载、仓库管理 | +| [3.4-sessions.md](./3-extensions/3.4-sessions.md) | 会话浏览、搜索过滤、恢复与删除 | +| [3.5-workspace.md](./3-extensions/3.5-workspace.md) | OpenClaw 工作区文件、每日记忆 | ### 4. 代理与高可用 @@ -99,9 +103,9 @@ ## 版本信息 -- 文档版本:v3.12.0 -- 最后更新:2026-03-09 -- 适用于 CC Switch v3.12.0+ +- 文档版本:v3.12.3 +- 最后更新:2026-04-04 +- 适用于 CC Switch v3.12.3+ ## 贡献 From b30f3c27ad9439d8247e7fb474c7da3ead3c3ac6 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 17:18:42 +0800 Subject: [PATCH 006/128] feat: display official subscription quota on Claude provider cards Read Claude OAuth credentials from macOS Keychain (with file fallback) and query the Anthropic usage API to show quota utilization inline on official provider cards. Includes compact countdown timer for reset windows and hides the rarely-used seven_day_sonnet tier in inline mode. --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/subscription.rs | 10 + src-tauri/src/lib.rs | 2 + src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/subscription.rs | 460 +++++++++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 359 ++++++++++++++++ src/components/providers/ProviderCard.tsx | 14 +- src/i18n/locales/en.json | 14 + src/i18n/locales/ja.json | 14 + src/i18n/locales/zh.json | 14 + src/lib/api/index.ts | 1 + src/lib/api/subscription.ts | 7 + src/lib/query/index.ts | 1 + src/lib/query/subscription.ts | 17 + src/types/subscription.ts | 30 ++ 15 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/subscription.rs create mode 100644 src-tauri/src/services/subscription.rs create mode 100644 src/components/SubscriptionQuotaFooter.tsx create mode 100644 src/lib/api/subscription.ts create mode 100644 src/lib/query/subscription.ts create mode 100644 src/types/subscription.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a751fb54a..fed30fe30 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ mod session_manager; mod settings; pub mod skill; mod stream_check; +mod subscription; mod sync_support; mod lightweight; @@ -49,6 +50,7 @@ pub use session_manager::*; pub use settings::*; pub use skill::*; pub use stream_check::*; +pub use subscription::*; pub use lightweight::*; pub use usage::*; diff --git a/src-tauri/src/commands/subscription.rs b/src-tauri/src/commands/subscription.rs new file mode 100644 index 000000000..00b456eb7 --- /dev/null +++ b/src-tauri/src/commands/subscription.rs @@ -0,0 +1,10 @@ +use crate::services::subscription::SubscriptionQuota; + +/// 查询官方订阅额度 +/// +/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。 +/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。 +#[tauri::command] +pub async fn get_subscription_quota(tool: String) -> Result { + crate::services::subscription::get_subscription_quota(&tool).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cf06fa896..43bc47757 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -888,6 +888,8 @@ pub fn run() { // usage query commands::queryProviderUsage, commands::testUsageScript, + // subscription quota + commands::get_subscription_quota, // New MCP via config.json (SSOT) commands::get_mcp_config, commands::upsert_mcp_server_in_config, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index e84de582e..a7c75e162 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod proxy; pub mod skill; pub mod speedtest; pub mod stream_check; +pub mod subscription; pub mod usage_stats; pub mod webdav; pub mod webdav_auto_sync; diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs new file mode 100644 index 000000000..47f0957dd --- /dev/null +++ b/src-tauri/src/services/subscription.rs @@ -0,0 +1,460 @@ +//! 官方订阅额度查询服务 +//! +//! 读取 CLI 工具的已有 OAuth 凭据,查询官方订阅额度。 +//! 第一层:仅读取凭据,不实现登录/刷新。 + +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config; + +// ── 数据类型 ────────────────────────────────────────────── + +/// 凭据状态 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialStatus { + Valid, + Expired, + NotFound, + ParseError, +} + +/// 单个限速窗口(如 5小时会话、7天周期) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaTier { + /// 窗口标识:five_hour, seven_day, seven_day_opus, seven_day_sonnet 等 + pub name: String, + /// 使用百分比 0–100 + pub utilization: f64, + /// ISO 8601 重置时间 + pub resets_at: Option, +} + +/// 超额使用信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtraUsage { + pub is_enabled: bool, + pub monthly_limit: Option, + pub used_credits: Option, + pub utilization: Option, + pub currency: Option, +} + +/// 订阅额度查询结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionQuota { + pub tool: String, + pub credential_status: CredentialStatus, + pub credential_message: Option, + pub success: bool, + pub tiers: Vec, + pub extra_usage: Option, + pub error: Option, + pub queried_at: Option, +} + +impl SubscriptionQuota { + fn not_found(tool: &str) -> Self { + Self { + tool: tool.to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + } + } + + fn error(tool: &str, status: CredentialStatus, message: String) -> Self { + Self { + tool: tool.to_string(), + credential_status: status, + credential_message: Some(message.clone()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(message), + queried_at: Some(now_millis()), + } + } +} + +// ── Claude 凭据读取 ────────────────────────────────────── + +/// Claude OAuth 凭据文件中的嵌套结构 +#[derive(Deserialize)] +struct ClaudeOAuthEntry { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "expiresAt")] + expires_at: Option, +} + +/// 读取 Claude OAuth 凭据 +/// +/// 按优先级尝试以下来源: +/// 1. macOS Keychain (service: "Claude Code-credentials") +/// 2. 凭据文件 ~/.claude/.credentials.json +/// +/// JSON 格式(两种 key 都兼容): +/// {"claudeAiOauth": {"accessToken": "...", "expiresAt": ...}} +/// {"claude.ai_oauth": {"accessToken": "...", "expiresAt": ...}} +fn read_claude_credentials() -> (Option, CredentialStatus, Option) { + // 来源 1: macOS Keychain + #[cfg(target_os = "macos")] + { + if let Some(result) = read_claude_credentials_from_keychain() { + return result; + } + } + + // 来源 2: 凭据文件 + read_claude_credentials_from_file() +} + +/// 从 macOS Keychain 读取 Claude 凭据 +#[cfg(target_os = "macos")] +fn read_claude_credentials_from_keychain( +) -> Option<(Option, CredentialStatus, Option)> { + let output = std::process::Command::new("security") + .args([ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; // Keychain 中无此条目,回退到文件 + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let json_str = json_str.trim(); + if json_str.is_empty() { + return None; + } + + Some(parse_claude_credentials_json(json_str)) +} + +/// 从文件读取 Claude 凭据 +fn read_claude_credentials_from_file() -> (Option, CredentialStatus, Option) { + let cred_path = config::get_claude_config_dir().join(".credentials.json"); + + if !cred_path.exists() { + return (None, CredentialStatus::NotFound, None); + } + + let content = match std::fs::read_to_string(&cred_path) { + Ok(c) => c, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to read credentials file: {e}")), + ); + } + }; + + parse_claude_credentials_json(&content) +} + +/// 解析 Claude 凭据 JSON(Keychain 和文件共用) +fn parse_claude_credentials_json( + content: &str, +) -> (Option, CredentialStatus, Option) { + let parsed: serde_json::Value = match serde_json::from_str(content) { + Ok(v) => v, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse credentials JSON: {e}")), + ); + } + }; + + // 兼容两种 key 名 + let entry_value = parsed + .get("claudeAiOauth") + .or_else(|| parsed.get("claude.ai_oauth")); + + let entry_value = match entry_value { + Some(v) => v, + None => { + return ( + None, + CredentialStatus::ParseError, + Some("No OAuth entry found in credentials".to_string()), + ); + } + }; + + let entry: ClaudeOAuthEntry = match serde_json::from_value(entry_value.clone()) { + Ok(e) => e, + Err(e) => { + return ( + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse OAuth entry: {e}")), + ); + } + }; + + let access_token = match entry.access_token { + Some(t) if !t.is_empty() => t, + _ => { + return ( + None, + CredentialStatus::ParseError, + Some("accessToken is empty or missing".to_string()), + ); + } + }; + + // 检查 token 是否过期 + if let Some(expires_at) = entry.expires_at { + if is_token_expired(&expires_at) { + return ( + Some(access_token), + CredentialStatus::Expired, + Some("OAuth token has expired".to_string()), + ); + } + } + + (Some(access_token), CredentialStatus::Valid, None) +} + +/// 判断 token 是否过期,兼容 Unix 时间戳(秒/毫秒)和 ISO 字符串 +fn is_token_expired(expires_at: &serde_json::Value) -> bool { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + match expires_at { + serde_json::Value::Number(n) => { + if let Some(ts) = n.as_u64() { + // 区分秒和毫秒(毫秒级时间戳大于 1e12) + let ts_secs = if ts > 1_000_000_000_000 { + ts / 1000 + } else { + ts + }; + ts_secs < now_secs + } else { + false + } + } + serde_json::Value::String(s) => { + // 尝试解析 ISO 8601 格式 + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + (dt.timestamp() as u64) < now_secs + } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") + { + (dt.and_utc().timestamp() as u64) < now_secs + } else { + false // 无法解析时不视为过期 + } + } + _ => false, + } +} + +// ── Claude API 查询 ────────────────────────────────────── + +/// Claude OAuth 用量 API 响应中的单个窗口 +#[derive(Deserialize)] +struct ApiUsageWindow { + utilization: Option, + resets_at: Option, +} + +/// Claude OAuth 用量 API 响应中的超额用量 +#[derive(Deserialize)] +struct ApiExtraUsage { + is_enabled: Option, + monthly_limit: Option, + used_credits: Option, + utilization: Option, + currency: Option, +} + +/// 已知的 Claude 用量窗口名称 +const KNOWN_TIERS: &[&str] = &[ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", +]; + +/// 查询 Claude 官方订阅额度 +async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.anthropic.com/api/oauth/usage") + .header("Authorization", format!("Bearer {access_token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("Network error: {e}"), + ); + } + }; + + let status = resp.status(); + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."), + ); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("API error (HTTP {status}): {body}"), + ); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "claude", + CredentialStatus::Valid, + format!("Failed to parse API response: {e}"), + ); + } + }; + + // 解析已知的 tier 窗口 + let mut tiers = Vec::new(); + for &tier_name in KNOWN_TIERS { + if let Some(window) = body.get(tier_name) { + if let Ok(w) = serde_json::from_value::(window.clone()) { + if let Some(util) = w.utilization { + tiers.push(QuotaTier { + name: tier_name.to_string(), + utilization: util, + resets_at: w.resets_at, + }); + } + } + } + } + + // 也解析未知窗口(API 可能返回新的窗口类型) + if let Some(obj) = body.as_object() { + for (key, value) in obj { + if key == "extra_usage" || KNOWN_TIERS.contains(&key.as_str()) { + continue; + } + if let Ok(w) = serde_json::from_value::(value.clone()) { + if let Some(util) = w.utilization { + tiers.push(QuotaTier { + name: key.clone(), + utilization: util, + resets_at: w.resets_at, + }); + } + } + } + } + + // 解析超额使用 + let extra_usage = body.get("extra_usage").and_then(|v| { + serde_json::from_value::(v.clone()) + .ok() + .map(|e| ExtraUsage { + is_enabled: e.is_enabled.unwrap_or(false), + monthly_limit: e.monthly_limit, + used_credits: e.used_credits, + utilization: e.utilization, + currency: e.currency, + }) + }); + + SubscriptionQuota { + tool: "claude".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 入口函数 ────────────────────────────────────────────── + +/// 查询指定 CLI 工具的官方订阅额度 +pub async fn get_subscription_quota(tool: &str) -> Result { + match tool { + "claude" => { + let (token, status, message) = read_claude_credentials(); + + match status { + CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("claude")), + CredentialStatus::ParseError => Ok(SubscriptionQuota::error( + "claude", + CredentialStatus::ParseError, + message.unwrap_or_else(|| "Failed to parse credentials".to_string()), + )), + CredentialStatus::Expired => { + // 即使过期也尝试调用 API(token 可能实际上仍有效) + if let Some(token) = token { + let result = query_claude_quota(&token).await; + if result.success { + return Ok(result); + } + } + Ok(SubscriptionQuota::error( + "claude", + CredentialStatus::Expired, + message.unwrap_or_else(|| "OAuth token has expired".to_string()), + )) + } + CredentialStatus::Valid => { + let token = token.expect("token must be Some when status is Valid"); + Ok(query_claude_quota(&token).await) + } + } + } + // Codex / Gemini: 暂不支持 + _ => Ok(SubscriptionQuota::not_found(tool)), + } +} + +// ── 辅助函数 ────────────────────────────────────────────── + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx new file mode 100644 index 000000000..6666f74ba --- /dev/null +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -0,0 +1,359 @@ +import React from "react"; +import { RefreshCw, AlertCircle, Clock } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { AppId } from "@/lib/api"; +import { useSubscriptionQuota } from "@/lib/query/subscription"; +import type { QuotaTier } from "@/types/subscription"; + +interface SubscriptionQuotaFooterProps { + appId: AppId; + inline?: boolean; +} + +/** 已知 tier 名称的显示映射 */ +const TIER_I18N_KEYS: Record = { + five_hour: "subscription.fiveHour", + seven_day: "subscription.sevenDay", + seven_day_opus: "subscription.sevenDayOpus", + seven_day_sonnet: "subscription.sevenDaySonnet", +}; + +/** 根据使用百分比返回颜色 class */ +function utilizationColor(utilization: number): string { + if (utilization >= 90) return "text-red-500 dark:text-red-400"; + if (utilization >= 70) return "text-orange-500 dark:text-orange-400"; + return "text-green-600 dark:text-green-400"; +} + +/** 计算倒计时的纯时间字符串,如 "2h30m"、"3d12h" */ +function countdownStr(resetsAt: string | null): string | null { + if (!resetsAt) return null; + const diffMs = new Date(resetsAt).getTime() - Date.now(); + if (diffMs <= 0) return null; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d${hours % 24}h`; + } + if (hours > 0) return `${hours}h${minutes}m`; + return `${minutes}m`; +} + +/** 格式化重置时间为倒计时文本(带 i18n 模板) */ +function formatResetTime( + resetsAt: string | null, + t: (key: string, options?: Record) => string, +): string | null { + const time = countdownStr(resetsAt); + if (!time) return null; + return t("subscription.resetsIn", { time }); +} + +/** 不需要在 inline 模式显示的 tier */ +const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]); + +/** 格式化相对时间(与 UsageFooter 一致) */ +function formatRelativeTime( + timestamp: number, + now: number, + t: (key: string, options?: { count?: number }) => string, +): string { + const diff = Math.floor((now - timestamp) / 1000); + if (diff < 60) return t("usage.justNow"); + if (diff < 3600) + return t("usage.minutesAgo", { count: Math.floor(diff / 60) }); + if (diff < 86400) + return t("usage.hoursAgo", { count: Math.floor(diff / 3600) }); + return t("usage.daysAgo", { count: Math.floor(diff / 86400) }); +} + +const SubscriptionQuotaFooter: React.FC = ({ + appId, + inline = false, +}) => { + const { t } = useTranslation(); + const { + data: quota, + isFetching: loading, + refetch, + } = useSubscriptionQuota(appId, true); + + // 定期更新相对时间显示 + const [now, setNow] = React.useState(Date.now()); + React.useEffect(() => { + if (!quota?.queriedAt) return; + const interval = setInterval(() => setNow(Date.now()), 30000); + return () => clearInterval(interval); + }, [quota?.queriedAt]); + + // 无凭据 → 不显示 + if (!quota || quota.credentialStatus === "not_found") return null; + + // 凭据解析错误 → 不显示(静默) + if (quota.credentialStatus === "parse_error") return null; + + // 凭据过期 + if (quota.credentialStatus === "expired" && !quota.success) { + if (inline) { + return ( +
+
+ + {t("subscription.expired")} +
+ +
+ ); + } + return ( +
+
+
+ +
+ {t("subscription.expired")} + + {t("subscription.expiredHint")} + +
+
+ +
+
+ ); + } + + // API 调用失败 + if (!quota.success) { + if (inline) { + return ( +
+
+ + {t("subscription.queryFailed")} +
+ +
+ ); + } + return ( +
+
+
+ + {quota.error || t("subscription.queryFailed")} +
+ +
+
+ ); + } + + // 成功获取数据 + const tiers = quota.tiers || []; + if (tiers.length === 0) return null; + + // ── inline 模式:紧凑两行显示 ── + if (inline) { + return ( +
+ {/* 第一行:查询时间 + 刷新 */} +
+ + + {quota.queriedAt + ? formatRelativeTime(quota.queriedAt, now, t) + : t("usage.never", { defaultValue: "从未更新" })} + + +
+ + {/* 第二行:各 tier 使用百分比 */} +
+ {tiers + .filter((tier) => !HIDDEN_INLINE_TIERS.has(tier.name)) + .map((tier) => ( + + ))} +
+
+ ); + } + + // ── 展开模式:详细信息 ── + return ( +
+
+ + {t("subscription.title", { defaultValue: "Subscription Quota" })} + +
+ {quota.queriedAt && ( + + + {formatRelativeTime(quota.queriedAt, now, t)} + + )} + +
+
+ +
+ {tiers.map((tier) => ( + + ))} +
+ + {/* 超额使用 */} + {quota.extraUsage?.isEnabled && ( +
+ {t("subscription.extraUsage")}: + + {quota.extraUsage.currency === "USD" ? "$" : ""} + {(quota.extraUsage.usedCredits ?? 0).toFixed(2)} + {quota.extraUsage.monthlyLimit != null && ( + <> + {" "} + / {quota.extraUsage.currency === "USD" ? "$" : ""} + {quota.extraUsage.monthlyLimit.toFixed(2)} + + )} + +
+ )} +
+ ); +}; + +/** inline 模式下的单个 tier 显示 */ +const TierBadge: React.FC<{ + tier: QuotaTier; + t: (key: string, options?: Record) => string; +}> = ({ tier, t }) => { + const label = TIER_I18N_KEYS[tier.name] + ? t(TIER_I18N_KEYS[tier.name]) + : tier.name; + const countdown = countdownStr(tier.resetsAt); + + return ( +
+ {label}: + + {t("subscription.utilization", { value: Math.round(tier.utilization) })} + + {countdown && ( + + + {countdown} + + )} +
+ ); +}; + +/** 展开模式下的单个 tier 进度条 */ +const TierBar: React.FC<{ + tier: QuotaTier; + t: (key: string, options?: Record) => string; +}> = ({ tier, t }) => { + const label = TIER_I18N_KEYS[tier.name] + ? t(TIER_I18N_KEYS[tier.name]) + : tier.name; + const resetText = formatResetTime(tier.resetsAt, t); + + return ( +
+ + {label} + + + {/* 进度条 */} +
+
= 90 + ? "bg-red-500" + : tier.utilization >= 70 + ? "bg-orange-500" + : "bg-green-500" + }`} + style={{ width: `${Math.min(tier.utilization, 100)}%` }} + /> +
+ +
+ + {Math.round(tier.utilization)}% + + {resetText && ( + + {resetText} + + )} +
+
+ ); +}; + +export default SubscriptionQuotaFooter; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index ab7f3bf07..289e27b3f 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"; import { ProviderActions } from "@/components/providers/ProviderActions"; import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; +import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; @@ -55,6 +56,14 @@ interface ProviderCardProps { onSetAsDefault?: () => void; } +/** 判断是否为官方供应商(无自定义 base URL,直连官方 API) */ +function isOfficialProvider(provider: Provider, appId: AppId): boolean { + if (appId !== "claude") return false; + const baseUrl = (provider.settingsConfig as Record)?.env + ?.ANTHROPIC_BASE_URL; + return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); +} + const extractApiUrl = (provider: Provider, fallbackText: string) => { if (provider.notes?.trim()) { return provider.notes.trim(); @@ -146,6 +155,7 @@ export function ProviderCard({ }, [provider.notes, displayUrl, fallbackUrlText]); const usageEnabled = provider.meta?.usage_script?.enabled ?? false; + const isOfficial = isOfficialProvider(provider, appId); // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent @@ -342,7 +352,9 @@ export function ProviderCard({ >
- {hasMultiplePlans ? ( + {isOfficial ? ( + + ) : hasMultiplePlans ? (
{t("usage.multiplePlans", { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ce540b17a..e3c0ffe5d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "Apply Suggested Defaults", "suggestedDefaultsHint": "Use this preset's recommended default model configuration" + }, + "subscription": { + "title": "Subscription Quota", + "fiveHour": "5-Hour", + "sevenDay": "7-Day", + "sevenDayOpus": "7-Day (Opus)", + "sevenDaySonnet": "7-Day (Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "Resets in {{time}}", + "extraUsage": "Extra Usage", + "expired": "Session expired", + "expiredHint": "Run the claude command to refresh your login", + "queryFailed": "Query failed", + "refresh": "Refresh" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6ddc13dbc..46519e9f5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "推奨デフォルト設定を適用", "suggestedDefaultsHint": "このプリセットの推奨デフォルトモデル設定を使用します" + }, + "subscription": { + "title": "サブスクリプションクォータ", + "fiveHour": "5時間", + "sevenDay": "7日間", + "sevenDayOpus": "7日間(Opus)", + "sevenDaySonnet": "7日間(Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "{{time}}後にリセット", + "extraUsage": "超過使用量", + "expired": "セッション期限切れ", + "expiredHint": "claudeコマンドを実行してログインを更新してください", + "queryFailed": "クエリに失敗しました", + "refresh": "更新" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 61174120b..5d27d3e29 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2276,5 +2276,19 @@ }, "suggestedDefaults": "应用建议默认配置", "suggestedDefaultsHint": "使用此预设推荐的默认模型配置" + }, + "subscription": { + "title": "订阅额度", + "fiveHour": "5小时", + "sevenDay": "7天", + "sevenDayOpus": "7天(Opus)", + "sevenDaySonnet": "7天(Sonnet)", + "utilization": "{{value}}%", + "resetsIn": "{{time}}后重置", + "extraUsage": "超额用量", + "expired": "会话已过期", + "expiredHint": "请运行 claude 命令刷新登录", + "queryFailed": "查询失败", + "refresh": "刷新" } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index ea971fba6..47458b8c3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -6,6 +6,7 @@ export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; export { skillsApi } from "./skills"; export { usageApi } from "./usage"; +export { subscriptionApi } from "./subscription"; export { vscodeApi } from "./vscode"; export { proxyApi } from "./proxy"; export { openclawApi } from "./openclaw"; diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts new file mode 100644 index 000000000..df7eaba43 --- /dev/null +++ b/src/lib/api/subscription.ts @@ -0,0 +1,7 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SubscriptionQuota } from "@/types/subscription"; + +export const subscriptionApi = { + getQuota: (tool: string): Promise => + invoke("get_subscription_quota", { tool }), +}; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index 223ec0327..a1a409a35 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -2,3 +2,4 @@ export * from "./queryClient"; export * from "./queries"; export * from "./mutations"; export * from "./proxy"; +export * from "./subscription"; diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts new file mode 100644 index 000000000..c70545b77 --- /dev/null +++ b/src/lib/query/subscription.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { subscriptionApi } from "@/lib/api/subscription"; +import type { AppId } from "@/lib/api/types"; + +const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes + +export function useSubscriptionQuota(appId: AppId, enabled: boolean) { + return useQuery({ + queryKey: ["subscription", "quota", appId], + queryFn: () => subscriptionApi.getQuota(appId), + enabled: enabled && ["claude", "codex", "gemini"].includes(appId), + refetchInterval: REFETCH_INTERVAL, + refetchOnWindowFocus: true, + staleTime: REFETCH_INTERVAL, + retry: 1, + }); +} diff --git a/src/types/subscription.ts b/src/types/subscription.ts new file mode 100644 index 000000000..3e1157caf --- /dev/null +++ b/src/types/subscription.ts @@ -0,0 +1,30 @@ +export type CredentialStatus = + | "valid" + | "expired" + | "not_found" + | "parse_error"; + +export interface QuotaTier { + name: string; + utilization: number; // 0-100 + resetsAt: string | null; +} + +export interface ExtraUsage { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + currency: string | null; +} + +export interface SubscriptionQuota { + tool: string; + credentialStatus: CredentialStatus; + credentialMessage: string | null; + success: boolean; + tiers: QuotaTier[]; + extraUsage: ExtraUsage | null; + error: string | null; + queriedAt: number | null; +} From 0200fe79aea82aef776bd9dca9eec97b8fd95fcd Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 17:47:16 +0800 Subject: [PATCH 007/128] feat: add official subscription quota display for Codex Read Codex OAuth credentials from ~/.codex/auth.json (with macOS Keychain fallback) and query chatgpt.com/backend-api/wham/usage to show rate limit utilization on official Codex provider cards. Reuses the same tier naming (five_hour, seven_day) for frontend i18n compat. --- src-tauri/src/services/subscription.rs | 328 +++++++++++++++++++++- src/components/providers/ProviderCard.tsx | 17 +- 2 files changed, 339 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 47f0957dd..727413d10 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -410,6 +410,302 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota { } } +// ── Codex 凭据读取 ────────────────────────────────────── + +#[derive(Deserialize)] +struct CodexAuthJson { + auth_mode: Option, + tokens: Option, + last_refresh: Option, +} + +#[derive(Deserialize)] +struct CodexTokens { + access_token: Option, + account_id: Option, +} + +/// (access_token, account_id, status, message) +type CodexCredentials = ( + Option, + Option, + CredentialStatus, + Option, +); + +/// 读取 Codex OAuth 凭据 +/// +/// 按优先级尝试以下来源: +/// 1. macOS Keychain (service: "Codex Auth") +/// 2. 凭据文件 ~/.codex/auth.json +/// +/// 仅 auth_mode == "chatgpt" (OAuth) 时有效,API key 模式不支持用量查询。 +fn read_codex_credentials() -> CodexCredentials { + #[cfg(target_os = "macos")] + { + if let Some(result) = read_codex_credentials_from_keychain() { + return result; + } + } + + read_codex_credentials_from_file() +} + +/// 从 macOS Keychain 读取 Codex 凭据 +#[cfg(target_os = "macos")] +fn read_codex_credentials_from_keychain() -> Option { + let output = std::process::Command::new("security") + .args(["find-generic-password", "-s", "Codex Auth", "-w"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let json_str = json_str.trim(); + if json_str.is_empty() { + return None; + } + + Some(parse_codex_credentials_json(json_str)) +} + +/// 从文件读取 Codex 凭据 +fn read_codex_credentials_from_file() -> CodexCredentials { + let auth_path = crate::codex_config::get_codex_auth_path(); + + if !auth_path.exists() { + return (None, None, CredentialStatus::NotFound, None); + } + + let content = match std::fs::read_to_string(&auth_path) { + Ok(c) => c, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to read Codex auth file: {e}")), + ); + } + }; + + parse_codex_credentials_json(&content) +} + +/// 解析 Codex 凭据 JSON(Keychain 和文件共用) +fn parse_codex_credentials_json(content: &str) -> CodexCredentials { + let auth: CodexAuthJson = match serde_json::from_str(content) { + Ok(a) => a, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse Codex auth JSON: {e}")), + ); + } + }; + + // 仅 OAuth 模式有用量数据 + if auth.auth_mode.as_deref() != Some("chatgpt") { + return ( + None, + None, + CredentialStatus::NotFound, + Some("Codex not using OAuth mode".to_string()), + ); + } + + let tokens = match auth.tokens { + Some(t) => t, + None => { + return ( + None, + None, + CredentialStatus::ParseError, + Some("No tokens in Codex auth".to_string()), + ); + } + }; + + let access_token = match tokens.access_token { + Some(t) if !t.is_empty() => t, + _ => { + return ( + None, + None, + CredentialStatus::ParseError, + Some("access_token is empty or missing".to_string()), + ); + } + }; + + // 检查 token 是否可能过期(距上次刷新 > 8 天) + if let Some(ref last_refresh) = auth.last_refresh { + if is_codex_token_stale(last_refresh) { + return ( + Some(access_token), + tokens.account_id, + CredentialStatus::Expired, + Some("Codex token may be stale (>8 days since last refresh)".to_string()), + ); + } + } + + ( + Some(access_token), + tokens.account_id, + CredentialStatus::Valid, + None, + ) +} + +/// 判断 Codex token 是否可能过期(Codex CLI 在 >8 天时自动刷新) +fn is_codex_token_stale(last_refresh: &str) -> bool { + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(last_refresh) { + let age_secs = now_secs.saturating_sub(dt.timestamp() as u64); + age_secs > 8 * 24 * 3600 + } else { + false + } +} + +// ── Codex API 查询 ────────────────────────────────────── + +#[derive(Deserialize)] +struct CodexRateLimitWindow { + used_percent: Option, + limit_window_seconds: Option, + reset_at: Option, +} + +#[derive(Deserialize)] +struct CodexRateLimit { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Deserialize)] +struct CodexUsageResponse { + rate_limit: Option, +} + +/// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n) +fn window_seconds_to_tier_name(secs: i64) -> String { + match secs { + 18000 => "five_hour".to_string(), + 604800 => "seven_day".to_string(), + s => { + let hours = s / 3600; + if hours >= 24 { + format!("{}_day", hours / 24) + } else { + format!("{}_hour", hours) + } + } + } +} + +/// Unix 时间戳(秒)转 ISO 8601 字符串 +fn unix_ts_to_iso(ts: i64) -> Option { + chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339()) +} + +/// 查询 Codex 官方订阅额度 +async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "codex-cli") + .header("Accept", "application/json"); + + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + + let resp = match req.timeout(std::time::Duration::from_secs(10)).send().await { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("Network error: {e}"), + ); + } + }; + + let status = resp.status(); + + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."), + ); + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("API error (HTTP {status}): {body}"), + ); + } + + let body: CodexUsageResponse = match resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "codex", + CredentialStatus::Valid, + format!("Failed to parse API response: {e}"), + ); + } + }; + + let mut tiers = Vec::new(); + + if let Some(rate_limit) = body.rate_limit { + for window in [rate_limit.primary_window, rate_limit.secondary_window] + .into_iter() + .flatten() + { + if let Some(used) = window.used_percent { + tiers.push(QuotaTier { + name: window + .limit_window_seconds + .map(window_seconds_to_tier_name) + .unwrap_or_else(|| "unknown".to_string()), + utilization: used, + resets_at: window.reset_at.and_then(unix_ts_to_iso), + }); + } + } + } + + SubscriptionQuota { + tool: "codex".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + // ── 入口函数 ────────────────────────────────────────────── /// 查询指定 CLI 工具的官方订阅额度 @@ -445,7 +741,37 @@ pub async fn get_subscription_quota(tool: &str) -> Result { + let (token, account_id, status, message) = read_codex_credentials(); + + match status { + CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("codex")), + CredentialStatus::ParseError => Ok(SubscriptionQuota::error( + "codex", + CredentialStatus::ParseError, + message.unwrap_or_else(|| "Failed to parse credentials".to_string()), + )), + CredentialStatus::Expired => { + // 即使可能过期也尝试调用 API + if let Some(token) = token { + let result = query_codex_quota(&token, account_id.as_deref()).await; + if result.success { + return Ok(result); + } + } + Ok(SubscriptionQuota::error( + "codex", + CredentialStatus::Expired, + message.unwrap_or_else(|| "Codex OAuth token may be stale".to_string()), + )) + } + CredentialStatus::Valid => { + let token = token.expect("token must be Some when status is Valid"); + Ok(query_codex_quota(&token, account_id.as_deref()).await) + } + } + } + // Gemini: 暂不支持 _ => Ok(SubscriptionQuota::not_found(tool)), } } diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 289e27b3f..f327d8d84 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -56,12 +56,19 @@ interface ProviderCardProps { onSetAsDefault?: () => void; } -/** 判断是否为官方供应商(无自定义 base URL,直连官方 API) */ +/** 判断是否为官方供应商(无自定义 base URL / API key,直连官方 API) */ function isOfficialProvider(provider: Provider, appId: AppId): boolean { - if (appId !== "claude") return false; - const baseUrl = (provider.settingsConfig as Record)?.env - ?.ANTHROPIC_BASE_URL; - return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); + const config = provider.settingsConfig as Record; + if (appId === "claude") { + const baseUrl = config?.env?.ANTHROPIC_BASE_URL; + return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""); + } + if (appId === "codex") { + // 无 OPENAI_API_KEY → 使用 Codex CLI 内置 OAuth(官方) + const apiKey = config?.auth?.OPENAI_API_KEY; + return !apiKey || (typeof apiKey === "string" && apiKey.trim() === ""); + } + return false; } const extractApiUrl = (provider: Provider, fallbackText: string) => { From 98230c3970f4769a2a631d5119d371db6e17e534 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 18:59:50 +0800 Subject: [PATCH 008/128] feat: add official subscription quota display for Gemini - Read Gemini OAuth credentials from macOS Keychain (gemini-cli-oauth) or legacy file (~/.gemini/oauth_creds.json) - Auto-refresh expired access tokens using refresh_token (Google OAuth tokens expire in ~1h, unlike Claude/Codex) - Two-step API: loadCodeAssist for project ID, then retrieveUserQuota for per-model quota buckets - Classify models into Pro/Flash/Flash Lite categories, show min remaining fraction as utilization percentage - Extend isOfficialProvider() for Gemini (no API key + no base URL) - Parameterize expiredHint i18n key with tool name for all three apps --- src-tauri/src/services/subscription.rs | 503 ++++++++++++++++++++- src/components/SubscriptionQuotaFooter.tsx | 6 +- src/components/providers/ProviderCard.tsx | 9 + src/i18n/locales/en.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/zh.json | 5 +- 6 files changed, 528 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 727413d10..364ee1fa7 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; +use std::collections::HashMap; + use crate::config; // ── 数据类型 ────────────────────────────────────────────── @@ -706,6 +708,470 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs } } +// ── Gemini 凭据读取 ────────────────────────────────────── + +/// Gemini OAuth 凭据文件格式(~/.gemini/oauth_creds.json) +#[derive(Deserialize)] +struct GeminiOAuthCredsFile { + access_token: Option, + refresh_token: Option, + expiry_date: Option, // 毫秒时间戳 +} + +/// (access_token, refresh_token, status, message) +type GeminiCredentials = ( + Option, + Option, + CredentialStatus, + Option, +); + +/// 读取 Gemini OAuth 凭据 +/// +/// 按优先级尝试以下来源: +/// 1. macOS Keychain (service: "gemini-cli-oauth", account: "main-account") +/// 2. 凭据文件 ~/.gemini/oauth_creds.json(遗留格式) +/// +/// 仅 OAuth 认证模式(`oauth-personal`)有效;API key 模式无法查询官方用量。 +fn read_gemini_credentials() -> GeminiCredentials { + #[cfg(target_os = "macos")] + { + if let Some(result) = read_gemini_credentials_from_keychain() { + return result; + } + } + + read_gemini_credentials_from_file() +} + +/// 从 macOS Keychain 读取 Gemini 凭据 +#[cfg(target_os = "macos")] +fn read_gemini_credentials_from_keychain() -> Option { + let output = std::process::Command::new("security") + .args([ + "find-generic-password", + "-s", + "gemini-cli-oauth", + "-a", + "main-account", + "-w", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let json_str = json_str.trim(); + if json_str.is_empty() { + return None; + } + + Some(parse_gemini_keychain_json(json_str)) +} + +/// 解析 Keychain 格式的 Gemini 凭据 +/// +/// Keychain 格式(keytar): +/// ```json +/// { "token": { "accessToken": "...", "refreshToken": "...", "expiresAt": 1234 }, "updatedAt": ... } +/// ``` +fn parse_gemini_keychain_json(content: &str) -> GeminiCredentials { + let parsed: serde_json::Value = match serde_json::from_str(content) { + Ok(v) => v, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse Gemini keychain JSON: {e}")), + ) + } + }; + + let token = match parsed.get("token") { + Some(t) => t, + None => { + // Keychain 中可能是扁平格式,尝试文件格式解析 + return parse_gemini_file_json(content); + } + }; + + let access_token = token + .get("accessToken") + .and_then(|v| v.as_str()) + .map(String::from); + let refresh_token = token + .get("refreshToken") + .and_then(|v| v.as_str()) + .map(String::from); + let expires_at = token.get("expiresAt").and_then(|v| v.as_i64()); + + match access_token { + Some(at) if !at.is_empty() => { + // expiresAt 是毫秒时间戳 + if let Some(exp_ms) = expires_at { + if exp_ms < now_millis() { + return ( + Some(at), + refresh_token, + CredentialStatus::Expired, + Some("Gemini access token has expired".to_string()), + ); + } + } + (Some(at), refresh_token, CredentialStatus::Valid, None) + } + _ => ( + None, + refresh_token, + CredentialStatus::ParseError, + Some("accessToken is empty or missing".to_string()), + ), + } +} + +/// 从文件读取 Gemini 凭据 +fn read_gemini_credentials_from_file() -> GeminiCredentials { + let cred_path = crate::gemini_config::get_gemini_dir().join("oauth_creds.json"); + if !cred_path.exists() { + return (None, None, CredentialStatus::NotFound, None); + } + + let content = match std::fs::read_to_string(&cred_path) { + Ok(c) => c, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to read Gemini credentials: {e}")), + ) + } + }; + + parse_gemini_file_json(&content) +} + +/// 解析文件格式的 Gemini 凭据 +/// +/// 文件格式(oauth_creds.json): +/// ```json +/// { "access_token": "...", "refresh_token": "...", "expiry_date": 1234 } +/// ``` +fn parse_gemini_file_json(content: &str) -> GeminiCredentials { + let creds: GeminiOAuthCredsFile = match serde_json::from_str(content) { + Ok(c) => c, + Err(e) => { + return ( + None, + None, + CredentialStatus::ParseError, + Some(format!("Failed to parse Gemini credentials: {e}")), + ) + } + }; + + let access_token = match creds.access_token { + Some(t) if !t.is_empty() => t, + _ => { + return ( + None, + creds.refresh_token, + CredentialStatus::ParseError, + Some("access_token is empty or missing".to_string()), + ) + } + }; + + // expiry_date 是毫秒时间戳 + if let Some(exp_ms) = creds.expiry_date { + if exp_ms < now_millis() { + return ( + Some(access_token), + creds.refresh_token, + CredentialStatus::Expired, + Some("Gemini access token has expired".to_string()), + ); + } + } + + ( + Some(access_token), + creds.refresh_token, + CredentialStatus::Valid, + None, + ) +} + +// ── Gemini Token 刷新 ────────────────────────────────────── + +/// Gemini OAuth Client 凭据(公开值,来自 Gemini CLI 源码 google-gemini/gemini-cli) +const GEMINI_OAUTH_CLIENT_ID: &str = + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const GEMINI_OAUTH_CLIENT_SECRET: &str = "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"; + +/// 使用 refresh_token 刷新 Gemini access token +/// +/// Google OAuth access_token 仅有 ~1h 有效期,需要定期用 refresh_token 刷新。 +/// refresh_token 本身不过期(除非用户撤销授权)。 +async fn refresh_gemini_token(refresh_token: &str) -> Option { + let client = crate::proxy::http_client::get(); + + let resp = client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", GEMINI_OAUTH_CLIENT_ID), + ("client_secret", GEMINI_OAUTH_CLIENT_SECRET), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .ok()?; + + if !resp.status().is_success() { + return None; + } + + let body: serde_json::Value = resp.json().await.ok()?; + body.get("access_token")?.as_str().map(String::from) +} + +// ── Gemini API 查询 ────────────────────────────────────── + +/// loadCodeAssist 响应 +#[derive(Deserialize)] +struct GeminiLoadCodeAssistResponse { + #[serde(rename = "cloudaicompanionProject")] + cloudaicompanion_project: Option, +} + +/// 配额 bucket +#[derive(Deserialize)] +struct GeminiBucketInfo { + #[serde(rename = "remainingFraction")] + remaining_fraction: Option, + #[serde(rename = "resetTime")] + reset_time: Option, + #[serde(rename = "modelId")] + model_id: Option, +} + +/// retrieveUserQuota 响应 +#[derive(Deserialize)] +struct GeminiQuotaResponse { + buckets: Option>, +} + +/// 从 loadCodeAssist 响应中提取项目 ID +fn extract_project_id(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Object(obj) => obj + .get("id") + .or_else(|| obj.get("projectId")) + .and_then(|v| v.as_str()) + .map(String::from), + _ => None, + } +} + +/// 将 Gemini 模型 ID 分类为 Pro / Flash / Flash Lite +fn classify_gemini_model(model_id: &str) -> &str { + if model_id.contains("flash-lite") { + "gemini_flash_lite" + } else if model_id.contains("flash") { + "gemini_flash" + } else if model_id.contains("pro") { + "gemini_pro" + } else { + model_id + } +} + +/// 查询 Gemini 官方订阅额度 +/// +/// 两步 API 调用: +/// 1. loadCodeAssist → 获取 cloudaicompanionProject +/// 2. retrieveUserQuota → 获取按模型分桶的配额数据 +async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + // ── Step 1: loadCodeAssist 获取项目 ID ── + let load_resp = client + .post("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist") + .header("Authorization", format!("Bearer {access_token}")) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "metadata": { + "ideType": "GEMINI_CLI", + "pluginType": "GEMINI" + } + })) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let load_resp = match load_resp { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("Network error (loadCodeAssist): {e}"), + ); + } + }; + + let load_status = load_resp.status(); + if load_status == reqwest::StatusCode::UNAUTHORIZED + || load_status == reqwest::StatusCode::FORBIDDEN + { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."), + ); + } + if !load_status.is_success() { + let body = load_resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("loadCodeAssist failed (HTTP {load_status}): {body}"), + ); + } + + let load_body: GeminiLoadCodeAssistResponse = match load_resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("Failed to parse loadCodeAssist response: {e}"), + ); + } + }; + + let project_id = load_body + .cloudaicompanion_project + .as_ref() + .and_then(extract_project_id); + + // ── Step 2: retrieveUserQuota 获取配额 ── + let mut quota_body = serde_json::json!({}); + if let Some(ref pid) = project_id { + quota_body["project"] = serde_json::Value::String(pid.clone()); + } + + let quota_resp = client + .post("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") + .header("Authorization", format!("Bearer {access_token}")) + .header("Content-Type", "application/json") + .json("a_body) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let quota_resp = match quota_resp { + Ok(r) => r, + Err(e) => { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("Network error (retrieveUserQuota): {e}"), + ); + } + }; + + let quota_status = quota_resp.status(); + if quota_status == reqwest::StatusCode::UNAUTHORIZED + || quota_status == reqwest::StatusCode::FORBIDDEN + { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Expired, + format!("Authentication failed (HTTP {quota_status})."), + ); + } + if !quota_status.is_success() { + let body = quota_resp.text().await.unwrap_or_default(); + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"), + ); + } + + let quota_data: GeminiQuotaResponse = match quota_resp.json().await { + Ok(v) => v, + Err(e) => { + return SubscriptionQuota::error( + "gemini", + CredentialStatus::Valid, + format!("Failed to parse quota response: {e}"), + ); + } + }; + + // ── 按模型分类汇总,每类取最低 remainingFraction ── + let mut category_map: HashMap)> = HashMap::new(); + + if let Some(buckets) = quota_data.buckets { + for bucket in buckets { + let model_id = bucket.model_id.as_deref().unwrap_or("unknown"); + let category = classify_gemini_model(model_id).to_string(); + let remaining = bucket.remaining_fraction.unwrap_or(1.0).clamp(0.0, 1.0); + + let entry = category_map + .entry(category) + .or_insert((remaining, bucket.reset_time.clone())); + if remaining < entry.0 { + entry.0 = remaining; + if bucket.reset_time.is_some() { + entry.1.clone_from(&bucket.reset_time); + } + } + } + } + + // 转换为 tiers(remainingFraction → utilization: 已用百分比) + let sort_order = |name: &str| -> usize { + match name { + "gemini_pro" => 0, + "gemini_flash" => 1, + "gemini_flash_lite" => 2, + _ => 3, + } + }; + + let mut tiers: Vec = category_map + .into_iter() + .map(|(name, (remaining, reset_time))| QuotaTier { + name, + utilization: (1.0 - remaining) * 100.0, + resets_at: reset_time, + }) + .collect(); + + tiers.sort_by_key(|t| sort_order(&t.name)); + + SubscriptionQuota { + tool: "gemini".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + // ── 入口函数 ────────────────────────────────────────────── /// 查询指定 CLI 工具的官方订阅额度 @@ -771,7 +1237,42 @@ pub async fn get_subscription_quota(tool: &str) -> Result { + let (token, refresh_token, status, message) = read_gemini_credentials(); + + match status { + CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("gemini")), + CredentialStatus::ParseError => Ok(SubscriptionQuota::error( + "gemini", + CredentialStatus::ParseError, + message.unwrap_or_else(|| "Failed to parse credentials".to_string()), + )), + CredentialStatus::Expired => { + // Gemini access_token 仅 ~1h 有效,尝试用 refresh_token 刷新 + if let Some(ref rt) = refresh_token { + if let Some(new_token) = refresh_gemini_token(rt).await { + return Ok(query_gemini_quota(&new_token).await); + } + } + // 刷新失败,尝试用旧 token + if let Some(ref token) = token { + let result = query_gemini_quota(token).await; + if result.success { + return Ok(result); + } + } + Ok(SubscriptionQuota::error( + "gemini", + CredentialStatus::Expired, + message.unwrap_or_else(|| "Gemini OAuth token has expired".to_string()), + )) + } + CredentialStatus::Valid => { + let token = token.expect("token must be Some when status is Valid"); + Ok(query_gemini_quota(&token).await) + } + } + } _ => Ok(SubscriptionQuota::not_found(tool)), } } diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 6666f74ba..10d1906d2 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -16,6 +16,10 @@ const TIER_I18N_KEYS: Record = { seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", seven_day_sonnet: "subscription.sevenDaySonnet", + // Gemini 模型分类 + gemini_pro: "subscription.geminiPro", + gemini_flash: "subscription.geminiFlash", + gemini_flash_lite: "subscription.geminiFlashLite", }; /** 根据使用百分比返回颜色 class */ @@ -123,7 +127,7 @@ const SubscriptionQuotaFooter: React.FC = ({
{t("subscription.expired")} - {t("subscription.expiredHint")} + {t("subscription.expiredHint", { tool: appId })}
diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index f327d8d84..f3569c363 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -68,6 +68,15 @@ function isOfficialProvider(provider: Provider, appId: AppId): boolean { const apiKey = config?.auth?.OPENAI_API_KEY; return !apiKey || (typeof apiKey === "string" && apiKey.trim() === ""); } + if (appId === "gemini") { + // 无 GEMINI_API_KEY 且无 GOOGLE_GEMINI_BASE_URL → Google OAuth 官方模式 + const apiKey = config?.env?.GEMINI_API_KEY; + const baseUrl = config?.env?.GOOGLE_GEMINI_BASE_URL; + return ( + (!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) && + (!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "")) + ); + } return false; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e3c0ffe5d..d5a15b69e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2283,11 +2283,14 @@ "sevenDay": "7-Day", "sevenDayOpus": "7-Day (Opus)", "sevenDaySonnet": "7-Day (Sonnet)", + "geminiPro": "Pro", + "geminiFlash": "Flash", + "geminiFlashLite": "Flash Lite", "utilization": "{{value}}%", "resetsIn": "Resets in {{time}}", "extraUsage": "Extra Usage", "expired": "Session expired", - "expiredHint": "Run the claude command to refresh your login", + "expiredHint": "Run the {{tool}} command to refresh your login", "queryFailed": "Query failed", "refresh": "Refresh" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 46519e9f5..2babea0ff 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2283,11 +2283,14 @@ "sevenDay": "7日間", "sevenDayOpus": "7日間(Opus)", "sevenDaySonnet": "7日間(Sonnet)", + "geminiPro": "Pro", + "geminiFlash": "Flash", + "geminiFlashLite": "Flash Lite", "utilization": "{{value}}%", "resetsIn": "{{time}}後にリセット", "extraUsage": "超過使用量", "expired": "セッション期限切れ", - "expiredHint": "claudeコマンドを実行してログインを更新してください", + "expiredHint": "{{tool}}コマンドを実行してログインを更新してください", "queryFailed": "クエリに失敗しました", "refresh": "更新" } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 5d27d3e29..ea08109a8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2283,11 +2283,14 @@ "sevenDay": "7天", "sevenDayOpus": "7天(Opus)", "sevenDaySonnet": "7天(Sonnet)", + "geminiPro": "Pro", + "geminiFlash": "Flash", + "geminiFlashLite": "Flash Lite", "utilization": "{{value}}%", "resetsIn": "{{time}}后重置", "extraUsage": "超额用量", "expired": "会话已过期", - "expiredHint": "请运行 claude 命令刷新登录", + "expiredHint": "请运行 {{tool}} 命令刷新登录", "queryFailed": "查询失败", "refresh": "刷新" } From b31bf43e15507f4c900f5b3b6ee19203cf914038 Mon Sep 17 00:00:00 2001 From: Jason Date: Sat, 4 Apr 2026 19:59:43 +0800 Subject: [PATCH 009/128] fix: hide usage config and health check buttons for official providers Official providers use built-in subscription quota display instead of custom usage scripts, and stream check is not applicable. Hide both action buttons when isOfficialProvider is true. --- src/components/providers/ProviderActions.tsx | 22 +++++++++++--------- src/components/providers/ProviderCard.tsx | 6 ++++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index fdead3c21..d776ae21a 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -28,7 +28,7 @@ interface ProviderActionsProps { onEdit: () => void; onDuplicate: () => void; onTest?: () => void; - onConfigureUsage: () => void; + onConfigureUsage?: () => void; onDelete: () => void; onRemoveFromConfig?: () => void; onDisableOmo?: () => void; @@ -263,15 +263,17 @@ export function ProviderActions({ )} - + {onConfigureUsage && ( + + )} {onOpenTerminal && (
-
+
{isOfficial ? ( ) : hasMultiplePlans ? ( @@ -414,8 +394,7 @@ export function ProviderCard({
Date: Sun, 5 Apr 2026 08:34:10 +0800 Subject: [PATCH 011/128] feat: add Copilot optimizer to reduce premium interaction consumption Implement request classification, tool result merging, compact detection, deterministic request IDs, and warmup downgrade for Copilot proxy. The root cause was x-initiator being hardcoded to "user", making Copilot count every API request (including tool callbacks and agent continuations) as a separate premium interaction. The optimizer dynamically classifies requests as "user" or "agent" based on message content analysis. Closes #1813 --- src-tauri/src/commands/settings.rs | 24 + src-tauri/src/database/dao/settings.rs | 25 + src-tauri/src/lib.rs | 2 + src-tauri/src/proxy/copilot_optimizer.rs | 901 +++++++++++++++++++++++ src-tauri/src/proxy/forwarder.rs | 85 ++- src-tauri/src/proxy/handler_context.rs | 7 +- src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/proxy/types.rs | 48 ++ src-tauri/src/tray.rs | 9 +- 9 files changed, 1093 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/proxy/copilot_optimizer.rs diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 3d07e9514..ef6f7b395 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -247,6 +247,30 @@ pub async fn set_optimizer_config( Ok(true) } +/// 获取 Copilot 优化器配置 +#[tauri::command] +pub async fn get_copilot_optimizer_config( + state: tauri::State<'_, crate::AppState>, +) -> Result { + state + .db + .get_copilot_optimizer_config() + .map_err(|e| e.to_string()) +} + +/// 设置 Copilot 优化器配置 +#[tauri::command] +pub async fn set_copilot_optimizer_config( + state: tauri::State<'_, crate::AppState>, + config: crate::proxy::types::CopilotOptimizerConfig, +) -> Result { + state + .db + .set_copilot_optimizer_config(&config) + .map_err(|e| e.to_string())?; + Ok(true) +} + /// 获取日志配置 #[tauri::command] pub async fn get_log_config( diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index bca48bcbf..4ee5431d6 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -270,6 +270,31 @@ impl Database { self.set_setting("optimizer_config", &json) } + // --- Copilot 优化器配置 --- + + /// 获取 Copilot 优化器配置 + /// + /// 返回配置,如果不存在则返回默认值(默认开启) + pub fn get_copilot_optimizer_config( + &self, + ) -> Result { + match self.get_setting("copilot_optimizer_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析 Copilot 优化器配置失败: {e}"))), + None => Ok(crate::proxy::types::CopilotOptimizerConfig::default()), + } + } + + /// 更新 Copilot 优化器配置 + pub fn set_copilot_optimizer_config( + &self, + config: &crate::proxy::types::CopilotOptimizerConfig, + ) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Database(format!("序列化 Copilot 优化器配置失败: {e}")))?; + self.set_setting("copilot_optimizer_config", &json) + } + // --- 日志配置 --- /// 获取日志配置 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43bc47757..4a2600b3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -867,6 +867,8 @@ pub fn run() { commands::set_rectifier_config, commands::get_optimizer_config, commands::set_optimizer_config, + commands::get_copilot_optimizer_config, + commands::set_copilot_optimizer_config, commands::get_log_config, commands::set_log_config, commands::restart_app, diff --git a/src-tauri/src/proxy/copilot_optimizer.rs b/src-tauri/src/proxy/copilot_optimizer.rs new file mode 100644 index 000000000..0092e02a5 --- /dev/null +++ b/src-tauri/src/proxy/copilot_optimizer.rs @@ -0,0 +1,901 @@ +//! Copilot 请求优化器 +//! +//! 解决 GitHub Copilot 代理消耗量异常问题(Issue #1813)。 +//! +//! Copilot 使用 `x-initiator` 请求头区分「用户发起」和「agent 续写」: +//! - `user`:计为一次 premium interaction(扣额度) +//! - `agent`:视为上一次交互的延续(不额外扣费) +//! +//! 参考实现: https://github.com/caozhiyuan/copilot-api + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// 请求分类结果 +#[derive(Debug, Clone)] +pub struct CopilotClassification { + /// "user" 或 "agent" — 映射到 x-initiator 请求头 + pub initiator: &'static str, + /// 是否为 warmup/探针请求(可降级到小模型) + pub is_warmup: bool, + /// 是否为上下文压缩请求 + pub is_compact: bool, +} + +/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。 +/// +/// 分类算法(只检查最后一条消息,与参考实现 caozhiyuan/copilot-api 对齐): +/// 1. 无消息 → "user"(安全默认,首次请求) +/// 2. 最后消息 role=user: +/// - content 中存在非 tool_result 类型 block → "user" +/// - content 全部是 tool_result → "agent" +/// - 匹配 compact 模式 → "agent" +/// 3. 最后消息 role 非 user → "user"(安全默认) +/// +/// Warmup 检测(与参考实现对齐): +/// - 请求头中有 `anthropic-beta` + 无 tools + 非 compact → warmup +/// +/// `compact_detection`:是否启用 compact 检测。为 false 时跳过, +/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。 +pub fn classify_request( + body: &Value, + has_anthropic_beta: bool, + compact_detection: bool, +) -> CopilotClassification { + let is_compact = compact_detection && is_compact_request(body); + + let messages = match body.get("messages").and_then(|m| m.as_array()) { + Some(msgs) if !msgs.is_empty() => msgs, + _ => { + return CopilotClassification { + initiator: "user", + is_warmup: is_warmup_request(body, has_anthropic_beta, false), + is_compact: false, + } + } + }; + + let last_msg = &messages[messages.len() - 1]; + let role = last_msg.get("role").and_then(|r| r.as_str()).unwrap_or(""); + + // 只有 role=user 的消息需要细分 + if role != "user" { + return CopilotClassification { + initiator: "user", + is_warmup: false, + is_compact, + }; + } + + // 参考实现的判定逻辑(Messages API 路径): + // 如果 content 是数组,检查是否有非 tool_result 的 block + // 有 → "user",全是 tool_result → "agent" + // 如果 content 是字符串 → "user" + let is_user_initiated = match last_msg.get("content") { + Some(content) if content.is_array() => { + let blocks = content.as_array().unwrap(); + // 存在非 tool_result block → 用户发起 + blocks + .iter() + .any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result")) + } + Some(content) if content.is_string() => true, + _ => false, + }; + + let initiator = if !is_user_initiated || is_compact { + "agent" + } else { + "user" + }; + + CopilotClassification { + initiator, + is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact), + is_compact, + } +} + +/// 检测是否为 warmup/探针请求(适合降级到小模型)。 +/// +/// 与参考实现对齐,三个条件同时满足: +/// 1. 请求头有 `anthropic-beta`(Claude Code warmup 探针的标志) +/// 2. 无 tools 定义 +/// 3. 非 compact 请求 +fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -> bool { + if !has_anthropic_beta || is_compact { + return false; + } + // 无工具定义 + !matches!(body.get("tools"), Some(tools) if tools.is_array() && !tools.as_array().unwrap().is_empty()) +} + +/// 检测是否为 Claude Code 上下文压缩/compact 请求。 +/// +/// 只匹配 Claude Code **内部生成**的机器特征,不匹配用户可能手动输入的通用短语, +/// 避免将真实用户请求误标为 agent。 +/// +/// 强特征来源: +/// 1. system prompt — Claude Code compact 模式会设置专用 system prompt,用户无法手动设置 +/// 2. "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools." — 机器指令 +/// 3. 同时包含 "Pending Tasks:" 和 "Current Work:" — Claude Code compact 的结构标记 +fn is_compact_request(body: &Value) -> bool { + // 信号 1: system prompt 以 Claude Code compact 专用前缀开头 + // 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号 + if let Some(system) = body.get("system") { + let system_text = if let Some(s) = system.as_str() { + s.to_string() + } else if let Some(arr) = system.as_array() { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(" ") + } else { + String::new() + }; + + if system_text + .starts_with("You are a helpful AI assistant tasked with summarizing conversations") + { + return true; + } + } + + // 信号 2 & 3: 检查最后一条用户消息中的机器生成特征 + let messages = match body.get("messages").and_then(|m| m.as_array()) { + Some(msgs) => msgs, + None => return false, + }; + + if let Some(last_msg) = messages.last() { + if last_msg.get("role").and_then(|r| r.as_str()) != Some("user") { + return false; + } + + let text = extract_text_from_message(last_msg); + + // 信号 2: Claude Code compact 的机器指令(大小写敏感,精确匹配) + if text.contains("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") { + return true; + } + + // 信号 3: Claude Code compact 的结构标记(两个同时出现才算) + if text.contains("Pending Tasks:") && text.contains("Current Work:") { + return true; + } + } + + false +} + +/// 合并用户消息中的 tool_result 和 text block。 +/// +/// 与参考实现 `mergeToolResultForClaude` 对齐: +/// +/// **消息内部合并**(核心):在单条 user 消息内,将 text block 吸收进 tool_result block, +/// 使整条消息只剩 tool_result 类型 block。这样 Copilot 不会将其视为用户发起的交互。 +/// +/// 场景:Claude Code 在 skill 调用、edit hook、plan 提醒等场景下,会发送混合了 +/// tool_result + text 的用户消息。text block 的存在让 Copilot 将其计为 premium request。 +/// +/// **跨消息合并**(补充):连续的 tool_result-only 用户消息合并为一条。 +pub fn merge_tool_results(mut body: Value) -> Value { + let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) { + Some(msgs) if !msgs.is_empty() => msgs, + _ => return body, + }; + + // Phase 1: 消息内部合并 — 将 text block 吸收进 tool_result block + for msg in messages.iter_mut() { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let content = match msg.get("content").and_then(|c| c.as_array()) { + Some(blocks) => blocks, + None => continue, + }; + + // 分离 tool_result 和 text block + let mut tool_results: Vec = Vec::new(); + let mut text_blocks: Vec = Vec::new(); + let mut valid = true; + + for block in content { + match block.get("type").and_then(|t| t.as_str()) { + Some("tool_result") => tool_results.push(block.clone()), + Some("text") => text_blocks.push(block.clone()), + _ => { + // 存在其他类型 block → 跳过此消息 + valid = false; + break; + } + } + } + + // 必须同时有 tool_result 和 text 才需要合并 + if !valid || tool_results.is_empty() || text_blocks.is_empty() { + continue; + } + + // 合并策略(与参考实现对齐) + let merged = merge_blocks_into_tool_results(tool_results, text_blocks); + msg["content"] = Value::Array(merged); + } + + // Phase 2: 跨消息合并 — 连续的 tool_result-only 用户消息合并 + let messages = body["messages"].as_array().unwrap().clone(); + if messages.len() <= 1 { + return body; + } + + let mut merged_msgs: Vec = Vec::with_capacity(messages.len()); + let mut i = 0; + + while i < messages.len() { + if is_tool_result_only_message(&messages[i]) { + let mut combined_content: Vec = Vec::new(); + while i < messages.len() && is_tool_result_only_message(&messages[i]) { + if let Some(content) = messages[i].get("content").and_then(|c| c.as_array()) { + combined_content.extend(content.iter().cloned()); + } + i += 1; + } + if !combined_content.is_empty() { + merged_msgs.push(serde_json::json!({ + "role": "user", + "content": combined_content + })); + } + } else { + merged_msgs.push(messages[i].clone()); + i += 1; + } + } + + body["messages"] = Value::Array(merged_msgs); + body +} + +/// 基于最后一条用户消息内容生成确定性 Request ID。 +/// +/// 与参考实现对齐: +/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control) +/// - 找不到用户内容时退化为随机 UUID +/// - 使用 UUID v4 格式 +pub fn deterministic_request_id(body: &Value, session_id: &str) -> String { + let last_user_content = find_last_user_content(body); + + match last_user_content { + Some(content) => { + let mut hasher = Sha256::new(); + hasher.update(session_id.as_bytes()); + hasher.update(content.as_bytes()); + let result = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&result[..16]); + // UUID v4 版本位和变体位(与参考实现一致) + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + Uuid::from_bytes(bytes).to_string() + } + None => Uuid::new_v4().to_string(), + } +} + +// ─── 内部辅助 ───────────────────────────────── + +/// 查找最后一条 user 消息的非 tool_result 文本内容。 +/// +/// 与参考实现的 `findLastUserContent` 对齐: +/// - 从后往前遍历消息 +/// - 排除 tool_result block +/// - 排除 cache_control 字段 +fn find_last_user_content(body: &Value) -> Option { + let messages = body.get("messages").and_then(|m| m.as_array())?; + + for msg in messages.iter().rev() { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let content = msg.get("content")?; + + if let Some(s) = content.as_str() { + return Some(s.to_string()); + } + + if let Some(blocks) = content.as_array() { + // 过滤 tool_result,保留其他 block(去掉 cache_control) + let filtered: Vec = blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) != Some("tool_result")) + .map(|b| { + let mut b = b.clone(); + if let Some(obj) = b.as_object_mut() { + obj.remove("cache_control"); + } + b + }) + .collect(); + + if !filtered.is_empty() { + return Some(serde_json::to_string(&filtered).unwrap_or_default()); + } + } + } + + None +} + +/// 将 text block 合并进 tool_result block。 +/// +/// 两种合并策略(与参考实现对齐): +/// - 数量相等:一一对应,text 追加到对应 tool_result 的 content 中 +/// - 数量不等:所有 text 追加到最后一个 tool_result 的 content 中 +fn merge_blocks_into_tool_results( + mut tool_results: Vec, + text_blocks: Vec, +) -> Vec { + if tool_results.len() == text_blocks.len() { + // 一一对应合并 + for (tr, tb) in tool_results.iter_mut().zip(text_blocks.iter()) { + append_text_to_tool_result(tr, tb); + } + } else { + // 所有 text 追加到最后一个 tool_result + if let Some(last_tr) = tool_results.last_mut() { + for tb in &text_blocks { + append_text_to_tool_result(last_tr, tb); + } + } + } + tool_results +} + +/// 将 text block 的内容追加到 tool_result 的 content 中 +fn append_text_to_tool_result(tool_result: &mut Value, text_block: &Value) { + let text = text_block + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or(""); + if text.trim().is_empty() { + return; + } + + // tool_result 的 content 可以是字符串或数组 + match tool_result.get("content") { + Some(c) if c.is_string() => { + let existing = c.as_str().unwrap_or(""); + tool_result["content"] = Value::String(format!("{existing}\n{text}")); + } + Some(c) if c.is_array() => { + let arr = tool_result["content"].as_array_mut().unwrap(); + arr.push(serde_json::json!({"type": "text", "text": text})); + } + _ => { + // content 缺失或 null — 直接设置 + tool_result["content"] = Value::String(text.to_string()); + } + } +} + +/// 从消息中提取文本内容 +fn extract_text_from_message(msg: &Value) -> String { + match msg.get("content") { + Some(content) if content.is_string() => content.as_str().unwrap_or("").to_string(), + Some(content) if content.is_array() => { + let blocks = content.as_array().unwrap(); + blocks + .iter() + .filter_map(|block| { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + block.get("text").and_then(|t| t.as_str()) + } else { + None + } + }) + .collect::>() + .join(" ") + } + _ => String::new(), + } +} + +/// 判断消息是否为 tool_result-only 的用户消息 +fn is_tool_result_only_message(msg: &Value) -> bool { + if msg.get("role").and_then(|r| r.as_str()) != Some("user") { + return false; + } + match msg.get("content").and_then(|c| c.as_array()) { + Some(blocks) if !blocks.is_empty() => blocks + .iter() + .all(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")), + _ => false, + } +} + +// ─── 测试 ───────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // === classify_request 测试 === + + #[test] + fn test_classify_user_text_message() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello, please help me write some code"} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + assert!(!result.is_compact); + } + + #[test] + fn test_classify_user_text_array_message() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "Please explain this code"} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_tool_result_only() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}], + "messages": [ + {"role": "user", "content": "Read the file"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "I'll read that file."}, + {"type": "tool_use", "id": "toolu_123", "name": "Read", "input": {"path": "/tmp/test.rs"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents here"} + ]} + ] + }); + let result = classify_request(&body, true, true); + assert_eq!(result.initiator, "agent"); + assert!(!result.is_warmup); + } + + #[test] + fn test_classify_tool_result_with_text_block() { + // 参考实现的关键场景:tool_result + text block + // 有非 tool_result block → 仍然是 "user" + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "file contents"}, + {"type": "text", "text": "Now please refactor this code"} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_empty_messages() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_no_messages() { + let body = json!({"model": "claude-sonnet-4-20250514"}); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "user"); + } + + #[test] + fn test_classify_compact_request_system_prompt() { + // compact 通过 system prompt 强特征检测 + let body = json!({ + "model": "claude-sonnet-4-20250514", + "system": "You are a helpful AI assistant tasked with summarizing conversations. Please create a summary.", + "messages": [ + {"role": "user", "content": "Here is the conversation history to summarize..."} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "agent"); + assert!(result.is_compact); + } + + #[test] + fn test_classify_compact_request_critical_marker() { + // compact 通过 CRITICAL 机器指令检测 + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize the conversation."} + ]} + ] + }); + let result = classify_request(&body, false, true); + assert_eq!(result.initiator, "agent"); + assert!(result.is_compact); + } + + #[test] + fn test_classify_compact_disabled_by_config() { + // compact_detection=false 时,即使内容匹配也不标记为 compact + let body = json!({ + "model": "claude-sonnet-4-20250514", + "system": "You are a helpful AI assistant tasked with summarizing conversations.", + "messages": [ + {"role": "user", "content": "Summarize"} + ] + }); + let result = classify_request(&body, false, false); // compact_detection=false + assert_eq!(result.initiator, "user"); // 不被标记为 agent + assert!(!result.is_compact); + } + + #[test] + fn test_no_false_positive_on_user_summarize_request() { + // P1 修复验证:用户手动输入 "summarize the conversation" 不应被误判为 compact + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Please summarize the conversation so far into a concise summary."} + ] + }); + let result = classify_request(&body, false, true); + // 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user + assert_eq!(result.initiator, "user"); + assert!(!result.is_compact); + } + + // === warmup 测试(与参考实现对齐) === + + #[test] + fn test_warmup_with_anthropic_beta_no_tools() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // has_anthropic_beta=true, 无 tools → warmup + let result = classify_request(&body, true, true); + assert!(result.is_warmup); + } + + #[test] + fn test_not_warmup_without_anthropic_beta() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // has_anthropic_beta=false → 不是 warmup + let result = classify_request(&body, false, true); + assert!(!result.is_warmup); + } + + #[test] + fn test_not_warmup_with_tools() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "tools": [{"name": "Read", "description": "Read a file", "input_schema": {}}], + "messages": [ + {"role": "user", "content": "Hello"} + ] + }); + // 有 tools → 不是 warmup(即使有 anthropic-beta) + let result = classify_request(&body, true, true); + assert!(!result.is_warmup); + } + + #[test] + fn test_not_warmup_when_agent() { + // tool_result → agent → 不判定 warmup + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_123", "content": "ok"} + ]} + ] + }); + let result = classify_request(&body, true, true); + assert_eq!(result.initiator, "agent"); + assert!(!result.is_warmup); + } + + // === merge_tool_results 测试 === + + #[test] + fn test_merge_intra_message_tool_result_text() { + // 核心场景:消息内部 tool_result + text → text 被吸收进 tool_result + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "file contents"}, + {"type": "text", "text": "skill output here"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + // 应只剩 1 个 tool_result block(text 被吸收) + assert_eq!(content.len(), 1); + assert_eq!(content[0]["type"], "tool_result"); + // tool_result 的 content 应包含原始内容 + 吸收的 text + let tr_content = content[0]["content"].as_str().unwrap(); + assert!(tr_content.contains("file contents")); + assert!(tr_content.contains("skill output here")); + } + + #[test] + fn test_merge_intra_message_equal_count() { + // 数量相等:一一对应合并 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result1"}, + {"type": "text", "text": "text1"}, + {"type": "tool_result", "tool_use_id": "t2", "content": "result2"}, + {"type": "text", "text": "text2"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 2); + assert!(content[0]["content"].as_str().unwrap().contains("text1")); + assert!(content[1]["content"].as_str().unwrap().contains("text2")); + } + + #[test] + fn test_merge_intra_message_empty_text_ignored() { + // 空 text block 不追加内容 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result"}, + {"type": "text", "text": ""} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1); + // 空 text 不改变原始 content + assert_eq!(content[0]["content"], "result"); + } + + #[test] + fn test_merge_intra_skips_other_block_types() { + // 有非 tool_result/text 的 block → 跳过整条消息 + let body = json!({ + "messages": [ + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "result"}, + {"type": "image", "source": {"data": "..."}}, + {"type": "text", "text": "caption"} + ]} + ] + }); + let result = merge_tool_results(body); + let content = result["messages"][0]["content"].as_array().unwrap(); + // 未合并,保持原样 3 个 block + assert_eq!(content.len(), 3); + } + + #[test] + fn test_merge_cross_message_consecutive() { + // 跨消息合并:连续 tool_result-only 用户消息 + let body = json!({ + "messages": [ + {"role": "user", "content": "Read files"}, + {"role": "assistant", "content": [ + {"type": "tool_use", "id": "t1", "name": "Read", "input": {}}, + {"type": "tool_use", "id": "t2", "name": "Read", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "file1"} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t2", "content": "file2"} + ]} + ] + }); + let result = merge_tool_results(body); + let messages = result["messages"].as_array().unwrap(); + assert_eq!(messages.len(), 3); + let merged_content = messages[2]["content"].as_array().unwrap(); + assert_eq!(merged_content.len(), 2); + } + + #[test] + fn test_merge_does_not_affect_normal_messages() { + let body = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "How are you?"} + ] + }); + let result = merge_tool_results(body.clone()); + assert_eq!(result["messages"], body["messages"]); + } + + // === deterministic_request_id 测试 === + + #[test] + fn test_deterministic_id_stable() { + let body = json!({ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hello"}] + }); + let id1 = deterministic_request_id(&body, "session1"); + let id2 = deterministic_request_id(&body, "session1"); + assert_eq!(id1, id2); + } + + #[test] + fn test_deterministic_id_varies_by_content() { + let body1 = json!({ + "messages": [{"role": "user", "content": "Hello"}] + }); + let body2 = json!({ + "messages": [{"role": "user", "content": "Goodbye"}] + }); + let id1 = deterministic_request_id(&body1, "session1"); + let id2 = deterministic_request_id(&body2, "session1"); + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_varies_by_session() { + let body = json!({ + "messages": [{"role": "user", "content": "Hello"}] + }); + let id1 = deterministic_request_id(&body, "session1"); + let id2 = deterministic_request_id(&body, "session2"); + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_ignores_tool_result() { + // tool_result 内容不同,但 user text 相同 → 相同 ID + let body1 = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "version_A"} + ]}, + {"role": "user", "content": "do something"} + ] + }); + let body2 = json!({ + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "version_B"} + ]}, + {"role": "user", "content": "do something"} + ] + }); + let id1 = deterministic_request_id(&body1, "s"); + let id2 = deterministic_request_id(&body2, "s"); + assert_eq!(id1, id2); + } + + #[test] + fn test_deterministic_id_fallback_when_no_user_content() { + // 无用户消息 → 退化为随机 UUID(每次不同) + let body = json!({ + "messages": [ + {"role": "assistant", "content": "Hi"} + ] + }); + let id1 = deterministic_request_id(&body, "s"); + let id2 = deterministic_request_id(&body, "s"); + // 随机 UUID,每次应不同 + assert_ne!(id1, id2); + } + + #[test] + fn test_deterministic_id_is_valid_uuid() { + let body = json!({ + "messages": [{"role": "user", "content": "test"}] + }); + let id = deterministic_request_id(&body, "session"); + assert!(Uuid::parse_str(&id).is_ok()); + } + + // === compact 检测增强测试 === + + #[test] + fn test_compact_detection_system_prompt() { + let body = json!({ + "system": "You are a helpful AI assistant tasked with summarizing conversations. Please provide a concise summary.", + "messages": [ + {"role": "user", "content": "Here is the conversation to summarize..."} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_critical_keyword() { + let body = json!({ + "messages": [ + {"role": "user", "content": "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. Summarize this conversation."} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_structural_markers() { + // Claude Code compact 特有的结构标记 + let body = json!({ + "messages": [ + {"role": "user", "content": "Summary of conversation:\n\nPending Tasks:\n- Fix bug\n\nCurrent Work:\n- Implementing feature"} + ] + }); + assert!(is_compact_request(&body)); + } + + #[test] + fn test_compact_no_false_positive_on_generic_summary() { + // 通用短语不应触发 compact 检测 + let body = json!({ + "messages": [ + {"role": "user", "content": "Your task is to create a detailed summary of the conversation so far."} + ] + }); + assert!(!is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_negative() { + let body = json!({ + "messages": [ + {"role": "user", "content": "What is the weather today?"} + ] + }); + assert!(!is_compact_request(&body)); + } + + #[test] + fn test_compact_detection_system_array() { + let body = json!({ + "system": [ + {"type": "text", "text": "You are a helpful AI assistant tasked with summarizing conversations."} + ], + "messages": [ + {"role": "user", "content": "Summarize"} + ] + }); + assert!(is_compact_request(&body)); + } +} diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 635a8bc32..13516c383 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -14,7 +14,7 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, - types::{OptimizerConfig, ProxyStatus, RectifierConfig}, + types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; use crate::commands::CopilotAuthState; @@ -52,6 +52,8 @@ pub struct RequestForwarder { rectifier_config: RectifierConfig, /// 优化器配置 optimizer_config: OptimizerConfig, + /// Copilot 优化器配置 + copilot_optimizer_config: CopilotOptimizerConfig, /// 非流式请求超时(秒) non_streaming_timeout: std::time::Duration, } @@ -70,6 +72,7 @@ impl RequestForwarder { _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, optimizer_config: OptimizerConfig, + copilot_optimizer_config: CopilotOptimizerConfig, ) -> Self { Self { router, @@ -80,6 +83,7 @@ impl RequestForwarder { current_provider_id_at_start, rectifier_config, optimizer_config, + copilot_optimizer_config, non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -760,7 +764,7 @@ impl RequestForwarder { super::model_mapper::apply_model_mapping(body.clone(), provider); // 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口) - let mapped_body = normalize_thinking_type(mapped_body); + let mut mapped_body = normalize_thinking_type(mapped_body); // 确定有效端点 // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) @@ -771,6 +775,62 @@ impl RequestForwarder { == Some("github_copilot") || base_url.contains("githubcopilot.com"); + // --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) --- + // 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move + let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled { + // 1. Tool result 合并 — 必须在分类之前执行 + // 合并将 [tool_result, text] 变为 [tool_result(含text)], + // 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block) + if self.copilot_optimizer_config.tool_result_merging { + mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body); + } + + // 2. 在合并后的 body 上进行分类 + let has_anthropic_beta = headers.contains_key("anthropic-beta"); + let classification = super::copilot_optimizer::classify_request( + &mapped_body, + has_anthropic_beta, + self.copilot_optimizer_config.compact_detection, + ); + + log::debug!( + "[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}", + classification.initiator, + classification.is_warmup, + classification.is_compact + ); + + // 3. Warmup 小模型降级 + if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup { + log::info!( + "[Copilot] Warmup 请求降级到模型: {}", + self.copilot_optimizer_config.warmup_model + ); + mapped_body["model"] = + serde_json::json!(&self.copilot_optimizer_config.warmup_model); + } + + // 预计算确定性 Request ID(在 body 被 move 之前) + // 使用 session_id 从 body.metadata.user_id 或请求头提取 + let session_id = body + .pointer("/metadata/user_id") + .and_then(|v| v.as_str()) + .or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok())) + .unwrap_or(""); + let det_request_id = if self.copilot_optimizer_config.deterministic_request_id { + Some(super::copilot_optimizer::deterministic_request_id( + &mapped_body, + session_id, + )) + } else { + None + }; + + Some((classification, det_request_id)) + } else { + None + }; + // GitHub Copilot 动态 endpoint 路由 // 从 CopilotAuthManager 获取缓存的 API endpoint(支持企业版等非默认 endpoint) if is_copilot && !is_full_url { @@ -858,7 +918,7 @@ impl RequestForwarder { || should_force_identity_encoding(&effective_endpoint, &filtered_body, headers); // 获取认证头(提前准备,用于内联替换) - let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { + let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) { // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token if auth.strategy == AuthStrategy::GitHubCopilot { if let Some(app_handle) = &self.app_handle { @@ -914,6 +974,25 @@ impl RequestForwarder { Vec::new() }; + // --- Copilot 优化器:动态 header 注入 --- + if let Some((ref classification, ref det_request_id)) = copilot_optimization { + for (name, value) in auth_headers.iter_mut() { + match name.as_str() { + "x-initiator" if self.copilot_optimizer_config.request_classification => { + *value = http::HeaderValue::from_static(classification.initiator); + } + "x-request-id" | "x-agent-task-id" => { + if let Some(ref det_id) = det_request_id { + if let Ok(hv) = http::HeaderValue::from_str(det_id) { + *value = hv; + } + } + } + _ => {} + } + } + } + // Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重) let copilot_fingerprint_headers: &[&str] = if is_copilot { &[ diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 4f36434db..bad855a91 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -8,7 +8,7 @@ use crate::proxy::{ extract_session_id, forwarder::RequestForwarder, server::ProxyState, - types::{AppProxyConfig, OptimizerConfig, RectifierConfig}, + types::{AppProxyConfig, CopilotOptimizerConfig, OptimizerConfig, RectifierConfig}, ProxyError, }; use axum::http::HeaderMap; @@ -61,6 +61,8 @@ pub struct RequestContext { pub rectifier_config: RectifierConfig, /// 优化器配置 pub optimizer_config: OptimizerConfig, + /// Copilot 优化器配置 + pub copilot_optimizer_config: CopilotOptimizerConfig, } impl RequestContext { @@ -96,6 +98,7 @@ impl RequestContext { // 从数据库读取整流器配置 let rectifier_config = state.db.get_rectifier_config().unwrap_or_default(); let optimizer_config = state.db.get_optimizer_config().unwrap_or_default(); + let copilot_optimizer_config = state.db.get_copilot_optimizer_config().unwrap_or_default(); let current_provider_id = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -160,6 +163,7 @@ impl RequestContext { session_id, rectifier_config, optimizer_config, + copilot_optimizer_config, }) } @@ -221,6 +225,7 @@ impl RequestContext { idle_timeout, self.rectifier_config.clone(), self.optimizer_config.clone(), + self.copilot_optimizer_config.clone(), ) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 93d819ea1..a14818c5d 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -5,6 +5,7 @@ pub mod body_filter; pub mod cache_injector; pub mod circuit_breaker; +pub mod copilot_optimizer; pub mod error; pub mod error_mapper; pub(crate) mod failover_switch; diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 6f5c19fe1..08df9e6eb 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -269,6 +269,54 @@ impl Default for OptimizerConfig { } } +/// Copilot 优化器配置 +/// +/// 存储在 settings 表中,key = "copilot_optimizer_config" +/// 解决 Copilot 代理消耗量异常问题(Issue #1813) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CopilotOptimizerConfig { + /// 总开关(默认开启 — 对 Copilot 用户至关重要) + #[serde(default = "default_true")] + pub enabled: bool, + /// x-initiator 请求分类(默认开启,P0 优先级) + #[serde(default = "default_true")] + pub request_classification: bool, + /// Tool result 消息合并(默认开启,P1 优先级) + #[serde(default = "default_true")] + pub tool_result_merging: bool, + /// Compact 请求识别(默认开启,P2 优先级) + #[serde(default = "default_true")] + pub compact_detection: bool, + /// 确定性 Request ID(默认开启,P3 优先级) + #[serde(default = "default_true")] + pub deterministic_request_id: bool, + /// Warmup 小模型降级(默认关闭,P4 优先级,opt-in) + #[serde(default)] + pub warmup_downgrade: bool, + /// Warmup 降级使用的模型(默认 "gpt-4o-mini") + #[serde(default = "default_warmup_model")] + pub warmup_model: String, +} + +fn default_warmup_model() -> String { + "gpt-4o-mini".to_string() +} + +impl Default for CopilotOptimizerConfig { + fn default() -> Self { + Self { + enabled: true, + request_classification: true, + tool_result_merging: true, + compact_detection: true, + deterministic_request_id: true, + warmup_downgrade: false, + warmup_model: "gpt-4o-mini".to_string(), + } + } +} + /// 日志配置 /// /// 存储在 settings 表的 log_config 字段中(JSON 格式) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 72f8821ed..9f9b06c58 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -313,11 +313,10 @@ pub fn create_tray_menu( if providers.is_empty() { // 空供应商:显示禁用的菜单项 let label = format!("{} {}", section.header_label, tray_texts.no_providers_label); - let empty_item = - MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>) - .map_err(|e| { - AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)) - })?; + let empty_item = MenuItem::with_id(app, section.empty_id, &label, false, None::<&str>) + .map_err(|e| { + AppError::Message(format!("创建{}空提示失败: {e}", section.log_name)) + })?; menu_builder = menu_builder.item(&empty_item); } else { // 有供应商:构建子菜单 From 159279bf4459007f2e4ffefa5ffa37aaf3b201c2 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 08:34:16 +0800 Subject: [PATCH 012/128] feat: restore Copilot preset and auth center tab in settings Re-enable GitHub Copilot provider preset and the OAuth auth center tab that were temporarily hidden due to abnormal consumption rates. The Copilot optimizer introduced in the previous commit addresses the underlying issue. --- src/components/settings/SettingsPage.tsx | 8 +++----- src/config/claudeProviderPresets.ts | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ba5e5e333..cb5361598 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { ScrollText, HardDriveDownload, FlaskConical, + KeyRound, } from "lucide-react"; import { toast } from "sonner"; import { @@ -42,6 +43,7 @@ import { ProxyTabContent } from "@/components/settings/ProxyTabContent"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; +import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -189,16 +191,14 @@ export function SettingsPage({ onValueChange={setActiveTab} className="flex flex-col h-full" > - + {t("settings.tabGeneral")} {t("settings.tabProxy")} - {/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快 {t("settings.tabAuth", { defaultValue: "认证" })} - */} {t("settings.tabAdvanced")} @@ -254,7 +254,6 @@ export function SettingsPage({ ) : null} - {/* HIDDEN: Copilot auth tab temporarily disabled - 用户反馈消耗过快 - */} {settings ? ( diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 8a577834b..2ed5dcd88 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -707,7 +707,6 @@ export const providerPresets: ProviderPreset[] = [ requiresOAuth: true, icon: "github", iconColor: "#000000", - hidden: true, // 临时隐藏:用户反馈消耗速度过快 }, { name: "Nvidia", From 8d38f0ee4f003e51d30f5345bc6c446bb6c17734 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 09:39:51 +0800 Subject: [PATCH 013/128] fix: allow provider switch without proxy, show warning instead of blocking Remove the hard block that prevented switching to providers requiring proxy (OpenAI format, Copilot, full URL mode) when the proxy is not running. Now the switch proceeds with a warning toast. Also deduplicate the proxy hint info toast so it doesn't appear alongside the warning. --- src/hooks/useProviderActions.ts | 4 ++-- tests/hooks/useProviderActions.test.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index a694e9484..346de6e5b 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -183,7 +183,6 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理", }), ); - return; } try { @@ -203,13 +202,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { // 根据供应商类型显示不同的成功提示 if ( + !proxyRequiredReason && activeApp === "claude" && provider.category !== "official" && (isCopilotProvider || provider.meta?.apiFormat === "openai_chat" || provider.meta?.apiFormat === "openai_responses") ) { - // OpenAI format provider: show proxy hint + // OpenAI format provider: show proxy hint (skip if warning already shown) toast.info( isCopilotProvider ? t("notifications.copilotProxyHint") diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index 8a7a2e8b4..2387b76a2 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -194,7 +194,8 @@ describe("useProviderActions", () => { expect(settingsApiApplyMock).not.toHaveBeenCalled(); }); - it("blocks switching providers that require proxy when proxy is not running", async () => { + it("warns but still switches providers that require proxy when proxy is not running", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); const { wrapper } = createWrapper(); const provider = createProvider({ category: "custom", @@ -211,12 +212,12 @@ describe("useProviderActions", () => { await result.current.switchProvider(provider); }); - expect(switchProviderMutateAsync).not.toHaveBeenCalled(); expect(toastWarningMock).toHaveBeenCalledTimes(1); - expect(settingsApiGetMock).not.toHaveBeenCalled(); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); }); - it("blocks switching Codex full URL providers when proxy is not running", async () => { + it("warns but still switches Codex full URL providers when proxy is not running", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); const { wrapper } = createWrapper(); const provider = createProvider({ category: "custom", @@ -233,9 +234,8 @@ describe("useProviderActions", () => { await result.current.switchProvider(provider); }); - expect(switchProviderMutateAsync).not.toHaveBeenCalled(); expect(toastWarningMock).toHaveBeenCalledTimes(1); - expect(settingsApiGetMock).not.toHaveBeenCalled(); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); }); it("should sync plugin config when switching Claude provider with integration enabled", async () => { From bfdac2a22ac6039aa5e28825552809644c1f1ef5 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 11:39:29 +0800 Subject: [PATCH 014/128] feat: add Token Plan quota query for Kimi, Zhipu GLM, and MiniMax Add a new "Token Plan" template type in the usage query panel that natively queries quota/usage from Chinese coding plan providers (Kimi For Coding, Zhipu GLM, MiniMax) without requiring custom scripts. - Rust backend: new coding_plan service with provider-specific API queries (Kimi /v1/usages, Zhipu /api/monitor/usage/quota/limit, MiniMax /coding_plan/remains) normalized into UsageResult - Frontend: Token Plan template in UsageScriptModal with auto-detection of provider based on ANTHROPIC_BASE_URL pattern matching - Follows the same pattern as GitHub Copilot template (dedicated API path in queryProviderUsage, no JS script needed) --- src-tauri/src/commands/coding_plan.rs | 9 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider.rs | 96 ++++- src-tauri/src/deeplink/provider.rs | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/provider.rs | 4 + src-tauri/src/services/coding_plan.rs | 425 ++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/components/UsageScriptModal.tsx | 295 +++++++++++---- src/components/providers/ProviderCard.tsx | 12 +- src/config/constants.ts | 1 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/api/subscription.ts | 5 + src/types.ts | 1 + 16 files changed, 761 insertions(+), 98 deletions(-) create mode 100644 src-tauri/src/commands/coding_plan.rs create mode 100644 src-tauri/src/services/coding_plan.rs diff --git a/src-tauri/src/commands/coding_plan.rs b/src-tauri/src/commands/coding_plan.rs new file mode 100644 index 000000000..0654cd8e8 --- /dev/null +++ b/src-tauri/src/commands/coding_plan.rs @@ -0,0 +1,9 @@ +use crate::services::subscription::SubscriptionQuota; + +#[tauri::command] +pub async fn get_coding_plan_quota( + base_url: String, + api_key: String, +) -> Result { + crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fed30fe30..4261b3da2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod auth; +mod coding_plan; mod config; mod copilot; mod deeplink; @@ -30,6 +31,7 @@ mod webdav_sync; mod workspace; pub use auth::*; +pub use coding_plan::*; pub use config::*; pub use copilot::*; pub use deeplink::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 8f4f07790..d505efb92 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -13,6 +13,7 @@ use std::str::FromStr; // 常量定义 const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; +const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan"; const COPILOT_UNIT_PREMIUM: &str = "requests"; /// 获取所有供应商 @@ -158,29 +159,25 @@ pub async fn queryProviderUsage( ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - // 检查是否为 GitHub Copilot 模板类型,并解析绑定账号 - let (is_copilot_template, copilot_account_id) = { - let providers = state - .db - .get_all_providers(app_type.as_str()) - .map_err(|e| format!("Failed to get providers: {e}"))?; + // 从数据库读取供应商信息,检查特殊模板类型 + let providers = state + .db + .get_all_providers(app_type.as_str()) + .map_err(|e| format!("Failed to get providers: {e}"))?; + let provider = providers.get(&providerId); + let usage_script = provider + .and_then(|p| p.meta.as_ref()) + .and_then(|m| m.usage_script.as_ref()); + let template_type = usage_script + .and_then(|s| s.template_type.as_deref()) + .unwrap_or(""); - let provider = providers.get(&providerId); - let is_copilot = provider - .and_then(|p| p.meta.as_ref()) - .and_then(|m| m.usage_script.as_ref()) - .and_then(|s| s.template_type.as_ref()) - .map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT) - .unwrap_or(false); - let account_id = provider + // ── GitHub Copilot 专用路径 ── + if template_type == TEMPLATE_TYPE_GITHUB_COPILOT { + let copilot_account_id = provider .and_then(|p| p.meta.as_ref()) .and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT)); - (is_copilot, account_id) - }; - - if is_copilot_template { - // 使用 Copilot 专用 API let auth_manager = copilot_state.0.read().await; let usage = match copilot_account_id.as_deref() { Some(account_id) => auth_manager @@ -211,6 +208,67 @@ pub async fn queryProviderUsage( }); } + // ── Coding Plan 专用路径 ── + if template_type == TEMPLATE_TYPE_TOKEN_PLAN { + // 从供应商配置中提取 API Key 和 Base URL + let settings_config = provider + .map(|p| &p.settings_config) + .cloned() + .unwrap_or_default(); + let env = settings_config.get("env"); + let base_url = env + .and_then(|e| e.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let api_key = env + .and_then(|e| { + e.get("ANTHROPIC_AUTH_TOKEN") + .or_else(|| e.get("ANTHROPIC_API_KEY")) + }) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key) + .await + .map_err(|e| format!("Failed to query coding plan: {e}"))?; + + // 将 SubscriptionQuota 转换为 UsageResult + if !quota.success { + return Ok(crate::provider::UsageResult { + success: false, + data: None, + error: quota.error, + }); + } + + let data: Vec = quota + .tiers + .iter() + .map(|tier| { + let total = 100.0; + let used = tier.utilization; + let remaining = total - used; + crate::provider::UsageData { + plan_name: Some(tier.name.clone()), + remaining: Some(remaining), + total: Some(total), + used: Some(used), + unit: Some("%".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: tier.resets_at.clone(), + } + }) + .collect(); + + return Ok(crate::provider::UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + }); + } + + // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state.inner(), app_type, &providerId) .await .map_err(|e| e.to_string()) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index d94189481..87e07282f 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -229,6 +229,7 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result, + /// Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "codingPlanProvider")] + pub coding_plan_provider: Option, } /// 用量数据 diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs new file mode 100644 index 000000000..ae051d51f --- /dev/null +++ b/src-tauri/src/services/coding_plan.rs @@ -0,0 +1,425 @@ +//! 国产 Token Plan 额度查询服务 +//! +//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。 +//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。 + +use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// ── 供应商检测 ────────────────────────────────────────────── + +enum CodingPlanProvider { + Kimi, + ZhipuCn, + ZhipuEn, + MiniMaxCn, + MiniMaxEn, +} + +fn detect_provider(base_url: &str) -> Option { + let url = base_url.to_lowercase(); + if url.contains("api.kimi.com/coding") { + Some(CodingPlanProvider::Kimi) + } else if url.contains("open.bigmodel.cn") || url.contains("bigmodel.cn") { + Some(CodingPlanProvider::ZhipuCn) + } else if url.contains("api.z.ai") { + Some(CodingPlanProvider::ZhipuEn) + } else if url.contains("api.minimaxi.com") { + Some(CodingPlanProvider::MiniMaxCn) + } else if url.contains("api.minimax.io") { + Some(CodingPlanProvider::MiniMaxEn) + } else { + None + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +fn millis_to_iso8601(ms: i64) -> Option { + let secs = ms / 1000; + let nsecs = ((ms % 1000) * 1_000_000) as u32; + chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339()) +} + +fn make_error(msg: String) -> SubscriptionQuota { + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: Some(msg), + queried_at: Some(now_millis()), + } +} + +// ── Kimi For Coding ───────────────────────────────────────── + +async fn query_kimi(api_key: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.kimi.com/coding/v1/usages") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let mut tiers = Vec::new(); + + // 总体用量(周限额) + if let Some(usage) = body.get("usage") { + let used = usage.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); + let limit = usage.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); + let resets_at = usage + .get("reset_at") + .or_else(|| usage.get("resetAt")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + tiers.push(QuotaTier { + name: "weekly_limit".to_string(), + utilization, + resets_at, + }); + } + + // 会话限额(5 小时窗口) + if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + if let Some(detail) = limit_item.get("detail") { + let used = detail.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); + let limit = detail.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); + let resets_at = detail + .get("reset_at") + .or_else(|| detail.get("resetAt")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + tiers.push(QuotaTier { + name: "session_limit".to_string(), + utilization, + resets_at, + }); + } + } + } + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 智谱 GLM ──────────────────────────────────────────────── + +async fn query_zhipu(api_key: &str) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + // 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制) + let resp = client + .get("https://api.z.ai/api/monitor/usage/quota/limit") + .header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀 + .header("Content-Type", "application/json") + .header("Accept-Language", "en-US,en") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // 检查业务级别错误 + if body.get("success").and_then(|v| v.as_bool()) == Some(false) { + let msg = body + .get("msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return make_error(format!("API error: {msg}")); + } + + let data = match body.get("data") { + Some(d) => d, + None => return make_error("Missing 'data' field in response".to_string()), + }; + + let mut tiers = Vec::new(); + + if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + let limit_type = limit_item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let percentage = limit_item + .get("percentage") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let next_reset = limit_item + .get("nextResetTime") + .and_then(|v| v.as_i64()) + .and_then(millis_to_iso8601); + + let tier_name = match limit_type { + "TOKENS_LIMIT" => "tokens_limit", + "TIME_LIMIT" => "mcp_limit", + _ => continue, + }; + + tiers.push(QuotaTier { + name: tier_name.to_string(), + utilization: percentage, + resets_at: next_reset, + }); + } + } + + // 套餐等级存入 credential_message + let level = data + .get("level") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: level, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── MiniMax ───────────────────────────────────────────────── + +async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { + let client = crate::proxy::http_client::get(); + + let api_domain = if is_cn { + "api.minimaxi.com" + } else { + "api.minimax.io" + }; + let url = format!("https://{api_domain}/v1/api/openplatform/coding_plan/remains"); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Expired, + credential_message: Some("Invalid API key".to_string()), + success: false, + tiers: vec![], + extra_usage: None, + error: Some(format!("Authentication failed (HTTP {status})")), + queried_at: Some(now_millis()), + }; + } + + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // 检查业务级别错误 + if let Some(base_resp) = body.get("base_resp") { + let status_code = base_resp + .get("status_code") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + if status_code != 0 { + let msg = base_resp + .get("status_msg") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return make_error(format!("API error (code {status_code}): {msg}")); + } + } + + let mut tiers = Vec::new(); + + if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) { + for item in model_remains { + let model_name = item + .get("model_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let total = item + .get("current_interval_total_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + // 注意:current_interval_usage_count 名字有误导,实际是"剩余量" + let remaining = item + .get("current_interval_usage_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let end_time = item.get("end_time").and_then(|v| v.as_i64()); + + let utilization = if total > 0.0 { + ((total - remaining) / total) * 100.0 + } else { + 0.0 + }; + + tiers.push(QuotaTier { + name: model_name.to_string(), + utilization, + resets_at: end_time.and_then(millis_to_iso8601), + }); + } + } + + SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::Valid, + credential_message: None, + success: true, + tiers, + extra_usage: None, + error: None, + queried_at: Some(now_millis()), + } +} + +// ── 公开入口 ──────────────────────────────────────────────── + +pub async fn get_coding_plan_quota( + base_url: &str, + api_key: &str, +) -> Result { + if api_key.trim().is_empty() { + return Ok(SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + }); + } + + let provider = match detect_provider(base_url) { + Some(p) => p, + None => { + return Ok(SubscriptionQuota { + tool: "coding_plan".to_string(), + credential_status: CredentialStatus::NotFound, + credential_message: None, + success: false, + tiers: vec![], + extra_usage: None, + error: None, + queried_at: None, + }) + } + }; + + let quota = match provider { + CodingPlanProvider::Kimi => query_kimi(api_key).await, + CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await, + CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await, + CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await, + }; + + Ok(quota) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a7c75e162..ce5cc4dd0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod coding_plan; pub mod config; pub mod env_checker; pub mod env_manager; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c9b850151..131deb267 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -95,6 +95,9 @@ const generatePresetTemplates = ( // GitHub Copilot 模板不需要脚本,使用专用 API [TEMPLATE_TYPES.GITHUB_COPILOT]: "", + + // Coding Plan 模板不需要脚本,使用专用 Rust 查询 + [TEMPLATE_TYPES.TOKEN_PLAN]: "", }); // 模板名称国际化键映射 @@ -103,8 +106,33 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral", [TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI", [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", + [TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan", }; +/** Coding Plan 供应商选项 */ +const TOKEN_PLAN_PROVIDERS = [ + { id: "kimi", label: "Kimi For Coding", pattern: /api\.kimi\.com\/coding/i }, + { + id: "zhipu", + label: "Zhipu GLM (智谱)", + pattern: /bigmodel\.cn|api\.z\.ai/i, + }, + { + id: "minimax", + label: "MiniMax", + pattern: /api\.minimaxi?\.com|api\.minimax\.io/i, + }, +] as const; + +/** 根据 Base URL 自动检测 Coding Plan 供应商 */ +function detectTokenPlanProvider(baseUrl: string | undefined): string | null { + if (!baseUrl) return null; + for (const cp of TOKEN_PLAN_PROVIDERS) { + if (cp.pattern.test(baseUrl)) return cp.id; + } + return null; +} + const UsageScriptModal: React.FC = ({ provider, appId, @@ -164,18 +192,39 @@ const UsageScriptModal: React.FC = ({ const [script, setScript] = useState(() => { const savedScript = provider.meta?.usage_script; - const defaultScript = { + if (savedScript) { + // 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充 + if ( + savedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN && + !savedScript.codingPlanProvider + ) { + return { + ...savedScript, + codingPlanProvider: + detectTokenPlanProvider(providerCredentials.baseUrl) || "kimi", + }; + } + return savedScript; + } + + // 新配置:如果 URL 匹配 Coding Plan,自动初始化 + const autoDetected = detectTokenPlanProvider(providerCredentials.baseUrl); + if (autoDetected) { + return { + enabled: false, + language: "javascript" as const, + code: "", + timeout: 10, + codingPlanProvider: autoDetected, + }; + } + + return { enabled: false, language: "javascript" as const, code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], timeout: 10, }; - - if (!savedScript) { - return defaultScript; - } - - return savedScript; }); const [testing, setTesting] = useState(false); @@ -236,7 +285,7 @@ const UsageScriptModal: React.FC = ({ } // 优先使用保存的 templateType if (existingScript?.templateType) { - return existingScript.templateType; + return existingScript.templateType as string; } // 向后兼容:根据字段推断模板类型 // 检测 NEW_API 模板(有 accessToken 或 userId) @@ -247,7 +296,11 @@ const UsageScriptModal: React.FC = ({ if (existingScript?.apiKey || existingScript?.baseUrl) { return TEMPLATE_TYPES.GENERAL; } - // 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致) + // 新配置:如果 URL 匹配 Coding Plan 供应商,自动选择 Coding Plan 模板 + if (detectTokenPlanProvider(providerCredentials.baseUrl)) { + return TEMPLATE_TYPES.TOKEN_PLAN; + } + // 默认使用 GENERAL(与默认代码模板一致) return TEMPLATE_TYPES.GENERAL; }, ); @@ -278,8 +331,11 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - // Copilot 模板不需要脚本验证 - if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) { + // Copilot 和 Coding Plan 模板不需要脚本验证 + if ( + selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN + ) { if (script.enabled && !script.code.trim()) { toast.error(t("usageScript.scriptEmpty")); return; @@ -297,6 +353,7 @@ const UsageScriptModal: React.FC = ({ | "general" | "newapi" | "github_copilot" + | "token_plan" | undefined, }; onSave(scriptWithTemplate); @@ -306,6 +363,45 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // Coding Plan 模板使用专用 API + if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { + const config = provider.settingsConfig as Record; + const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? ""; + const apiKey: string = + config?.env?.ANTHROPIC_AUTH_TOKEN ?? + config?.env?.ANTHROPIC_API_KEY ?? + ""; + const { subscriptionApi } = await import("@/lib/api/subscription"); + const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey); + if (quota.success && quota.tiers.length > 0) { + const summary = quota.tiers + .map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`) + .join(", "); + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + // 将结果转换为 UsageResult 格式更新缓存 + const usageData = quota.tiers.map((tier) => ({ + planName: tier.name, + remaining: 100 - tier.utilization, + total: 100, + used: tier.utilization, + unit: "%", + })); + queryClient.setQueryData(["usage", provider.id, appId], { + success: true, + data: usageData, + }); + } else { + toast.error( + `${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`, + { duration: 5000 }, + ); + } + return; + } + // Copilot 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) { const accountId = resolveManagedAccountId( @@ -410,7 +506,7 @@ const UsageScriptModal: React.FC = ({ const handleUsePreset = (presetName: string) => { const preset = PRESET_TEMPLATES[presetName]; - if (preset) { + if (preset !== undefined) { if (presetName === TEMPLATE_TYPES.CUSTOM) { // 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换 // 这样可以避免同源检查导致的问题 @@ -447,6 +543,21 @@ const UsageScriptModal: React.FC = ({ accessToken: undefined, userId: undefined, }); + } else if (presetName === TEMPLATE_TYPES.TOKEN_PLAN) { + // Coding Plan 模板不需要脚本,使用 Rust 原生查询 + const autoDetected = detectTokenPlanProvider( + providerCredentials.baseUrl, + ); + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + codingPlanProvider: + script.codingPlanProvider || autoDetected || "kimi", + }); } setSelectedTemplate(presetName); } @@ -529,10 +640,11 @@ const UsageScriptModal: React.FC = ({ .filter((name) => { const isCopilotProvider = provider.meta?.providerType === "github_copilot"; - // Copilot 供应商只显示 copilot 模板,其他供应商不显示 copilot 模板 + // Copilot 供应商只显示 copilot 模板 if (isCopilotProvider) { return name === TEMPLATE_TYPES.GITHUB_COPILOT; } + // 非 Copilot 供应商不显示 copilot 模板 return name !== TEMPLATE_TYPES.GITHUB_COPILOT; }) .map((name) => { @@ -634,6 +746,43 @@ const UsageScriptModal: React.FC = ({
)} + {/* Coding Plan 模式:供应商选择 */} + {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && ( +
+

+ {t("usageScript.tokenPlanHint")} +

+
+ {TOKEN_PLAN_PROVIDERS.map((cp) => ( + + ))} +
+
+ )} + {/* 凭证配置 */} {shouldShowCredentialsConfig && (
@@ -860,40 +1009,42 @@ const UsageScriptModal: React.FC = ({
{/* 提取器代码 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && ( -
-
- -
- {t("usageScript.extractorHint")} + {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && ( +
+
+ +
+ {t("usageScript.extractorHint")} +
+ + setScript((prev) => ({ ...prev, code: value })) + } + height={480} + language="javascript" + showMinimap={false} + />
- - setScript((prev) => ({ ...prev, code: value })) - } - height={480} - language="javascript" - showMinimap={false} - /> -
- )} + )} {/* 帮助信息 - Copilot 模板不需要 */} - {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && ( -
-

- {t("usageScript.scriptHelp")} -

-
-
- {t("usageScript.configFormat")} -
-                    {`({
+          {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
+            selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
+              
+

+ {t("usageScript.scriptHelp")} +

+
+
+ {t("usageScript.configFormat")} +
+                      {`({
   request: {
     url: "{{baseUrl}}/api/usage",
     method: "POST",
@@ -910,39 +1061,39 @@ const UsageScriptModal: React.FC = ({
     };
   }
 })`}
-                  
-
+
+
-
- {t("usageScript.extractorFormat")} -
    -
  • {t("usageScript.fieldIsValid")}
  • -
  • {t("usageScript.fieldInvalidMessage")}
  • -
  • {t("usageScript.fieldRemaining")}
  • -
  • {t("usageScript.fieldUnit")}
  • -
  • {t("usageScript.fieldPlanName")}
  • -
  • {t("usageScript.fieldTotal")}
  • -
  • {t("usageScript.fieldUsed")}
  • -
  • {t("usageScript.fieldExtra")}
  • -
-
+
+ {t("usageScript.extractorFormat")} +
    +
  • {t("usageScript.fieldIsValid")}
  • +
  • {t("usageScript.fieldInvalidMessage")}
  • +
  • {t("usageScript.fieldRemaining")}
  • +
  • {t("usageScript.fieldUnit")}
  • +
  • {t("usageScript.fieldPlanName")}
  • +
  • {t("usageScript.fieldTotal")}
  • +
  • {t("usageScript.fieldUsed")}
  • +
  • {t("usageScript.fieldExtra")}
  • +
+
-
- {t("usageScript.tips")} -
    -
  • - {t("usageScript.tip1", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -
  • -
  • {t("usageScript.tip2")}
  • -
  • {t("usageScript.tip3")}
  • -
+
+ {t("usageScript.tips")} +
    +
  • + {t("usageScript.tip1", { + apiKey: "{{apiKey}}", + baseUrl: "{{baseUrl}}", + })} +
  • +
  • {t("usageScript.tip2")}
  • +
  • {t("usageScript.tip3")}
  • +
+
-
- )} + )}
)} diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 90750b743..c58d23e77 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -343,9 +343,7 @@ export function ProviderCard({
-
+
{isOfficial ? ( @@ -393,9 +391,7 @@ export function ProviderCard({
-
+
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} - onTest={onTest && !isOfficial ? () => onTest(provider) : undefined} + onTest={ + onTest && !isOfficial ? () => onTest(provider) : undefined + } onConfigureUsage={ isOfficial ? undefined : () => onConfigureUsage(provider) } diff --git a/src/config/constants.ts b/src/config/constants.ts index 07d2a686c..de9db8a68 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -9,6 +9,7 @@ export const TEMPLATE_TYPES = { GENERAL: "general", NEW_API: "newapi", GITHUB_COPILOT: "github_copilot", + TOKEN_PLAN: "token_plan", } as const; export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d5a15b69e..455b33dd3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1090,7 +1090,9 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", + "tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota", "resetDate": "Reset date", "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2babea0ff..77a05fa6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1090,7 +1090,9 @@ "templateGeneral": "General", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", + "tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ", "resetDate": "リセット日", "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ea08109a8..a073f547d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1090,7 +1090,9 @@ "templateGeneral": "通用模板", "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", + "templateTokenPlan": "Token Plan", "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", + "tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度", "resetDate": "重置日期", "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index df7eaba43..9c572f0ec 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -4,4 +4,9 @@ import type { SubscriptionQuota } from "@/types/subscription"; export const subscriptionApi = { getQuota: (tool: string): Promise => invoke("get_subscription_quota", { tool }), + getCodingPlanQuota: ( + baseUrl: string, + apiKey: string, + ): Promise => + invoke("get_coding_plan_quota", { baseUrl, apiKey }), }; diff --git a/src/types.ts b/src/types.ts index e6a7f358a..868a05cc6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,7 @@ export interface UsageScript { baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用) accessToken?: string; // 访问令牌(NewAPI 模板使用) userId?: string; // 用户ID(NewAPI 模板使用) + codingPlanProvider?: string; // Coding Plan 供应商标识(如 "kimi", "zhipu", "minimax") autoQueryInterval?: number; // 自动查询间隔(单位:分钟,0 表示禁用) autoIntervalMinutes?: number; // 自动查询间隔(分钟)- 别名字段 request?: { From ca6a187745d0ca6f7cfea53d619e0d7daecc1fee Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 13:35:54 +0800 Subject: [PATCH 015/128] fix: correct MiniMax quota calculation and improve Token Plan display - Fix MiniMax usage_count being treated as remaining (was inverted) - Add MiniMax weekly quota tier extraction - Remove Zhipu TIME_LIMIT (tools usage), keep only TOKENS_LIMIT - Improve Kimi parsing with extract_reset_time and parse_f64 helpers - Reuse TierBadge for Token Plan inline rendering - Clean up unused i18n keys and debug println --- src-tauri/src/services/coding_plan.rs | 140 ++++++++++++--------- src/components/SubscriptionQuotaFooter.tsx | 12 +- src/components/UsageFooter.tsx | 51 +++++++- src/components/providers/ProviderCard.tsx | 4 +- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 7 files changed, 146 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index ae051d51f..34d800c4c 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -46,6 +46,28 @@ fn millis_to_iso8601(ms: i64) -> Option { chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339()) } +/// 从 JSON 值提取重置时间,兼容字符串和数字格式 +/// - 字符串:直接返回(ISO 8601) +/// - 数字:自动判断秒/毫秒并转为 ISO 8601 +fn extract_reset_time(value: &serde_json::Value) -> Option { + if let Some(s) = value.as_str() { + return Some(s.to_string()); + } + if let Some(n) = value.as_i64() { + // 区分秒和毫秒:秒级时间戳 < 1e12,毫秒 >= 1e12 + let ms = if n < 1_000_000_000_000 { n * 1000 } else { n }; + return millis_to_iso8601(ms); + } + None +} + +/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`) +fn parse_f64(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| value.as_str().and_then(|s| s.parse().ok())) +} + fn make_error(msg: String) -> SubscriptionQuota { SubscriptionQuota { tool: "coding_plan".to_string(), @@ -103,16 +125,36 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { let mut tiers = Vec::new(); + // 5 小时窗口限额(优先显示) + if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { + for limit_item in limits { + if let Some(detail) = limit_item.get("detail") { + let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0); + let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0); + let resets_at = detail.get("resetTime").and_then(extract_reset_time); + + let used = (limit - remaining).max(0.0); + let utilization = if limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + tiers.push(QuotaTier { + name: "five_hour".to_string(), + utilization, + resets_at, + }); + } + } + } + // 总体用量(周限额) if let Some(usage) = body.get("usage") { - let used = usage.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); - let limit = usage.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); - let resets_at = usage - .get("reset_at") - .or_else(|| usage.get("resetAt")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let limit = usage.get("limit").and_then(parse_f64).unwrap_or(1.0); + let remaining = usage.get("remaining").and_then(parse_f64).unwrap_or(0.0); + let resets_at = usage.get("resetTime").and_then(extract_reset_time); + let used = (limit - remaining).max(0.0); let utilization = if limit > 0.0 { (used / limit) * 100.0 } else { @@ -125,32 +167,6 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota { }); } - // 会话限额(5 小时窗口) - if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) { - for limit_item in limits { - if let Some(detail) = limit_item.get("detail") { - let used = detail.get("used").and_then(|v| v.as_f64()).unwrap_or(0.0); - let limit = detail.get("limit").and_then(|v| v.as_f64()).unwrap_or(1.0); - let resets_at = detail - .get("reset_at") - .or_else(|| detail.get("resetAt")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let utilization = if limit > 0.0 { - (used / limit) * 100.0 - } else { - 0.0 - }; - tiers.push(QuotaTier { - name: "session_limit".to_string(), - utilization, - resets_at, - }); - } - } - } - SubscriptionQuota { tool: "coding_plan".to_string(), credential_status: CredentialStatus::Valid, @@ -238,14 +254,12 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota { .and_then(|v| v.as_i64()) .and_then(millis_to_iso8601); - let tier_name = match limit_type { - "TOKENS_LIMIT" => "tokens_limit", - "TIME_LIMIT" => "mcp_limit", - _ => continue, - }; + if limit_type != "TOKENS_LIMIT" { + continue; + } tiers.push(QuotaTier { - name: tier_name.to_string(), + name: "five_hour".to_string(), utilization: percentage, resets_at: next_reset, }); @@ -337,33 +351,45 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { let mut tiers = Vec::new(); if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) { - for item in model_remains { - let model_name = item - .get("model_name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let total = item + // 只取第一个模型(MiniMax-M*,主力编程模型) + if let Some(item) = model_remains.first() { + // 窗口额度(current_interval_usage_count = 已用量,非剩余量) + let interval_total = item .get("current_interval_total_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); - // 注意:current_interval_usage_count 名字有误导,实际是"剩余量" - let remaining = item + let interval_used = item .get("current_interval_usage_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); let end_time = item.get("end_time").and_then(|v| v.as_i64()); - let utilization = if total > 0.0 { - ((total - remaining) / total) * 100.0 - } else { - 0.0 - }; + if interval_total > 0.0 { + tiers.push(QuotaTier { + name: "five_hour".to_string(), + utilization: (interval_used / interval_total) * 100.0, + resets_at: end_time.and_then(millis_to_iso8601), + }); + } - tiers.push(QuotaTier { - name: model_name.to_string(), - utilization, - resets_at: end_time.and_then(millis_to_iso8601), - }); + // 周额度 + let weekly_total = item + .get("current_weekly_total_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let weekly_used = item + .get("current_weekly_usage_count") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let weekly_end = item.get("weekly_end_time").and_then(|v| v.as_i64()); + + if weekly_total > 0.0 { + tiers.push(QuotaTier { + name: "weekly_limit".to_string(), + utilization: (weekly_used / weekly_total) * 100.0, + resets_at: weekly_end.and_then(millis_to_iso8601), + }); + } } } diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 10d1906d2..32cb05489 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -10,8 +10,8 @@ interface SubscriptionQuotaFooterProps { inline?: boolean; } -/** 已知 tier 名称的显示映射 */ -const TIER_I18N_KEYS: Record = { +/** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ +export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", @@ -20,17 +20,19 @@ const TIER_I18N_KEYS: Record = { gemini_pro: "subscription.geminiPro", gemini_flash: "subscription.geminiFlash", gemini_flash_lite: "subscription.geminiFlashLite", + // Token Plan(five_hour 已在上方官方映射中) + weekly_limit: "subscription.weeklyLimit", }; /** 根据使用百分比返回颜色 class */ -function utilizationColor(utilization: number): string { +export function utilizationColor(utilization: number): string { if (utilization >= 90) return "text-red-500 dark:text-red-400"; if (utilization >= 70) return "text-orange-500 dark:text-orange-400"; return "text-green-600 dark:text-green-400"; } /** 计算倒计时的纯时间字符串,如 "2h30m"、"3d12h" */ -function countdownStr(resetsAt: string | null): string | null { +export function countdownStr(resetsAt: string | null): string | null { if (!resetsAt) return null; const diffMs = new Date(resetsAt).getTime() - Date.now(); if (diffMs <= 0) return null; @@ -278,7 +280,7 @@ const SubscriptionQuotaFooter: React.FC = ({ }; /** inline 模式下的单个 tier 显示 */ -const TierBadge: React.FC<{ +export const TierBadge: React.FC<{ tier: QuotaTier; t: (key: string, options?: Record) => string; }> = ({ tier, t }) => { diff --git a/src/components/UsageFooter.tsx b/src/components/UsageFooter.tsx index d48ffe844..9b0654e52 100644 --- a/src/components/UsageFooter.tsx +++ b/src/components/UsageFooter.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { type AppId } from "@/lib/api"; import { useUsageQuery } from "@/lib/query/queries"; import { UsageData, Provider } from "@/types"; +import { TierBadge } from "@/components/SubscriptionQuotaFooter"; +import type { QuotaTier } from "@/types/subscription"; interface UsageFooterProps { provider: Provider; @@ -15,6 +17,15 @@ interface UsageFooterProps { inline?: boolean; // 是否内联显示(在按钮左侧) } +/** UsageData → QuotaTier 转换(Token Plan 使用) */ +function toQuotaTier(data: UsageData): QuotaTier { + return { + name: data.planName || "", + utilization: data.used || 0, + resetsAt: data.extra || null, + }; +} + const UsageFooter: React.FC = ({ provider, providerId, @@ -25,6 +36,8 @@ const UsageFooter: React.FC = ({ inline = false, }) => { const { t } = useTranslation(); + const isTokenPlan = + provider.meta?.usage_script?.templateType === "token_plan"; // 统一的用量查询(自动查询仅对当前激活的供应商启用) // OpenCode(累加模式):使用 isInConfig 代替 isCurrent @@ -108,7 +121,41 @@ const UsageFooter: React.FC = ({ // 无数据时不显示 if (usageDataList.length === 0) return null; - // 内联模式:仅显示第一个套餐的核心数据(分上下两行) + // ── Token Plan:订阅风格内联渲染(百分比徽章 + 倒计时) ── + if (isTokenPlan && inline) { + return ( +
+ {/* 第一行:查询时间 + 刷新 */} +
+ + + {lastQueriedAt + ? formatRelativeTime(lastQueriedAt, now, t) + : t("usage.never", { defaultValue: "从未更新" })} + + +
+ {/* 第二行:tier 徽章(复用官方订阅的 TierBadge) */} +
+ {usageDataList.map((data, index) => ( + + ))} +
+
+ ); + } + + // ── 通用用量:内联模式(原有逻辑) ── if (inline) { const firstUsage = usageDataList[0]; const isExpired = firstUsage.isValid === false; @@ -231,6 +278,8 @@ const UsageFooter: React.FC = ({ ); }; +// ── 通用用量组件 ──────────────────────────────────────────── + // 单个套餐数据展示组件 const UsagePlanItem: React.FC<{ data: UsageData }> = ({ data }) => { const { t } = useTranslation(); diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index c58d23e77..cb450fa97 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -186,8 +186,10 @@ export function ProviderCard({ autoQueryInterval, }); + const isTokenPlan = + provider.meta?.usage_script?.templateType === "token_plan"; const hasMultiplePlans = - usage?.success && usage.data && usage.data.length > 1; + usage?.success && usage.data && usage.data.length > 1 && !isTokenPlan; const [isExpanded, setIsExpanded] = useState(false); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 455b33dd3..09f421fce 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "Weekly", "utilization": "{{value}}%", "resetsIn": "Resets in {{time}}", "extraUsage": "Extra Usage", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 77a05fa6d..145355ef3 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "週間", "utilization": "{{value}}%", "resetsIn": "{{time}}後にリセット", "extraUsage": "超過使用量", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a073f547d..75fc9232a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2288,6 +2288,7 @@ "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", + "weeklyLimit": "每周", "utilization": "{{value}}%", "resetsIn": "{{time}}后重置", "extraUsage": "超额用量", From 4685e5b59741c7b9a4eeb8eb8f3b76b797d0f1c3 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 13:41:52 +0800 Subject: [PATCH 016/128] =?UTF-8?q?fix:=20invert=20MiniMax=20usage=5Fcount?= =?UTF-8?q?=20to=20match=200%=E2=86=92100%=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit usage_count is remaining quota (starts at total, decreases to 0), not used count. Invert calculation so all providers consistently show 0% when fresh and 100% when exhausted. --- src-tauri/src/services/coding_plan.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/coding_plan.rs b/src-tauri/src/services/coding_plan.rs index 34d800c4c..565ee2cf0 100644 --- a/src-tauri/src/services/coding_plan.rs +++ b/src-tauri/src/services/coding_plan.rs @@ -353,12 +353,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { if let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) { // 只取第一个模型(MiniMax-M*,主力编程模型) if let Some(item) = model_remains.first() { - // 窗口额度(current_interval_usage_count = 已用量,非剩余量) + // usage_count 是剩余量(满额=total,用完=0),需反转为已用百分比 let interval_total = item .get("current_interval_total_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let interval_used = item + let interval_remaining = item .get("current_interval_usage_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); @@ -367,7 +367,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { if interval_total > 0.0 { tiers.push(QuotaTier { name: "five_hour".to_string(), - utilization: (interval_used / interval_total) * 100.0, + utilization: ((interval_total - interval_remaining) / interval_total) * 100.0, resets_at: end_time.and_then(millis_to_iso8601), }); } @@ -377,7 +377,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { .get("current_weekly_total_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let weekly_used = item + let weekly_remaining = item .get("current_weekly_usage_count") .and_then(|v| v.as_f64()) .unwrap_or(0.0); @@ -386,7 +386,7 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota { if weekly_total > 0.0 { tiers.push(QuotaTier { name: "weekly_limit".to_string(), - utilization: (weekly_used / weekly_total) * 100.0, + utilization: ((weekly_total - weekly_remaining) / weekly_total) * 100.0, resets_at: weekly_end.and_then(millis_to_iso8601), }); } From 602c5717b2eee812318a2791729641dc15ff1724 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 16:04:20 +0800 Subject: [PATCH 017/128] fix: gate parse_gemini_keychain_json with cfg(target_os = "macos") The function is only called from read_gemini_credentials_from_keychain which is already macOS-only. Without the gate, non-macOS CI fails with dead-code error due to -D warnings. --- src-tauri/src/services/subscription.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 364ee1fa7..5c2d4b4da 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -778,6 +778,7 @@ fn read_gemini_credentials_from_keychain() -> Option { /// ```json /// { "token": { "accessToken": "...", "refreshToken": "...", "expiresAt": 1234 }, "updatedAt": ... } /// ``` +#[cfg(target_os = "macos")] fn parse_gemini_keychain_json(content: &str) -> GeminiCredentials { let parsed: serde_json::Value = match serde_json::from_str(content) { Ok(v) => v, From 34f16886a20fb165e413a2cb9d8891d440e28934 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Wed, 8 Apr 2026 09:41:20 +0800 Subject: [PATCH 018/128] Normalize fragmented system prompts for strict chat backends (#1942) Some OpenAI-compatible chat providers reject requests when Claude-side\nsystem fragments arrive as multiple system messages. Normalize the\nconverted OpenAI chat payload so system content becomes a single\nleading system message while leaving the rest of the message stream\nunchanged.\n\nConstraint: Nvidia/Qwen-style chat completions require a single leading system prompt\nRejected: Reorder system messages only | still leaves fragmented system prompts for strict backends\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep OpenAI chat system prompts normalized unless a provider explicitly requires fragmented system messages\nTested: cargo test proxy::providers::transform --manifest-path src-tauri/Cargo.toml\nNot-tested: Full end-to-end proxy capture against Nvidia upstream in this session\nRelated: #1881 --- src-tauri/src/proxy/providers/transform.rs | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 2cdc93c79..2b660f334 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -113,6 +113,7 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result) -> Result) { + let system_count = messages + .iter() + .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system")) + .count(); + + if system_count == 0 { + return; + } + + if system_count == 1 { + if let Some(index) = messages.iter().position(|message| { + message.get("role").and_then(|value| value.as_str()) == Some("system") + }) { + if index > 0 { + let message = messages.remove(index); + messages.insert(0, message); + } + } + return; + } + + let mut parts = Vec::new(); + messages.retain(|message| { + if message.get("role").and_then(|value| value.as_str()) != Some("system") { + return true; + } + + match message.get("content") { + Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()), + Some(Value::Array(content_parts)) => { + let text = content_parts + .iter() + .filter_map(|part| part.get("text").and_then(|value| value.as_str())) + .collect::>() + .join("\n"); + if !text.is_empty() { + parts.push(text); + } + } + _ => {} + } + + false + }); + + if !parts.is_empty() { + messages.insert(0, json!({"role": "system", "content": parts.join("\n")})); + } +} + /// 转换单条消息到 OpenAI 格式(可能产生多条消息) fn convert_message_to_openai( role: &str, @@ -560,6 +612,31 @@ mod tests { assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); } + #[test] + fn test_anthropic_to_openai_normalizes_fragmented_system_messages() { + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": [ + {"type": "text", "text": "You are Claude Code."}, + {"type": "text", "text": "Be concise."} + ], + "messages": [ + {"role": "system", "content": "Follow repo conventions."}, + {"role": "user", "content": "Hello"} + ] + }); + + let result = anthropic_to_openai(input, None).unwrap(); + assert_eq!(result["messages"].as_array().unwrap().len(), 2); + assert_eq!(result["messages"][0]["role"], "system"); + assert_eq!( + result["messages"][0]["content"], + "You are Claude Code.\nBe concise.\nFollow repo conventions." + ); + assert_eq!(result["messages"][1]["role"], "user"); + } + #[test] fn test_anthropic_to_openai_tool_use() { let input = json!({ From dc4524e960b910b5c325397902d81ae8ec4ce233 Mon Sep 17 00:00:00 2001 From: Cod1ng Date: Wed, 8 Apr 2026 10:02:35 +0800 Subject: [PATCH 019/128] fix: handle UTF-8 multi-byte characters split across stream chunk boundaries (#1923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: handle UTF-8 multi-byte characters split across stream chunk boundaries Replace String::from_utf8_lossy with append_utf8_safe in all four SSE streaming paths. When a multi-byte UTF-8 character (e.g. Chinese, emoji) is split across TCP chunk boundaries, from_utf8_lossy silently replaces the incomplete halves with U+FFFD (�). This caused intermittent garbled output in Claude Code when using the Copilot reverse proxy, because the format conversion streams reconstruct SSE events from the corrupted buffer. The new append_utf8_safe function preserves incomplete trailing bytes in a remainder buffer and merges them with the next chunk before decoding, ensuring characters are never split during UTF-8 conversion. Fixes: intermittent U+FFFD replacement characters in Claude Code output via Copilot proxy (not reproducible with direct Copilot connections like opencode because they pass through raw bytes without format conversion). * style: fix cargo fmt formatting in UTF-8 boundary tests --------- Co-authored-by: Cod1ng Co-authored-by: encodets --- src-tauri/src/proxy/providers/streaming.rs | 45 ++- .../proxy/providers/streaming_responses.rs | 45 ++- src-tauri/src/proxy/response_handler.rs | 4 +- src-tauri/src/proxy/response_processor.rs | 4 +- src-tauri/src/proxy/sse.rs | 275 +++++++++++++++++- 5 files changed, 364 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 65a10260e..d553ba6b9 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -93,6 +93,7 @@ pub fn create_anthropic_sse_stream( ) -> impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); let mut message_id = None; let mut current_model = None; let mut next_content_index: u32 = 0; @@ -107,8 +108,7 @@ pub fn create_anthropic_sse_stream( while let Some(chunk) = stream.next().await { match chunk { Ok(bytes) => { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); while let Some(pos) = buffer.find("\n\n") { let line = buffer[..pos].to_string(); @@ -750,4 +750,45 @@ mod tests { assert!(deltas.contains(&"{\"a\":")); assert!(deltas.contains(&"1}")); } + + #[tokio::test] + async fn test_streaming_chinese_split_across_chunks_no_replacement_chars() { + // "你好" split across two TCP chunks inside a streaming text delta. + // Before the fix, from_utf8_lossy would produce U+FFFD for each half. + let full = concat!( + "data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n", + "data: {\"id\":\"chatcmpl_3\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n", + "data: [DONE]\n\n" + ); + let bytes = full.as_bytes(); + + // Find "你" in the byte stream and split inside it + let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap(); + let split_point = ni_start + 1; // split after first byte of "你" + + let chunk1 = Bytes::from(bytes[..split_point].to_vec()); + let chunk2 = Bytes::from(bytes[split_point..].to_vec()); + + let upstream = stream::iter(vec![ + Ok::<_, std::io::Error>(chunk1), + Ok::<_, std::io::Error>(chunk2), + ]); + let converted = create_anthropic_sse_stream(upstream); + let chunks: Vec<_> = converted.collect().await; + + let merged = chunks + .into_iter() + .map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string()) + .collect::(); + + // Must contain the original Chinese characters, not replacement chars + assert!( + merged.contains("你好"), + "expected '你好' in output, got replacement chars (U+FFFD)" + ); + assert!( + !merged.contains('\u{FFFD}'), + "output must not contain U+FFFD replacement characters" + ); + } } diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index ea9274ff8..2c4b306e2 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -101,6 +101,7 @@ pub fn create_anthropic_sse_stream_from_responses impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); let mut message_id: Option = None; let mut current_model: Option = None; let mut has_sent_message_start = false; @@ -118,8 +119,7 @@ pub fn create_anthropic_sse_stream_from_responses { - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); // SSE 事件由 \n\n 分隔 while let Some(pos) = buffer.find("\n\n") { @@ -1029,4 +1029,45 @@ mod tests { assert_eq!(text_stops, 1); assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]); } + + #[tokio::test] + async fn test_streaming_responses_chinese_split_across_chunks_no_replacement_chars() { + // Chinese text delta split across two TCP chunks. + let full = concat!( + "event: response.created\n", + "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cn\",\"model\":\"gpt-4o\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n", + "event: response.output_text.delta\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你好世界\"}\n\n", + "event: response.completed\n", + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":4}}}\n\n" + ); + let bytes = full.as_bytes(); + + // Find "你" and split inside it + let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap(); + let split_point = ni_start + 2; // split after second byte of "你" + + let chunk1 = Bytes::from(bytes[..split_point].to_vec()); + let chunk2 = Bytes::from(bytes[split_point..].to_vec()); + + let upstream = stream::iter(vec![ + Ok::<_, std::io::Error>(chunk1), + Ok::<_, std::io::Error>(chunk2), + ]); + let converted = create_anthropic_sse_stream_from_responses(upstream); + let chunks: Vec<_> = converted.collect().await; + let merged = chunks + .into_iter() + .map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string()) + .collect::(); + + assert!( + merged.contains("你好世界"), + "expected '你好世界' in output, got replacement chars (U+FFFD)" + ); + assert!( + !merged.contains('\u{FFFD}'), + "output must not contain U+FFFD replacement characters" + ); + } } diff --git a/src-tauri/src/proxy/response_handler.rs b/src-tauri/src/proxy/response_handler.rs index 408e66a86..502ed289f 100644 --- a/src-tauri/src/proxy/response_handler.rs +++ b/src-tauri/src/proxy/response_handler.rs @@ -71,6 +71,7 @@ impl StreamHandler { async_stream::stream! { let mut _last_activity = Instant::now(); let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); tokio::pin!(stream); @@ -82,8 +83,7 @@ impl StreamHandler { _last_activity = Instant::now(); // 解析 SSE 事件 - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); // 提取完整事件 while let Some(pos) = buffer.find("\n\n") { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 609ff393a..3f4fe8425 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -568,6 +568,7 @@ pub fn create_logged_passthrough_stream( ) -> impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); + let mut utf8_remainder: Vec = Vec::new(); let mut collector = usage_collector; let mut is_first_chunk = true; @@ -619,8 +620,7 @@ pub fn create_logged_passthrough_stream( ); } is_first_chunk = false; - let text = String::from_utf8_lossy(&bytes); - buffer.push_str(&text); + crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); // 尝试解析并记录完整的 SSE 事件 while let Some(pos) = buffer.find("\n\n") { diff --git a/src-tauri/src/proxy/sse.rs b/src-tauri/src/proxy/sse.rs index 235edce8b..f26a6a711 100644 --- a/src-tauri/src/proxy/sse.rs +++ b/src-tauri/src/proxy/sse.rs @@ -4,9 +4,71 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str> .or_else(|| line.strip_prefix(&format!("{field}:"))) } +/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte +/// characters that are split across chunk boundaries. +/// +/// `remainder` accumulates trailing bytes from the previous chunk that form an +/// incomplete UTF-8 sequence (at most 3 bytes under normal operation). On each +/// call the remainder is prepended to `new_bytes`, the longest valid UTF-8 +/// prefix is appended to `buffer`, and any trailing incomplete bytes are saved +/// back into `remainder` for the next call. +/// +/// A defensive guard discards `remainder` via lossy conversion if it ever +/// exceeds 3 bytes, which cannot happen with well-formed UTF-8 streams. +pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec, new_bytes: &[u8]) { + // Build the byte slice to decode: prepend any leftover bytes from previous chunk. + let (owned, bytes): (Option>, &[u8]) = if remainder.is_empty() { + (None, new_bytes) + } else { + // Defensive guard: remainder should never exceed 3 bytes (max incomplete + // UTF-8 sequence is 3 bytes: a 4-byte char missing its last byte). If it + // does, the stream is producing genuinely invalid bytes; flush them lossy + // and start fresh. + if remainder.len() > 3 { + buffer.push_str(&String::from_utf8_lossy(remainder)); + remainder.clear(); + (None, new_bytes) + } else { + let mut combined = std::mem::take(remainder); + combined.extend_from_slice(new_bytes); + (Some(combined), &[]) + } + }; + let input = owned.as_deref().unwrap_or(bytes); + + // Decode loop: consume all valid UTF-8 and any genuinely invalid bytes, + // only leaving a trailing incomplete sequence in remainder. + let mut pos = 0; + loop { + match std::str::from_utf8(&input[pos..]) { + Ok(s) => { + buffer.push_str(s); + // Everything consumed – remainder stays empty. + return; + } + Err(e) => { + let valid_up_to = pos + e.valid_up_to(); + buffer.push_str( + // Safety: from_utf8 guarantees [pos..valid_up_to] is valid UTF-8. + std::str::from_utf8(&input[pos..valid_up_to]).unwrap(), + ); + if let Some(invalid_len) = e.error_len() { + // Genuinely invalid byte(s) – emit U+FFFD and continue. + buffer.push('\u{FFFD}'); + pos = valid_up_to + invalid_len; + } else { + // Incomplete trailing sequence – stash for next chunk. + *remainder = input[valid_up_to..].to_vec(); + return; + } + } + } + } +} + #[cfg(test)] mod tests { - use super::strip_sse_field; + use super::{append_utf8_safe, strip_sse_field}; #[test] fn strip_sse_field_accepts_optional_space() { @@ -28,4 +90,215 @@ mod tests { ); assert_eq!(strip_sse_field("id:1", "data"), None); } + + // ------------------------------------------------------------------ + // append_utf8_safe tests + // ------------------------------------------------------------------ + + #[test] + fn ascii_passthrough() { + let mut buf = String::new(); + let mut rem = Vec::new(); + append_utf8_safe(&mut buf, &mut rem, b"hello world"); + assert_eq!(buf, "hello world"); + assert!(rem.is_empty()); + } + + #[test] + fn complete_multibyte_in_single_chunk() { + let mut buf = String::new(); + let mut rem = Vec::new(); + append_utf8_safe(&mut buf, &mut rem, "你好世界".as_bytes()); + assert_eq!(buf, "你好世界"); + assert!(rem.is_empty()); + } + + #[test] + fn split_multibyte_across_two_chunks() { + // "你" = E4 BD A0 (3 bytes) + let bytes = "你".as_bytes(); + assert_eq!(bytes.len(), 3); + + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Chunk 1: first 2 bytes (incomplete) + append_utf8_safe(&mut buf, &mut rem, &bytes[..2]); + assert_eq!(buf, ""); + assert_eq!(rem.len(), 2); + + // Chunk 2: last byte completes the character + append_utf8_safe(&mut buf, &mut rem, &bytes[2..]); + assert_eq!(buf, "你"); + assert!(rem.is_empty()); + } + + #[test] + fn split_four_byte_char_across_chunks() { + // 😀 = F0 9F 98 80 (4 bytes) + let bytes = "😀".as_bytes(); + assert_eq!(bytes.len(), 4); + + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Send 1 byte at a time + append_utf8_safe(&mut buf, &mut rem, &bytes[..1]); + assert_eq!(buf, ""); + assert_eq!(rem.len(), 1); + + append_utf8_safe(&mut buf, &mut rem, &bytes[1..2]); + assert_eq!(buf, ""); + assert_eq!(rem.len(), 2); + + append_utf8_safe(&mut buf, &mut rem, &bytes[2..3]); + assert_eq!(buf, ""); + assert_eq!(rem.len(), 3); + + append_utf8_safe(&mut buf, &mut rem, &bytes[3..]); + assert_eq!(buf, "😀"); + assert!(rem.is_empty()); + } + + #[test] + fn mixed_ascii_and_split_multibyte() { + // "hi你" = 68 69 E4 BD A0 + let all = "hi你".as_bytes(); + assert_eq!(all.len(), 5); + + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Chunk 1: "hi" + first byte of "你" + append_utf8_safe(&mut buf, &mut rem, &all[..3]); + assert_eq!(buf, "hi"); + assert_eq!(rem.len(), 1); + + // Chunk 2: remaining 2 bytes of "你" + append_utf8_safe(&mut buf, &mut rem, &all[3..]); + assert_eq!(buf, "hi你"); + assert!(rem.is_empty()); + } + + #[test] + fn multiple_split_characters_in_sequence() { + let text = "你好"; + let bytes = text.as_bytes(); // E4 BD A0 E5 A5 BD + + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Split in the middle: first char complete + 1 byte of second + append_utf8_safe(&mut buf, &mut rem, &bytes[..4]); + assert_eq!(buf, "你"); + assert_eq!(rem.len(), 1); + + // Remaining 2 bytes complete second char + append_utf8_safe(&mut buf, &mut rem, &bytes[4..]); + assert_eq!(buf, "你好"); + assert!(rem.is_empty()); + } + + #[test] + fn empty_chunks_are_harmless() { + let mut buf = String::new(); + let mut rem = Vec::new(); + + append_utf8_safe(&mut buf, &mut rem, b""); + assert_eq!(buf, ""); + assert!(rem.is_empty()); + + append_utf8_safe(&mut buf, &mut rem, b"ok"); + assert_eq!(buf, "ok"); + + append_utf8_safe(&mut buf, &mut rem, b""); + assert_eq!(buf, "ok"); + } + + #[test] + fn sse_json_with_chinese_split_at_boundary() { + // Simulates an SSE data line with Chinese content split across chunks + let json_line = "data: {\"text\":\"你好\"}\n\n"; + let bytes = json_line.as_bytes(); + + // Find where "你" starts in the byte stream and split there + let ni_start = bytes.windows(3).position(|w| w == "你".as_bytes()).unwrap(); + let split_point = ni_start + 1; // split inside "你" + + let mut buf = String::new(); + let mut rem = Vec::new(); + + append_utf8_safe(&mut buf, &mut rem, &bytes[..split_point]); + append_utf8_safe(&mut buf, &mut rem, &bytes[split_point..]); + + assert_eq!(buf, json_line); + assert!(rem.is_empty()); + + // Verify the buffer can be parsed as SSE with valid JSON + let data = strip_sse_field(buf.lines().next().unwrap(), "data").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(data).unwrap(); + assert_eq!(parsed["text"], "你好"); + } + + #[test] + fn invalid_bytes_flushed_immediately_not_accumulated() { + // 0xFF is never valid in UTF-8 – it should be replaced immediately, + // not stashed in remainder. + let mut buf = String::new(); + let mut rem = Vec::new(); + + // "hi" + invalid byte + "ok" + append_utf8_safe(&mut buf, &mut rem, b"hi\xFFok"); + assert!( + rem.is_empty(), + "remainder should be empty after invalid byte" + ); + assert!(buf.contains("hi"), "valid prefix must be present"); + assert!(buf.contains("ok"), "valid suffix must be present"); + assert!(buf.contains('\u{FFFD}'), "invalid byte must produce U+FFFD"); + } + + #[test] + fn invalid_byte_in_slow_path_flushed_immediately() { + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Prime remainder with an incomplete sequence (first byte of "你") + append_utf8_safe(&mut buf, &mut rem, &"你".as_bytes()[..1]); + assert_eq!(rem.len(), 1); + + // Next chunk starts with an invalid byte – the stale remainder and the + // invalid byte should both be flushed, not accumulated. + append_utf8_safe(&mut buf, &mut rem, b"\xFFworld"); + assert!(rem.is_empty(), "remainder should be empty"); + assert!( + buf.contains("world"), + "valid data after invalid byte must appear" + ); + } + + #[test] + fn defensive_guard_flushes_oversized_remainder() { + let mut buf = String::new(); + let mut rem = Vec::new(); + + // Manually inject 4 invalid bytes into remainder to trigger the >3 guard. + // This can't happen with well-formed UTF-8, but tests the safety net. + rem.extend_from_slice(b"\x80\x80\x80\x80"); + assert_eq!(rem.len(), 4); + + append_utf8_safe(&mut buf, &mut rem, b"hello"); + // The 4 invalid bytes should have been flushed lossy, then "hello" decoded. + assert!(rem.is_empty(), "remainder must be empty after guard flush"); + assert!( + buf.contains("hello"), + "valid data after guard flush must appear" + ); + // The 4 invalid bytes each produce a U+FFFD + let replacement_count = buf.chars().filter(|&c| c == '\u{FFFD}').count(); + assert_eq!( + replacement_count, 4, + "each invalid byte should produce one U+FFFD" + ); + } } From 24555275bbf78e5fdae50113edabaeea3eb9fba3 Mon Sep 17 00:00:00 2001 From: Satoru Date: Wed, 8 Apr 2026 21:17:19 +0800 Subject: [PATCH 020/128] fix: add padding to toolbar container to prevent add button shadow clipping (#1951) The orange add button's circular shadow appeared square because the parent overflow-x-hidden container was clipping it at the edges. --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 0665c1889..750c5ba2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1035,7 +1035,7 @@ function App() { )}
Date: Sun, 5 Apr 2026 16:54:51 +0800 Subject: [PATCH 021/128] feat: add official balance query for DeepSeek, StepFun, SiliconFlow, OpenRouter, Novita AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Official" (官方) template type in the usage query panel that queries account balance via each provider's native API endpoint. Follows the same zero-script pattern as Token Plan — Rust handles the HTTP call, frontend auto-detects the provider from base URL. Supported providers and endpoints: - DeepSeek: GET /user/balance - StepFun: GET /v1/accounts - SiliconFlow: GET /v1/user/info (cn + com) - OpenRouter: GET /api/v1/credits - Novita AI: GET /v3/user/balance --- src-tauri/src/commands/balance.rs | 6 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/provider.rs | 25 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/balance.rs | 411 ++++++++++++++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/components/UsageScriptModal.tsx | 105 ++++++- src/config/constants.ts | 1 + src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/api/subscription.ts | 5 + 12 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/balance.rs create mode 100644 src-tauri/src/services/balance.rs diff --git a/src-tauri/src/commands/balance.rs b/src-tauri/src/commands/balance.rs new file mode 100644 index 000000000..ea7a3e109 --- /dev/null +++ b/src-tauri/src/commands/balance.rs @@ -0,0 +1,6 @@ +use crate::provider::UsageResult; + +#[tauri::command] +pub async fn get_balance(base_url: String, api_key: String) -> Result { + crate::services::balance::get_balance(&base_url, &api_key).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4261b3da2..d0be4717b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod auth; +mod balance; mod coding_plan; mod config; mod copilot; @@ -31,6 +32,7 @@ mod webdav_sync; mod workspace; pub use auth::*; +pub use balance::*; pub use coding_plan::*; pub use config::*; pub use copilot::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index d505efb92..565cc8bac 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -14,6 +14,7 @@ use std::str::FromStr; // 常量定义 const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan"; +const TEMPLATE_TYPE_BALANCE: &str = "balance"; const COPILOT_UNIT_PREMIUM: &str = "requests"; /// 获取所有供应商 @@ -268,6 +269,30 @@ pub async fn queryProviderUsage( }); } + // ── 官方余额查询路径 ── + if template_type == TEMPLATE_TYPE_BALANCE { + let settings_config = provider + .map(|p| &p.settings_config) + .cloned() + .unwrap_or_default(); + let env = settings_config.get("env"); + let base_url = env + .and_then(|e| e.get("ANTHROPIC_BASE_URL")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let api_key = env + .and_then(|e| { + e.get("ANTHROPIC_AUTH_TOKEN") + .or_else(|| e.get("ANTHROPIC_API_KEY")) + }) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + return crate::services::balance::get_balance(base_url, api_key) + .await + .map_err(|e| format!("Failed to query balance: {e}")); + } + // ── 通用 JS 脚本路径 ── ProviderService::query_usage(state.inner(), app_type, &providerId) .await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 277713bc7..3b33f6f9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -893,6 +893,7 @@ pub fn run() { // subscription quota commands::get_subscription_quota, commands::get_coding_plan_quota, + commands::get_balance, // New MCP via config.json (SSOT) commands::get_mcp_config, commands::upsert_mcp_server_in_config, diff --git a/src-tauri/src/services/balance.rs b/src-tauri/src/services/balance.rs new file mode 100644 index 000000000..fa6b5d5a1 --- /dev/null +++ b/src-tauri/src/services/balance.rs @@ -0,0 +1,411 @@ +//! 供应商余额查询服务 +//! +//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。 +//! 返回 UsageResult 格式,与现有用量系统无缝对接。 + +use crate::provider::{UsageData, UsageResult}; +use std::time::Duration; + +// ── 供应商检测 ────────────────────────────────────────────── + +enum BalanceProvider { + DeepSeek, + StepFun, + SiliconFlow, + SiliconFlowEn, + OpenRouter, + NovitaAI, +} + +fn detect_provider(base_url: &str) -> Option { + let url = base_url.to_lowercase(); + if url.contains("api.deepseek.com") { + Some(BalanceProvider::DeepSeek) + } else if url.contains("api.stepfun.ai") || url.contains("api.stepfun.com") { + Some(BalanceProvider::StepFun) + } else if url.contains("api.siliconflow.cn") { + Some(BalanceProvider::SiliconFlow) + } else if url.contains("api.siliconflow.com") { + Some(BalanceProvider::SiliconFlowEn) + } else if url.contains("openrouter.ai") { + Some(BalanceProvider::OpenRouter) + } else if url.contains("api.novita.ai") { + Some(BalanceProvider::NovitaAI) + } else { + None + } +} + +fn make_error(msg: String) -> UsageResult { + UsageResult { + success: false, + data: None, + error: Some(msg), + } +} + +fn make_auth_error(status: reqwest::StatusCode) -> UsageResult { + UsageResult { + success: false, + data: Some(vec![UsageData { + plan_name: None, + remaining: None, + total: None, + used: None, + unit: None, + is_valid: Some(false), + invalid_message: Some(format!("Authentication failed (HTTP {status})")), + extra: None, + }]), + error: Some(format!("Authentication failed (HTTP {status})")), + } +} + +// ── DeepSeek ──────────────────────────────────────────────── +// GET https://api.deepseek.com/user/balance +// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available } + +async fn query_deepseek(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.deepseek.com/user/balance") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let is_available = body + .get("is_available") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let mut data = Vec::new(); + + if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) { + for info in infos { + let currency = info + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("CNY"); + let total = parse_f64_field(info, "total_balance"); + + data.push(UsageData { + plan_name: Some(currency.to_string()), + remaining: total, + total: None, + used: None, + unit: Some(currency.to_string()), + is_valid: Some(is_available), + invalid_message: if !is_available { + Some("Insufficient balance".to_string()) + } else { + None + }, + extra: None, + }); + } + } + + UsageResult { + success: true, + data: if data.is_empty() { None } else { Some(data) }, + error: None, + } +} + +// ── StepFun ───────────────────────────────────────────────── +// GET https://api.stepfun.com/v1/accounts +// Response: { object, type, balance, total_cash_balance, total_voucher_balance } + +async fn query_stepfun(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.stepfun.com/v1/accounts") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let balance = parse_f64_field(&body, "balance").unwrap_or(0.0); + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("StepFun".to_string()), + remaining: Some(balance), + total: None, + used: None, + unit: Some("CNY".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: None, + }]), + error: None, + } +} + +// ── SiliconFlow ───────────────────────────────────────────── +// GET https://api.siliconflow.cn/v1/user/info (or .com for EN) +// Response: { code, data: { balance, chargeBalance, totalBalance, status } } + +async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let domain = if is_cn { + "api.siliconflow.cn" + } else { + "api.siliconflow.com" + }; + let url = format!("https://{domain}/v1/user/info"); + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let data = match body.get("data") { + Some(d) => d, + None => return make_error("Missing 'data' field in response".to_string()), + }; + + let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0); + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("SiliconFlow".to_string()), + remaining: Some(total_balance), + total: None, + used: None, + unit: Some("CNY".to_string()), + is_valid: Some(true), + invalid_message: None, + extra: None, + }]), + error: None, + } +} + +// ── OpenRouter ────────────────────────────────────────────── +// GET https://openrouter.ai/api/v1/credits +// Response: { data: { total_credits, total_usage } } + +async fn query_openrouter(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://openrouter.ai/api/v1/credits") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + let data = body.get("data").unwrap_or(&body); + let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0); + let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0); + let remaining = total_credits - total_usage; + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("OpenRouter".to_string()), + remaining: Some(remaining), + total: Some(total_credits), + used: Some(total_usage), + unit: Some("USD".to_string()), + is_valid: Some(remaining > 0.0), + invalid_message: if remaining <= 0.0 { + Some("No credits remaining".to_string()) + } else { + None + }, + extra: None, + }]), + error: None, + } +} + +// ── Novita AI ─────────────────────────────────────────────── +// GET https://api.novita.ai/v3/user/balance +// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices } +// 金额单位:0.0001 USD + +async fn query_novita(api_key: &str) -> UsageResult { + let client = crate::proxy::http_client::get(); + + let resp = client + .get("https://api.novita.ai/v3/user/balance") + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .timeout(Duration::from_secs(10)) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => return make_error(format!("Network error: {e}")), + }; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return make_auth_error(status); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return make_error(format!("API error (HTTP {status}): {body}")); + } + + let body: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => return make_error(format!("Failed to parse response: {e}")), + }; + + // Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD + let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0; + + UsageResult { + success: true, + data: Some(vec![UsageData { + plan_name: Some("Novita AI".to_string()), + remaining: Some(available), + total: None, + used: None, + unit: Some("USD".to_string()), + is_valid: Some(available > 0.0), + invalid_message: if available <= 0.0 { + Some("No balance remaining".to_string()) + } else { + None + }, + extra: None, + }]), + error: None, + } +} + +// ── 工具函数 ──────────────────────────────────────────────── + +/// 解析 JSON 字段为 f64,兼容数字和字符串格式 +fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option { + obj.get(field).and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) +} + +// ── 公开入口 ──────────────────────────────────────────────── + +pub async fn get_balance(base_url: &str, api_key: &str) -> Result { + if api_key.trim().is_empty() { + return Ok(UsageResult { + success: false, + data: None, + error: Some("API key is empty".to_string()), + }); + } + + let provider = match detect_provider(base_url) { + Some(p) => p, + None => { + return Ok(UsageResult { + success: false, + data: None, + error: Some("Unknown balance provider".to_string()), + }) + } + }; + + let result = match provider { + BalanceProvider::DeepSeek => query_deepseek(api_key).await, + BalanceProvider::StepFun => query_stepfun(api_key).await, + BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await, + BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await, + BalanceProvider::OpenRouter => query_openrouter(api_key).await, + BalanceProvider::NovitaAI => query_novita(api_key).await, + }; + + Ok(result) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ce5cc4dd0..64c1f641b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod balance; pub mod coding_plan; pub mod config; pub mod env_checker; diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 131deb267..19529f933 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -98,6 +98,9 @@ const generatePresetTemplates = ( // Coding Plan 模板不需要脚本,使用专用 Rust 查询 [TEMPLATE_TYPES.TOKEN_PLAN]: "", + + // 官方余额查询模板不需要脚本,使用专用 Rust 查询 + [TEMPLATE_TYPES.BALANCE]: "", }); // 模板名称国际化键映射 @@ -107,6 +110,7 @@ const TEMPLATE_NAME_KEYS: Record = { [TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI", [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", [TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan", + [TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance", }; /** Coding Plan 供应商选项 */ @@ -124,6 +128,25 @@ const TOKEN_PLAN_PROVIDERS = [ }, ] as const; +/** 官方余额查询供应商检测 */ +const BALANCE_PROVIDERS = [ + { id: "deepseek", label: "DeepSeek", pattern: /api\.deepseek\.com/i }, + { id: "stepfun", label: "StepFun", pattern: /api\.stepfun\.(ai|com)/i }, + { + id: "siliconflow", + label: "SiliconFlow", + pattern: /api\.siliconflow\.(cn|com)/i, + }, + { id: "openrouter", label: "OpenRouter", pattern: /openrouter\.ai/i }, + { id: "novita", label: "Novita AI", pattern: /api\.novita\.ai/i }, +] as const; + +/** 根据 Base URL 自动检测余额查询供应商 */ +function detectBalanceProvider(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl)); +} + /** 根据 Base URL 自动检测 Coding Plan 供应商 */ function detectTokenPlanProvider(baseUrl: string | undefined): string | null { if (!baseUrl) return null; @@ -219,6 +242,16 @@ const UsageScriptModal: React.FC = ({ }; } + // 新配置:如果 URL 匹配官方余额查询供应商,自动初始化 + if (detectBalanceProvider(providerCredentials.baseUrl)) { + return { + enabled: false, + language: "javascript" as const, + code: "", + timeout: 10, + }; + } + return { enabled: false, language: "javascript" as const, @@ -300,6 +333,10 @@ const UsageScriptModal: React.FC = ({ if (detectTokenPlanProvider(providerCredentials.baseUrl)) { return TEMPLATE_TYPES.TOKEN_PLAN; } + // 新配置:如果 URL 匹配官方余额查询供应商,自动选择 Balance 模板 + if (detectBalanceProvider(providerCredentials.baseUrl)) { + return TEMPLATE_TYPES.BALANCE; + } // 默认使用 GENERAL(与默认代码模板一致) return TEMPLATE_TYPES.GENERAL; }, @@ -331,10 +368,11 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - // Copilot 和 Coding Plan 模板不需要脚本验证 + // Copilot、Coding Plan、Balance 模板不需要脚本验证 if ( selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && - selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN + selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && + selectedTemplate !== TEMPLATE_TYPES.BALANCE ) { if (script.enabled && !script.code.trim()) { toast.error(t("usageScript.scriptEmpty")); @@ -354,6 +392,7 @@ const UsageScriptModal: React.FC = ({ | "newapi" | "github_copilot" | "token_plan" + | "balance" | undefined, }; onSave(scriptWithTemplate); @@ -363,6 +402,37 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // 官方余额查询模板使用专用 API + if (selectedTemplate === TEMPLATE_TYPES.BALANCE) { + const config = provider.settingsConfig as Record; + const baseUrl: string = config?.env?.ANTHROPIC_BASE_URL ?? ""; + const apiKey: string = + config?.env?.ANTHROPIC_AUTH_TOKEN ?? + config?.env?.ANTHROPIC_API_KEY ?? + ""; + const { subscriptionApi } = await import("@/lib/api/subscription"); + const result = await subscriptionApi.getBalance(baseUrl, apiKey); + if (result.success && result.data && result.data.length > 0) { + const summary = result.data + .map((d) => { + const name = d.planName ? `[${d.planName}] ` : ""; + return `${name}${t("usage.remaining")} ${d.remaining?.toFixed(2)} ${d.unit || ""}`; + }) + .join(", "); + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + queryClient.setQueryData(["usage", provider.id, appId], result); + } else { + toast.error( + `${t("usageScript.testFailed")}: ${result.error || t("endpointTest.noResult")}`, + { duration: 5000 }, + ); + } + return; + } + // Coding Plan 模板使用专用 API if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) { const config = provider.settingsConfig as Record; @@ -558,6 +628,16 @@ const UsageScriptModal: React.FC = ({ codingPlanProvider: script.codingPlanProvider || autoDetected || "kimi", }); + } else if (presetName === TEMPLATE_TYPES.BALANCE) { + // 官方余额查询模板不需要脚本,使用 Rust 原生查询 + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + }); } setSelectedTemplate(presetName); } @@ -746,6 +826,27 @@ const UsageScriptModal: React.FC = ({
)} + {/* 官方余额查询模式:自动提示 */} + {selectedTemplate === TEMPLATE_TYPES.BALANCE && ( +
+

+ {t("usageScript.balanceHint")} +

+
+ {BALANCE_PROVIDERS.filter((bp) => + bp.pattern.test(providerCredentials.baseUrl || ""), + ).map((bp) => ( + + {bp.label} + + ))} +
+
+ )} + {/* Coding Plan 模式:供应商选择 */} {selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
diff --git a/src/config/constants.ts b/src/config/constants.ts index de9db8a68..158d983e7 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -10,6 +10,7 @@ export const TEMPLATE_TYPES = { NEW_API: "newapi", GITHUB_COPILOT: "github_copilot", TOKEN_PLAN: "token_plan", + BALANCE: "balance", } as const; export type TemplateType = (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 09f421fce..4f148e3e0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "Official", "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", "tokenPlanHint": "Automatically uses the provider's API Key and Base URL to query Token Plan quota", + "balanceHint": "Automatically uses the provider's API Key to query account balance", "resetDate": "Reset date", "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 145355ef3..0e3bb23c0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "公式", "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", "tokenPlanHint": "プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ", + "balanceHint": "プロバイダーのAPI Keyを使用してアカウント残高を自動クエリ", "resetDate": "リセット日", "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 75fc9232a..b70027bd9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1091,8 +1091,10 @@ "templateNewAPI": "NewAPI", "templateCopilot": "GitHub Copilot", "templateTokenPlan": "Token Plan", + "templateBalance": "官方", "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", "tokenPlanHint": "自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度", + "balanceHint": "自动使用供应商的 API Key 查询账户余额", "resetDate": "重置日期", "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 9c572f0ec..79f29eb19 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -9,4 +9,9 @@ export const subscriptionApi = { apiKey: string, ): Promise => invoke("get_coding_plan_quota", { baseUrl, apiKey }), + getBalance: ( + baseUrl: string, + apiKey: string, + ): Promise => + invoke("get_balance", { baseUrl, apiKey }), }; From 9192b6f988ab19e302a3228bded4922f36dbd6fe Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 17:26:45 +0800 Subject: [PATCH 022/128] fix: align usage display across provider cards by always rendering action buttons Test and ConfigureUsage buttons are now always rendered instead of conditionally, with a disabled style for providers that don't support them (e.g. official subscriptions). This ensures consistent button container width so the usage display aligns uniformly across all cards. --- src/components/providers/ProviderActions.tsx | 57 ++++++++++---------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index d776ae21a..98db9307d 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -246,34 +246,37 @@ export function ProviderActions({ - {onTest && ( - - )} + - {onConfigureUsage && ( - - )} + {onOpenTerminal && ( +
{isLoading ? ( @@ -290,8 +364,14 @@ const UnifiedSkillsPanel = React.forwardRef< handleUninstall(skill)} + onUpdate={() => handleUpdateSkill(skill)} isLast={index === skills.length - 1} /> ))} @@ -339,15 +419,21 @@ UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel"; interface InstalledSkillListItemProps { skill: InstalledSkill; + hasUpdate?: boolean; + isUpdating?: boolean; onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; + onUpdate?: () => void; isLast?: boolean; } const InstalledSkillListItem: React.FC = ({ skill, + hasUpdate, + isUpdating, onToggleApp, onUninstall, + onUpdate, isLast, }) => { const { t } = useTranslation(); @@ -387,6 +473,14 @@ const InstalledSkillListItem: React.FC = ({ {sourceLabel} + {hasUpdate && ( + + {t("skills.updateAvailable")} + + )}
{skill.description && (

= ({ appIds={MCP_SKILLS_APP_IDS} /> -

+
+ {hasUpdate && onUpdate && ( + + )} + {skillUpdates && skillUpdates.length > 0 && ( + + )}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 16102e205..663634e50 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "Checking...", "noUpdates": "All skills are up to date", "updatesFound": "{{count}} skill(s) have updates available", + "updateAll": "Update All ({{count}})", + "updatingAll": "Updating...", + "updateAllSuccess": "Successfully updated {{count}} skill(s)", "error": { "skillNotFound": "Skill not found: {{directory}}", "missingRepoInfo": "Missing repository info (owner or name)", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0010b3483..0a432b889 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "確認中...", "noUpdates": "すべてのスキルは最新です", "updatesFound": "{{count}} 個のスキルに更新があります", + "updateAll": "すべて更新 ({{count}})", + "updatingAll": "更新中...", + "updateAllSuccess": "{{count}} 個のスキルを更新しました", "error": { "skillNotFound": "スキルが見つかりません: {{directory}}", "missingRepoInfo": "リポジトリ情報(owner または name)が不足しています", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 04658c586..dd2715b3b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1582,6 +1582,9 @@ "checkingUpdates": "检查中...", "noUpdates": "所有技能已是最新版本", "updatesFound": "发现 {{count}} 个技能有可用更新", + "updateAll": "全部更新 ({{count}})", + "updatingAll": "更新中...", + "updateAllSuccess": "已成功更新 {{count}} 个技能", "error": { "skillNotFound": "技能不存在:{{directory}}", "missingRepoInfo": "缺少仓库信息(owner 或 name)", From 6d220b2528f0960f3be8ca466d490b22514e8f68 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 19:26:34 +0800 Subject: [PATCH 026/128] fix: animate "Update All" button sliding in from the left of "Check Updates" Use max-width + opacity CSS transition so the button smoothly expands into view instead of popping in abruptly. --- src/components/skills/UnifiedSkillsPanel.tsx | 61 +++++++++++--------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 4e11f2888..701af4929 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -344,42 +344,51 @@ const UnifiedSkillsPanel = React.forwardRef< counts={enabledCounts} appIds={MCP_SKILLS_APP_IDS} /> - - {skillUpdates && skillUpdates.length > 0 && ( +
+
0 ? "200px" : "0px", + opacity: skillUpdates && skillUpdates.length > 0 ? 1 : 0, + }} + > + +
- )} +
From 8cfce8abfc623f2c7417d8a812e156bd4c00f9ac Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 20:21:51 +0800 Subject: [PATCH 027/128] feat: add skill storage location toggle between CC Switch and ~/.agents/skills Allow users to choose between storing skills in CC Switch's managed directory (~/.cc-switch/skills/) or the Agent Skills open standard directory (~/.agents/skills/). Includes migration logic that safely moves files before updating settings, with confirmation dialog for non-empty installations. --- src-tauri/src/commands/skill.rs | 13 +- src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 116 +++++++++++- src-tauri/src/settings.rs | 26 ++- src/components/settings/SettingsPage.tsx | 11 ++ .../settings/SkillStorageLocationSettings.tsx | 165 ++++++++++++++++++ src/i18n/locales/en.json | 12 ++ src/i18n/locales/ja.json | 12 ++ src/i18n/locales/zh.json | 12 ++ src/lib/api/skills.ts | 14 ++ src/lib/schemas/settings.ts | 1 + src/types.ts | 5 + 12 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 src/components/settings/SkillStorageLocationSettings.tsx diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index a55b5e6f7..8c63ec589 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -7,8 +7,8 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; use crate::services::skill::{ - DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService, - SkillUninstallResult, SkillUpdateInfo, + DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, + SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, }; use crate::store::AppState; use std::sync::Arc; @@ -161,6 +161,15 @@ pub async fn update_skill( .map_err(|e| e.to_string()) } +/// 迁移 Skill 存储位置 +#[tauri::command] +pub async fn migrate_skill_storage( + target: SkillStorageLocation, + app_state: State<'_, AppState>, +) -> Result { + SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dca642610..e60c3ae23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -965,6 +965,7 @@ pub fn run() { commands::discover_available_skills, commands::check_skill_updates, commands::update_skill, + commands::migrate_skill_storage, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f26803d34..adadf54ad 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -34,6 +34,17 @@ pub enum SyncMethod { Copy, } +/// Skill 存储位置(SSOT 目录选择) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SkillStorageLocation { + /// CC Switch 管理目录 (~/.cc-switch/skills/) + #[default] + CcSwitch, + /// Agent Skills 统一标准目录 (~/.agents/skills/) + Unified, +} + /// 可发现的技能(来自仓库) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverableSkill { @@ -174,6 +185,15 @@ pub struct SkillUpdateInfo { pub remote_hash: String, } +/// Skill 存储位置迁移结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationResult { + pub migrated_count: usize, + pub skipped_count: usize, + pub errors: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -403,9 +423,20 @@ impl SkillService { // ========== 路径管理 ========== - /// 获取 SSOT 目录(~/.cc-switch/skills/) + /// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/) pub fn get_ssot_dir() -> Result { - let dir = get_app_config_dir().join("skills"); + let location = crate::settings::get_skill_storage_location(); + let dir = match location { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context(format_skill_error( + "GET_HOME_DIR_FAILED", + &[], + Some("checkPermission"), + ))?; + home.join(".agents").join("skills") + } + }; fs::create_dir_all(&dir)?; Ok(dir) } @@ -1052,6 +1083,87 @@ impl SkillService { Ok(count) } + /// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件) + /// + /// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。 + pub fn migrate_storage( + db: &Arc, + target: SkillStorageLocation, + ) -> Result { + let current = crate::settings::get_skill_storage_location(); + if current == target { + return Ok(MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }); + } + + // 1. 解析旧目录和新目录(不改设置) + let old_dir = Self::get_ssot_dir()?; + let new_dir = match target { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context("Cannot determine home directory")?; + home.join(".agents").join("skills") + } + }; + fs::create_dir_all(&new_dir)?; + + // 2. 逐个移动 skill 目录 + let skills = db.get_all_installed_skills()?; + let mut result = MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }; + + for skill in skills.values() { + let src = old_dir.join(&skill.directory); + let dst = new_dir.join(&skill.directory); + + if !src.exists() { + result.skipped_count += 1; + continue; + } + if dst.exists() { + result.skipped_count += 1; + continue; + } + + // 优先 rename(同文件系统原子操作),失败则 copy+delete + match fs::rename(&src, &dst) { + Ok(()) => result.migrated_count += 1, + Err(_) => match Self::copy_dir_recursive(&src, &dst) { + Ok(()) => { + let _ = fs::remove_dir_all(&src); + result.migrated_count += 1; + } + Err(e) => { + result.errors.push(format!("{}: {e}", skill.directory)); + } + }, + } + } + + // 3. 文件移动完成后才持久化设置 + crate::settings::set_skill_storage_location(target)?; + + // 4. 刷新所有应用目录的 symlink(指向新 SSOT) + for app in AppType::all() { + let _ = Self::sync_to_app(db, &app); + } + + log::info!( + "Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误", + result.migrated_count, + result.skipped_count, + result.errors.len() + ); + + Ok(result) + } + pub fn list_backups() -> Result> { let backup_dir = Self::get_backup_dir()?; let mut entries = Vec::new(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b44929dd1..1d1dd836e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,7 @@ use std::sync::{OnceLock, RwLock}; use crate::app_config::AppType; use crate::error::AppError; -use crate::services::skill::SyncMethod; +use crate::services::skill::{SkillStorageLocation, SyncMethod}; /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -245,6 +245,9 @@ pub struct AppSettings { /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy #[serde(default)] pub skill_sync_method: SyncMethod, + /// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + #[serde(default)] + pub skill_storage_location: SkillStorageLocation, // ===== WebDAV 同步设置 ===== #[serde(default, skip_serializing_if = "Option::is_none")] @@ -307,6 +310,7 @@ impl Default for AppSettings { current_provider_opencode: None, current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), + skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, webdav_backup: None, backup_interval_hours: None, @@ -642,6 +646,26 @@ pub fn get_skill_sync_method() -> SyncMethod { .skill_sync_method } +// ===== Skill 存储位置管理函数 ===== + +/// 获取 Skill 存储位置配置 +pub fn get_skill_storage_location() -> SkillStorageLocation { + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .skill_storage_location +} + +/// 设置 Skill 存储位置 +pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> { + mutate_settings(|s| { + s.skill_storage_location = location; + }) +} + // ===== 备份策略管理函数 ===== /// Get the effective auto-backup interval in hours (default 24) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index cb5361598..b01cba7dd 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -32,6 +32,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings"; import { ThemeSettings } from "@/components/settings/ThemeSettings"; import { WindowSettings } from "@/components/settings/WindowSettings"; import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings"; +import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings"; import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings"; import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; @@ -44,6 +45,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; +import { useInstalledSkills } from "@/hooks/useSkills"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -96,6 +98,8 @@ export function SettingsPage({ resetStatus, } = useImportExport({ onImportSuccess }); + const { data: installedSkills } = useInstalledSkills(); + const [activeTab, setActiveTab] = useState("general"); const [showRestartPrompt, setShowRestartPrompt] = useState(false); @@ -229,6 +233,13 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> + + updateSettings({ skillStorageLocation: location }) + } + /> diff --git a/src/components/settings/SkillStorageLocationSettings.tsx b/src/components/settings/SkillStorageLocationSettings.tsx new file mode 100644 index 000000000..1aae08f87 --- /dev/null +++ b/src/components/settings/SkillStorageLocationSettings.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { skillsApi, type MigrationResult } from "@/lib/api/skills"; +import type { SkillStorageLocation } from "@/types"; + +export interface SkillStorageLocationSettingsProps { + value: SkillStorageLocation; + installedCount: number; + onMigrated: (target: SkillStorageLocation) => void; +} + +export function SkillStorageLocationSettings({ + value, + installedCount, + onMigrated, +}: SkillStorageLocationSettingsProps) { + const { t } = useTranslation(); + const [pendingTarget, setPendingTarget] = + useState(null); + const [isMigrating, setIsMigrating] = useState(false); + + const handleSelect = (target: SkillStorageLocation) => { + if (target === value) return; + if (installedCount > 0) { + setPendingTarget(target); + } else { + doMigrate(target); + } + }; + + const doMigrate = async (target: SkillStorageLocation) => { + setIsMigrating(true); + setPendingTarget(null); + try { + const result: MigrationResult = await skillsApi.migrateStorage(target); + if (result.errors.length > 0) { + toast.warning( + t("settings.skillStorage.migrationPartial", { + migrated: result.migratedCount, + errors: result.errors.length, + }), + ); + } else { + toast.success( + t("settings.skillStorage.migrationSuccess", { + count: result.migratedCount, + }), + ); + } + onMigrated(target); + } catch (error) { + toast.error(String(error)); + } finally { + setIsMigrating(false); + } + }; + + return ( +
+
+

+ {t("settings.skillStorage.title")} +

+

+ {t("settings.skillStorage.description")} +

+
+
+ handleSelect("cc_switch")} + > + {t("settings.skillStorage.ccSwitch")} + + handleSelect("unified")} + > + {isMigrating && value !== "unified" ? ( + + ) : null} + {t("settings.skillStorage.unified")} + +
+

+ {value === "unified" + ? t("settings.skillStorage.unifiedHint") + : t("settings.skillStorage.ccSwitchHint")} +

+ + {/* 迁移确认对话框 */} + { + if (!open) setPendingTarget(null); + }} + > + + + {t("settings.skillStorage.confirmTitle")} + + {t("settings.skillStorage.confirmMessage", { + count: installedCount, + })} + + + + + + + + +
+ ); +} + +interface StorageButtonProps { + active: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function StorageButton({ + active, + disabled, + onClick, + children, +}: StorageButtonProps) { + return ( + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 663634e50..9b8440e7c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "Skill Storage Location", + "description": "Choose where CC Switch stores the master copies of your skills", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.", + "unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.", + "confirmTitle": "Migrate Skill Storage", + "confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?", + "migrationSuccess": "Successfully migrated {{count}} skill(s)", + "migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details." + }, "skillSync": { "title": "Skill Sync Method", "description": "Choose how to sync Skills files", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a432b889..f2ccfac41 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "スキル保存場所", + "description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。", + "unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。", + "confirmTitle": "スキル保存場所の移行", + "confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?", + "migrationSuccess": "{{count}} 個のスキルを移行しました", + "migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。" + }, "skillSync": { "title": "スキル同期方式", "description": "スキルファイルの同期方法を選択", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index dd2715b3b..6427611ea 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "技能存储位置", + "description": "选择 CC Switch 存放技能主副本的目录", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", + "unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。", + "confirmTitle": "迁移技能存储", + "confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?", + "migrationSuccess": "已成功迁移 {{count}} 个技能", + "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" + }, "skillSync": { "title": "Skill 同步方式", "description": "选择 Skills 的文件同步策略", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 52463975d..3e92e88b0 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -88,6 +88,13 @@ export interface SkillUpdateInfo { remoteHash: string; } +/** 存储位置迁移结果 */ +export interface MigrationResult { + migratedCount: number; + skippedCount: number; + errors: string[]; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -169,6 +176,13 @@ export const skillsApi = { return await invoke("update_skill", { id }); }, + /** 迁移 Skill 存储位置 */ + async migrateStorage( + target: "cc_switch" | "unified", + ): Promise { + return await invoke("migrate_skill_storage", { target }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index c73781ce8..752251369 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -29,6 +29,7 @@ export const settingsSchema = z.object({ // Skill 同步设置 skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(), + skillStorageLocation: z.enum(["cc_switch", "unified"]).optional(), // WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取) webdavSync: z diff --git a/src/types.ts b/src/types.ts index 868a05cc6..f3abcb0f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,6 +177,9 @@ export interface ProviderMeta { // Skill 同步方式 export type SkillSyncMethod = "auto" | "symlink" | "copy"; +// Skill 存储位置 +export type SkillStorageLocation = "cc_switch" | "unified"; + // Claude API 格式类型 // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 @@ -292,6 +295,8 @@ export interface Settings { // ===== Skill 同步设置 ===== // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + skillStorageLocation?: SkillStorageLocation; // ===== WebDAV v2 同步设置 ===== webdavSync?: WebDavSyncSettings; From 33f5d56afdcd08de05e2ce815e708a24b4554691 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 20:26:04 +0800 Subject: [PATCH 028/128] fix: move skill settings above window settings in general tab --- src/components/settings/SettingsPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index b01cba7dd..5acd350dd 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -229,10 +229,6 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> - + From d51e774b20e54e209be8ef766d61d4fc6a367692 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 22:16:10 +0800 Subject: [PATCH 029/128] feat: integrate skills.sh search for discovering skills from public registry Add skills.sh API integration allowing users to search and install from a catalog of 91K+ agent skills directly within CC Switch. The search results are converted to DiscoverableSkill objects and reuse the existing install pipeline. Includes fallback directory search for repos where skills are nested in subdirectories, and filters out non-GitHub sources. --- src-tauri/src/commands/skill.rs | 13 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 176 +++++++++++++- src/components/skills/SkillCard.tsx | 17 +- src/components/skills/SkillsPage.tsx | 328 +++++++++++++++++++++++---- src/hooks/useDebouncedValue.ts | 16 ++ src/hooks/useSkills.ts | 22 ++ src/i18n/locales/en.json | 13 ++ src/i18n/locales/ja.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/api/skills.ts | 28 +++ 11 files changed, 583 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useDebouncedValue.ts diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 8c63ec589..5bec2b17b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -9,6 +9,7 @@ use crate::error::format_skill_error; use crate::services::skill::{ DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, + SkillsShSearchResult, }; use crate::store::AppState; use std::sync::Arc; @@ -170,6 +171,18 @@ pub async fn migrate_skill_storage( SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) } +/// 搜索 skills.sh 公共目录 +#[tauri::command] +pub async fn search_skills_sh( + query: String, + limit: usize, + offset: usize, +) -> Result { + SkillService::search_skills_sh(&query, limit, offset) + .await + .map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e60c3ae23..d7490e924 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -966,6 +966,7 @@ pub fn run() { commands::check_skill_updates, commands::update_skill, commands::migrate_skill_storage, + commands::search_skills_sh, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index adadf54ad..2ce39ea02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -194,6 +194,58 @@ pub struct MigrationResult { pub errors: Vec, } +// ========== skills.sh API 类型 ========== + +/// skills.sh API 原始响应 +/// +/// 注意:API 命名不一致(searchType 是 camelCase,duration_ms 是 snake_case), +/// 因此不能用 rename_all,需要逐字段指定。 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiResponse { + pub query: String, + #[serde(rename = "searchType")] + #[allow(dead_code)] + pub search_type: String, + pub skills: Vec, + pub count: usize, + #[allow(dead_code)] + pub duration_ms: u64, +} + +/// skills.sh API 原始技能条目 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiSkill { + pub id: String, + #[serde(rename = "skillId")] + pub skill_id: String, + pub name: String, + pub installs: u64, + pub source: String, +} + +/// skills.sh 搜索结果(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShSearchResult { + pub skills: Vec, + pub total_count: usize, + pub query: String, +} + +/// skills.sh 可安装技能(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShDiscoverableSkill { + pub key: String, + pub name: String, + pub directory: String, + pub repo_owner: String, + pub repo_name: String, + pub repo_branch: String, + pub installs: u64, + pub readme_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -614,14 +666,28 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let source = temp_dir.join(&source_rel); + let mut source = temp_dir.join(&source_rel); if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); + // 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md) + let target_name = source_rel + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) { + log::info!( + "Skill directory '{}' not found at direct path, using fallback: {}", + target_name, + found.display() + ); + source = found; + } else { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); + } } let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); @@ -1982,6 +2048,38 @@ impl SkillService { } } + /// 在目录树中查找名称匹配且包含 SKILL.md 的子目录 + /// + /// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"), + /// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。 + fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option { + fn walk(dir: &Path, target: &str, depth: usize) -> Option { + if depth > 3 { + return None; + } + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() { + return Some(path); + } + if let Some(found) = walk(&path, target, depth + 1) { + return Some(found); + } + } + None + } + walk(root, target_name, 0) + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -2589,6 +2687,70 @@ impl SkillService { Ok(()) } + + // ========== skills.sh 搜索 ========== + + /// 搜索 skills.sh 公共目录 + pub async fn search_skills_sh( + query: &str, + limit: usize, + offset: usize, + ) -> Result { + let client = crate::proxy::http_client::get(); + + let url = url::Url::parse_with_params( + "https://skills.sh/api/search", + &[ + ("q", query), + ("limit", &limit.to_string()), + ("offset", &offset.to_string()), + ], + )?; + + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let skills = resp + .skills + .into_iter() + .filter_map(|s| { + let parts: Vec<&str> = s.source.splitn(2, '/').collect(); + if parts.len() != 2 { + return None; + } + let (owner, repo) = (parts[0].to_string(), parts[1].to_string()); + // 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works") + if owner.contains('.') || repo.contains('.') { + return None; + } + Some(SkillsShDiscoverableSkill { + key: s.id, + name: s.name, + directory: s.skill_id.clone(), + repo_owner: owner.clone(), + repo_name: repo.clone(), + repo_branch: "main".to_string(), + installs: s.installs, + readme_url: Some(format!( + "https://github.com/{}/{}/tree/main/{}", + owner, repo, s.skill_id + )), + }) + }) + .collect(); + + Ok(SkillsShSearchResult { + skills, + total_count: resp.count, + query: resp.query, + }) + } } // ========== 迁移支持 ========== diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 5787f72ec..6c0d20b87 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -20,9 +20,15 @@ interface SkillCardProps { skill: SkillCardSkill; onInstall: (directory: string) => Promise; onUninstall: (directory: string) => Promise; + installs?: number; } -export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { +export function SkillCard({ + skill, + onInstall, + onUninstall, + installs, +}: SkillCardProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -81,6 +87,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { {skill.repoOwner}/{skill.repoName} )} + {typeof installs === "number" && ( + + + {installs.toLocaleString()} + + )}
{skill.installed && ( diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index ba09c835b..c24d68e94 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,4 +1,10 @@ -import { useState, useMemo, forwardRef, useImperativeHandle } from "react"; +import { + useState, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RefreshCw, Search } from "lucide-react"; +import { RefreshCw, Search, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; @@ -20,9 +26,14 @@ import { useSkillRepos, useAddSkillRepo, useRemoveSkillRepo, + useSearchSkillsSh, } from "@/hooks/useSkills"; import type { AppId } from "@/lib/api/types"; -import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; +import type { + DiscoverableSkill, + SkillRepo, + SkillsShDiscoverableSkill, +} from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { @@ -34,9 +45,13 @@ export interface SkillsPageHandle { openRepoManager: () => void; } +type SearchSource = "repos" | "skillssh"; + +const SKILLSSH_PAGE_SIZE = 20; + /** * Skills 发现面板 - * 用于浏览和安装来自仓库的 Skills + * 用于浏览和安装来自仓库或 skills.sh 的 Skills */ export const SkillsPage = forwardRef( ({ initialApp = "claude" }, ref) => { @@ -48,6 +63,15 @@ export const SkillsPage = forwardRef( "all" | "installed" | "uninstalled" >("all"); + // skills.sh 搜索状态 + const [searchSource, setSearchSource] = useState("repos"); + const [skillsShInput, setSkillsShInput] = useState(""); + const [skillsShQuery, setSkillsShQuery] = useState(""); + const [skillsShOffset, setSkillsShOffset] = useState(0); + const [accumulatedResults, setAccumulatedResults] = useState< + SkillsShDiscoverableSkill[] + >([]); + // currentApp 用于安装时的默认应用 const currentApp = initialApp; @@ -61,6 +85,33 @@ export const SkillsPage = forwardRef( const { data: installedSkills } = useInstalledSkills(); const { data: repos = [], refetch: refetchRepos } = useSkillRepos(); + // skills.sh 搜索 + const { + data: skillsShResult, + isLoading: loadingSkillsSh, + isFetching: fetchingSkillsSh, + } = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset); + + // 当搜索结果返回时累积 + useEffect(() => { + if (skillsShResult) { + if (skillsShOffset === 0) { + setAccumulatedResults(skillsShResult.skills); + } else { + setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]); + } + } + }, [skillsShResult, skillsShOffset]); + + // 手动提交搜索 + const handleSkillsShSearch = () => { + const trimmed = skillsShInput.trim(); + if (trimmed.length < 2) return; + setSkillsShOffset(0); + setAccumulatedResults([]); + setSkillsShQuery(trimmed); + }; + // Mutations const installMutation = useInstallSkill(); const addRepoMutation = useAddSkillRepo(); @@ -110,7 +161,16 @@ export const SkillsPage = forwardRef( }); }, [discoverableSkills, installedKeys]); - const loading = loadingDiscoverable || fetchingDiscoverable; + // 检查 skills.sh 结果的安装状态 + const isSkillsShInstalled = (skill: SkillsShDiscoverableSkill): boolean => { + const key = `${skill.directory.toLowerCase()}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`; + return installedKeys.has(key); + }; + + const loading = + searchSource === "repos" + ? loadingDiscoverable || fetchingDiscoverable + : false; useImperativeHandle(ref, () => ({ refresh: () => { @@ -120,13 +180,36 @@ export const SkillsPage = forwardRef( openRepoManager: () => setRepoManagerOpen(true), })); + // skills.sh 结果转为 DiscoverableSkill(复用现有安装流程) + const toDiscoverableSkill = ( + s: SkillsShDiscoverableSkill, + ): DiscoverableSkill => ({ + key: s.key, + name: s.name, + description: "", + directory: s.directory, + repoOwner: s.repoOwner, + repoName: s.repoName, + repoBranch: s.repoBranch, + readmeUrl: s.readmeUrl, + }); + const handleInstall = async (directory: string) => { - // 找到对应的 DiscoverableSkill - const skill = discoverableSkills?.find( - (s) => - s.directory === directory || - s.directory.split("/").pop() === directory, - ); + let skill: DiscoverableSkill | undefined; + + if (searchSource === "skillssh") { + const found = accumulatedResults.find((s) => s.directory === directory); + if (found) { + skill = toDiscoverableSkill(found); + } + } else { + skill = discoverableSkills?.find( + (s) => + s.directory === directory || + s.directory.split("/").pop() === directory, + ); + } + if (!skill) { toast.error(t("skills.notFound")); return; @@ -201,7 +284,7 @@ export const SkillsPage = forwardRef( } }; - // 过滤技能列表 + // 过滤技能列表(仓库模式) const filteredSkills = useMemo(() => { // 按仓库筛选 const byRepo = skills.filter((skill) => { @@ -232,35 +315,56 @@ export const SkillsPage = forwardRef( }); }, [skills, searchQuery, filterRepo, filterStatus]); + // 是否有更多 skills.sh 结果 + const hasMoreSkillsSh = + skillsShResult && accumulatedResults.length < skillsShResult.totalCount; + + // 无仓库时默认切换到 skills.sh + const effectiveSource = + searchSource === "repos" && skills.length === 0 && !loading + ? "skillssh" + : searchSource; + return (
{/* 技能网格(可滚动详情区域) */}
- {loading ? ( -
- -
- ) : skills.length === 0 ? ( -
-

- {t("skills.empty")} -

-

- {t("skills.emptyDescription")} -

+ {/* 搜索来源切换 + 搜索框 */} +
+ {/* 来源切换 */} +
+
- ) : ( - <> - {/* 搜索框和筛选器 */} -
+ + {effectiveSource === "repos" ? ( + <> + {/* 仓库模式搜索框 */}
( {t("skills.count", { count: filteredSkills.length })}

)} -
+ + ) : ( + <> + {/* skills.sh 搜索框 */} +
+ + setSkillsShInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSkillsShSearch(); + }} + className="pl-9 pr-3" + /> +
+ + + )} +
- {/* 技能列表或无结果提示 */} - {filteredSkills.length === 0 ? ( + {/* 内容区域 */} + {effectiveSource === "repos" ? ( + /* ===== 仓库模式 ===== */ + loading ? ( +
+ +
+ ) : skills.length === 0 ? ( +
+

+ {t("skills.empty")} +

+

+ {t("skills.emptyDescription")} +

+ +
+ ) : filteredSkills.length === 0 ? ( +
+

+ {t("skills.noResults")} +

+

+ {t("skills.emptyDescription")} +

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ ) + ) : ( + /* ===== skills.sh 模式 ===== */ + <> + {loadingSkillsSh && accumulatedResults.length === 0 ? ( +
+ + + {t("skills.skillssh.loading")} + +
+ ) : skillsShQuery.length < 2 ? ( +
+ +

+ {t("skills.skillssh.searchPlaceholder")} +

+
+ ) : accumulatedResults.length === 0 && !loadingSkillsSh ? (

- {t("skills.noResults")} -

-

- {t("skills.emptyDescription")} + {t("skills.skillssh.noResults", { + query: skillsShQuery, + })}

) : ( -
- {filteredSkills.map((skill) => ( - - ))} -
+ <> +
+ {accumulatedResults.map((skill) => { + const installed = isSkillsShInstalled(skill); + return ( + + ); + })} +
+ + {/* 加载更多 + 底部信息 */} +
+ {hasMoreSkillsSh && ( + + )} +

+ {t("skills.skillssh.poweredBy")} +

+
+ )} )} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..4e9f9a04b --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +/** + * 返回一个延迟更新的值,在指定时间内无新变化后才更新。 + * 用于搜索输入等场景,避免每次按键都触发请求。 + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 65d67aa63..ea641f099 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -11,6 +11,7 @@ import { type ImportSkillSelection, type InstalledSkill, type SkillUpdateInfo, + type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; @@ -326,6 +327,26 @@ export function useUpdateSkill() { }); } +// ========== skills.sh 搜索 ========== + +/** + * 搜索 skills.sh 公共目录 + * 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验 + */ +export function useSearchSkillsSh( + query: string, + limit: number, + offset: number, +) { + return useQuery({ + queryKey: ["skills", "skillssh", query, limit, offset], + queryFn: () => skillsApi.searchSkillsSh(query, limit, offset), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, + }); +} + // ========== 辅助类型 ========== export type { @@ -334,5 +355,6 @@ export type { ImportSkillSelection, SkillBackupEntry, SkillUpdateInfo, + SkillsShSearchResult, AppId, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b8440e7c..da49b30a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1650,6 +1650,19 @@ }, "search": "Search Skills", "searchPlaceholder": "Search skill name or repo...", + "searchSource": { + "repos": "Repos", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "Search skills.sh (min 2 chars)...", + "installs": "{{count}} installs", + "loadMore": "Load More", + "loading": "Searching skills.sh...", + "noResults": "No skills found for \"{{query}}\"", + "error": "Failed to search skills.sh", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "Filter by status", "all": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f2ccfac41..bc13a3138 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,6 +1650,19 @@ }, "search": "スキルを検索", "searchPlaceholder": "スキル名またはリポジトリで検索...", + "searchSource": { + "repos": "リポジトリ", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "skills.sh を検索(2文字以上)...", + "installs": "{{count}} インストール", + "loadMore": "さらに読み込む", + "loading": "skills.sh を検索中...", + "noResults": "\"{{query}}\" に該当するスキルがありません", + "error": "skills.sh の検索に失敗しました", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "状態で絞り込み", "all": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6427611ea..431e8354f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1650,6 +1650,19 @@ }, "search": "搜索技能", "searchPlaceholder": "搜索技能名称或仓库名称...", + "searchSource": { + "repos": "仓库", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "搜索 skills.sh(至少 2 个字符)...", + "installs": "{{count}} 次安装", + "loadMore": "加载更多", + "loading": "正在搜索 skills.sh...", + "noResults": "未找到 \"{{query}}\" 相关技能", + "error": "搜索 skills.sh 失败", + "poweredBy": "由 skills.sh 提供" + }, "filter": { "placeholder": "状态筛选", "all": "全部", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3e92e88b0..456e57b67 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -95,6 +95,25 @@ export interface MigrationResult { errors: string[]; } +/** skills.sh 可发现的技能 */ +export interface SkillsShDiscoverableSkill { + key: string; + name: string; + directory: string; + repoOwner: string; + repoName: string; + repoBranch: string; + installs: number; + readmeUrl?: string; +} + +/** skills.sh 搜索结果 */ +export interface SkillsShSearchResult { + skills: SkillsShDiscoverableSkill[]; + totalCount: number; + query: string; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -183,6 +202,15 @@ export const skillsApi = { return await invoke("migrate_skill_storage", { target }); }, + /** 搜索 skills.sh 公共目录 */ + async searchSkillsSh( + query: string, + limit: number, + offset: number, + ): Promise { + return await invoke("search_skills_sh", { query, limit, offset }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ From 2d581bce91b4a21b2855cb0077802f4d424c4b3c Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 22:41:27 +0800 Subject: [PATCH 030/128] fix: hide empty description and fix broken skill link for skills.sh results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hide "暂无描述" text when skill has no description (skills.sh API doesn't return descriptions), show empty spacer instead - Change skills.sh result link from guessed subdirectory path to repo root URL, since skillId doesn't reflect the actual nested path --- src-tauri/src/services/skill.rs | 4 ++-- src/components/skills/SkillCard.tsx | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 2ce39ea02..461acb002 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -2738,8 +2738,8 @@ impl SkillService { repo_branch: "main".to_string(), installs: s.installs, readme_url: Some(format!( - "https://github.com/{}/{}/tree/main/{}", - owner, repo, s.skill_id + "https://github.com/{}/{}", + owner, repo )), }) }) diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 6c0d20b87..2b47c40ca 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -108,11 +108,15 @@ export function SkillCard({ )}
- -

- {skill.description || t("skills.noDescription")} -

-
+ {skill.description ? ( + +

+ {skill.description} +

+
+ ) : ( +
+ )} {skill.readmeUrl && ( +
+
+ ); +} diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 691a8e075..5d6bd0c17 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -371,13 +371,16 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { {t("usage.status")} + + {t("usage.source", { defaultValue: "Source" })} + {logs.length === 0 ? ( {t("usage.noData")} @@ -506,6 +509,21 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { {log.statusCode} + + {log.dataSource && log.dataSource !== "proxy" ? ( + + {t(`usage.dataSource.${log.dataSource}`, { + defaultValue: log.dataSource, + })} + + ) : ( + + {t("usage.dataSource.proxy", { + defaultValue: "Proxy", + })} + + )} + )) )} diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 81060e357..f11ba536d 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -6,6 +6,7 @@ import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; +import { DataSourceBar } from "./DataSourceBar"; import type { TimeRange } from "@/types/usage"; import { motion } from "framer-motion"; import { @@ -100,6 +101,8 @@ export function UsageDashboard() {
+ + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index da49b30a6..8fc98c2ff 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1018,6 +1018,21 @@ "unknownProvider": "Unknown Provider", "stream": "Stream", "nonStream": "Non-stream", + "source": "Source", + "dataSources": "Data Sources", + "dataSource": { + "proxy": "Proxy", + "session_log": "Session Log", + "codex_db": "Codex DB" + }, + "sessionSync": { + "trigger": "Sync session logs", + "import": "Import Sessions", + "resync": "Sync", + "imported": "Imported {{count}} records from session logs", + "upToDate": "Session logs are up to date", + "failed": "Session sync failed" + }, "totalRecords": "{{total}} records total", "modelPricing": "Model Pricing", "loadPricingError": "Failed to load pricing data", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bc13a3138..e27cc8411 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1018,6 +1018,21 @@ "unknownProvider": "不明なプロバイダー", "stream": "ストリーム", "nonStream": "非ストリーム", + "source": "ソース", + "dataSources": "データソース", + "dataSource": { + "proxy": "プロキシ", + "session_log": "セッションログ", + "codex_db": "Codex DB" + }, + "sessionSync": { + "trigger": "セッションログを同期", + "import": "セッションをインポート", + "resync": "同期", + "imported": "セッションログから {{count}} 件のレコードをインポートしました", + "upToDate": "セッションログは最新です", + "failed": "セッション同期に失敗しました" + }, "totalRecords": "全 {{total}} 件", "modelPricing": "モデル料金", "loadPricingError": "料金データの読み込みに失敗しました", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 431e8354f..84f783c00 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1018,6 +1018,21 @@ "unknownProvider": "未知供应商", "stream": "流", "nonStream": "非流", + "source": "来源", + "dataSources": "数据来源", + "dataSource": { + "proxy": "代理", + "session_log": "会话日志", + "codex_db": "Codex 数据库" + }, + "sessionSync": { + "trigger": "同步会话日志", + "import": "导入会话", + "resync": "同步", + "imported": "从会话日志导入了 {{count}} 条记录", + "upToDate": "会话日志已是最新", + "failed": "会话同步失败" + }, "totalRecords": "共 {{total}} 条记录", "modelPricing": "模型定价", "loadPricingError": "加载定价数据失败", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index dbb38bc9b..981af57d2 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -9,6 +9,8 @@ import type { ModelPricing, ProviderLimitStatus, PaginatedLogs, + SessionSyncResult, + DataSourceSummary, } from "@/types/usage"; import type { UsageResult } from "@/types"; import type { AppId } from "./types"; @@ -115,4 +117,13 @@ export const usageApi = { ): Promise => { return invoke("check_provider_limits", { providerId, appType }); }, + + // Session usage sync + syncSessionUsage: async (): Promise => { + return invoke("sync_session_usage"); + }, + + getDataSourceBreakdown: async (): Promise => { + return invoke("get_usage_data_sources"); + }, }; diff --git a/src/types/usage.ts b/src/types/usage.ts index 61dc8774f..6b21e2b71 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -31,6 +31,20 @@ export interface RequestLog { statusCode: number; errorMessage?: string; createdAt: number; + dataSource?: string; +} + +export interface SessionSyncResult { + imported: number; + skipped: number; + filesScanned: number; + errors: string[]; +} + +export interface DataSourceSummary { + dataSource: string; + requestCount: number; + totalCostUsd: string; } export interface PaginatedLogs { From 46051317da488d2e47c484f04e4b69cf0a283953 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 11:44:39 +0800 Subject: [PATCH 032/128] =?UTF-8?q?fix:=20correct=20model=20pricing=20CNY?= =?UTF-8?q?=E2=86=92USD=20and=20add=20missing=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 13 Chinese model prices that were stored as CNY values in USD fields (DeepSeek, Kimi, MiniMax, GLM, Doubao, Mimo) - Add 12 new models: GPT-5.4/mini/nano, o3, o4-mini, GPT-4.1/mini/nano, Gemini 3.1 Pro/Flash Lite, Gemini 2.5 Flash Lite, Gemini 2.0 Flash, DeepSeek Chat/Reasoner, Kimi K2.5 - Merge pricing migration into existing v7→v8 to avoid extra version bump --- src-tauri/src/database/schema.rs | 143 +++++++++++++++++++------------ 1 file changed, 90 insertions(+), 53 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index b3e9d26e2..4f8257cc2 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -414,7 +414,7 @@ impl Database { Self::set_user_version(conn, 7)?; } 7 => { - log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪)"); + log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)"); Self::migrate_v7_to_v8(conn)?; Self::set_user_version(conn, 8)?; } @@ -1102,7 +1102,38 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?; - log::info!("v7 -> v8 迁移完成:已添加 data_source 列和 session_log_sync 表"); + // 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD + let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ + ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "0.28", "1.11", "0.028", "0"), + ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), + ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), + ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), + ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), + ("minimax-m2", "0.27", "0.95", "0.03", "0"), + ("glm-4.7", "0.39", "1.75", "0.04", "0"), + ("glm-4.6", "0.28", "1.11", "0.03", "0"), + ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), + ]; + for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { + conn.execute( + "UPDATE model_pricing SET + input_cost_per_million = ?2, + output_cost_per_million = ?3, + cache_read_cost_per_million = ?4, + cache_creation_cost_per_million = ?5 + WHERE model_id = ?1", + rusqlite::params![model_id, input, output, cache_read, cache_creation], + ) + .map_err(|e| { + AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) + })?; + } + + log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价"); Ok(()) } @@ -1195,6 +1226,10 @@ impl Database { "0.30", "3.75", ), + // GPT-5.4 系列 + ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"), + ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"), + ("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"), // GPT-5.2 系列 ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"), ("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"), @@ -1355,6 +1390,30 @@ impl Database { "0.125", "0", ), + // OpenAI Reasoning 系列 + ("o3", "OpenAI o3", "2", "8", "0.50", "0"), + ("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"), + // GPT-4.1 系列 + ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"), + ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"), + ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"), + // Gemini 3.1 系列 + ( + "gemini-3.1-pro-preview", + "Gemini 3.1 Pro Preview", + "2", + "12", + "0.20", + "0", + ), + ( + "gemini-3.1-flash-lite-preview", + "Gemini 3.1 Flash Lite Preview", + "0.25", + "1.50", + "0.025", + "0", + ), // Gemini 3 系列 ( "gemini-3-pro-preview", @@ -1389,6 +1448,16 @@ impl Database { "0.03", "0", ), + ( + "gemini-2.5-flash-lite", + "Gemini 2.5 Flash Lite", + "0.10", + "0.40", + "0.01", + "0", + ), + // Gemini 2.0 系列 + ("gemini-2.0-flash", "Gemini 2.0 Flash", "0.10", "0.40", "0.025", "0"), // StepFun 系列 ( "step-3.5-flash", @@ -1398,68 +1467,36 @@ impl Database { "0.02", "0", ), - // ====== 国产模型 (CNY/1M tokens) ====== + // ====== 国产模型 (USD/1M tokens) ====== // Doubao (字节跳动) - ( - "doubao-seed-code", - "Doubao Seed Code", - "1.20", - "8.00", - "0.24", - "0", - ), + ("doubao-seed-code", "Doubao Seed Code", "0.17", "1.11", "0.02", "0"), // DeepSeek 系列 - ( - "deepseek-v3.2", - "DeepSeek V3.2", - "2.00", - "3.00", - "0.40", - "0", - ), - ( - "deepseek-v3.1", - "DeepSeek V3.1", - "4.00", - "12.00", - "0.80", - "0", - ), - ("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"), + ("deepseek-v3.2", "DeepSeek V3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "DeepSeek V3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"), + ("deepseek-chat", "DeepSeek Chat", "0.28", "0.42", "0.028", "0"), + ("deepseek-reasoner", "DeepSeek Reasoner", "0.28", "0.42", "0.028", "0"), // Kimi (月之暗面) - ( - "kimi-k2-thinking", - "Kimi K2 Thinking", - "4.00", - "16.00", - "1.00", - "0", - ), - ("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"), - ( - "kimi-k2-turbo", - "Kimi K2 Turbo", - "8.00", - "58.00", - "1.00", - "0", - ), + ("kimi-k2-thinking", "Kimi K2 Thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "Kimi K2 Turbo", "1.11", "8.06", "0.14", "0"), + ("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"), // MiniMax 系列 - ("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"), + ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"), ( "minimax-m2.1-lightning", "MiniMax M2.1 Lightning", - "2.10", - "16.80", - "0.21", + "0.27", + "2.33", + "0.03", "0", ), - ("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"), + ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"), // GLM (智谱) - ("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"), - ("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"), + ("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"), + ("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"), // Mimo (小米) - ("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"), + ("mimo-v2-flash", "Mimo V2 Flash", "0.09", "0.29", "0.009", "0"), ]; for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { From 2ea6a307fc2e9fcab26337b42e9ea3982448baef Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 18:31:38 +0800 Subject: [PATCH 033/128] feat: replace Codex estimated usage with precise JSONL session log parsing Replace the 70/30 input/output token estimation from state_5.sqlite with precise parsing of Codex CLI JSONL session logs (~/.codex/sessions/). - Parse event_msg (token_count), turn_context, and session_meta events - Compute exact input/output/cached token deltas from cumulative totals - Reuse session_log_sync table for incremental file scanning - Pre-filter lines with string contains() before JSON deserialization - Add codex_session data source to DataSourceBar with i18n (zh/en/ja) --- src-tauri/src/services/session_usage_codex.rs | 775 ++++++++++++++---- src/components/usage/DataSourceBar.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 5 files changed, 608 insertions(+), 177 deletions(-) diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 4e2a3041b..2a6ab36ba 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -1,11 +1,17 @@ -//! Codex 使用数据导入 +//! Codex 会话日志使用追踪 //! -//! 从 ~/.codex/state_5.sqlite 的 threads 表读取 token 使用数据, -//! 导入到 proxy_request_logs 表。 +//! 从 ~/.codex/sessions/ 下的 JSONL 会话文件中提取精确 token 使用数据, +//! 替代原有的 state_5.sqlite 估算方案。 //! -//! ## 限制 -//! - Thread 级粒度(非请求级) -//! - 只有总 token 数,使用估算比例分 input/output +//! ## 数据流 +//! ```text +//! ~/.codex/sessions/YYYY/MM/DD/*.jsonl → 增量解析 → delta 计算 → 费用计算 → proxy_request_logs 表 +//! ``` +//! +//! ## 解析的事件类型 +//! - `session_meta` → 提取 session_id +//! - `turn_context` → 提取当前 model +//! - `event_msg` (type=token_count) → 提取累计 token 用量,计算 delta use crate::codex_config::get_codex_config_dir; use crate::database::{lock_conn, Database}; @@ -14,203 +20,510 @@ use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; use crate::proxy::usage::parser::TokenUsage; use crate::services::session_usage::SessionSyncResult; use rust_decimal::Decimal; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; -/// 默认 input/output 比例(70% input, 30% output) -const DEFAULT_INPUT_RATIO: f64 = 0.7; +/// 累计 token 用量(跟踪 total_token_usage 字段) +#[derive(Debug, Clone, Default)] +struct CumulativeTokens { + input: u64, + cached_input: u64, + output: u64, +} -/// 同步 Codex 使用数据 +/// 单次 API 调用的 token 增量 +#[derive(Debug)] +struct DeltaTokens { + input: u32, + cached_input: u32, + output: u32, +} + +impl DeltaTokens { + fn is_zero(&self) -> bool { + self.input == 0 && self.cached_input == 0 && self.output == 0 + } +} + +/// 单文件解析时的运行状态 +struct FileParseState { + session_id: Option, + current_model: String, + prev_total: Option, + event_index: u32, +} + +/// 计算两次累计值之间的 delta +fn compute_delta(prev: &Option, current: &CumulativeTokens) -> DeltaTokens { + match prev { + None => DeltaTokens { + input: current.input as u32, + cached_input: current.cached_input as u32, + output: current.output as u32, + }, + Some(p) => DeltaTokens { + input: current.input.saturating_sub(p.input) as u32, + cached_input: current.cached_input.saturating_sub(p.cached_input) as u32, + output: current.output.saturating_sub(p.output) as u32, + }, + } +} + +/// 从 JSON Value 中提取累计 token 用量 +fn parse_cumulative_tokens(total_usage: &serde_json::Value) -> Option { + if total_usage.is_null() || !total_usage.is_object() { + return None; + } + Some(CumulativeTokens { + input: total_usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cached_input: total_usage + .get("cached_input_tokens") + .or_else(|| total_usage.get("cache_read_input_tokens")) + .and_then(|v| v.as_u64()) + .unwrap_or(0), + output: total_usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + }) +} + +/// 同步 Codex 使用数据(从 JSONL 会话日志) pub fn sync_codex_usage(db: &Database) -> Result { let codex_dir = get_codex_config_dir(); - let state_db_path = codex_dir.join("state_5.sqlite"); - if !state_db_path.exists() { - return Ok(SessionSyncResult { - imported: 0, - skipped: 0, - files_scanned: 0, - errors: vec![], - }); - } + let files = collect_codex_session_files(&codex_dir); let mut result = SessionSyncResult { imported: 0, skipped: 0, - files_scanned: 1, + files_scanned: files.len() as u32, errors: vec![], }; - // 只读打开 Codex SQLite 数据库 - let codex_conn = match rusqlite::Connection::open_with_flags( - &state_db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(e) => { - result.errors.push(format!("无法打开 Codex 数据库: {e}")); - return Ok(result); - } - }; + if files.is_empty() { + return Ok(result); + } - // 查询所有 thread - let mut stmt = match codex_conn.prepare( - "SELECT id, model, model_provider, tokens_used, created_at, title - FROM threads - WHERE tokens_used > 0 - ORDER BY created_at DESC", - ) { - Ok(s) => s, - Err(e) => { - result - .errors - .push(format!("查询 Codex threads 失败: {e}")); - return Ok(result); - } - }; - - let rows = match stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, // id - row.get::<_, Option>(1)?, // model (nullable) - row.get::<_, String>(2)?, // model_provider - row.get::<_, i64>(3)?, // tokens_used - row.get::<_, i64>(4)?, // created_at (unix seconds) - row.get::<_, String>(5)?, // title - )) - }) { - Ok(r) => r, - Err(e) => { - result.errors.push(format!("遍历 threads 失败: {e}")); - return Ok(result); - } - }; - - let conn = lock_conn!(db.conn); - - for row_result in rows { - let (thread_id, model, _provider, tokens_used, created_at, _title) = match row_result { - Ok(r) => r, - Err(e) => { - result.errors.push(format!("读取行失败: {e}")); - continue; + for file_path in &files { + match sync_single_codex_file(db, file_path) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; } - }; - - let request_id = format!("codex_thread:{thread_id}"); - let model = model.unwrap_or_else(|| "unknown".to_string()); - - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - result.skipped += 1; - continue; - } - - // 使用估算比例分配 input/output - let input_tokens = (tokens_used as f64 * DEFAULT_INPUT_RATIO) as u32; - let output_tokens = tokens_used as u32 - input_tokens; - - let usage = TokenUsage { - input_tokens, - output_tokens, - cache_read_tokens: 0, - cache_creation_tokens: 0, - model: Some(model.clone()), - }; - - // 查找定价 - let pricing = find_codex_pricing(&conn, &model); - let multiplier = Decimal::from(1); - let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = - match pricing { - Some(p) => { - let cost = CostCalculator::calculate(&usage, &p, multiplier); - ( - cost.input_cost.to_string(), - cost.output_cost.to_string(), - cost.cache_read_cost.to_string(), - cost.cache_creation_cost.to_string(), - cost.total_cost.to_string(), - ) - } - None => ( - "0".to_string(), - "0".to_string(), - "0".to_string(), - "0".to_string(), - "0".to_string(), - ), - }; - - let insert_result = conn.execute( - "INSERT OR IGNORE INTO proxy_request_logs ( - request_id, provider_id, app_type, model, request_model, - input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, - input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, - latency_ms, first_token_ms, status_code, error_message, session_id, - provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", - rusqlite::params![ - request_id, - "_codex_session", - "codex", - model, - model, - input_tokens, - output_tokens, - 0i64, // cache_read_tokens - 0i64, // cache_creation_tokens - input_cost, - output_cost, - cache_read_cost, - cache_creation_cost, - total_cost, - 0i64, // latency_ms - Option::::None, - 200i64, // status_code - Option::::None, - Some(thread_id), // session_id = thread_id - Some("codex_db"), - 0i64, // is_streaming: unknown - "1.0", - created_at, - "codex_db", - ], - ); - - match insert_result { - Ok(changed) if changed > 0 => result.imported += 1, - Ok(_) => result.skipped += 1, Err(e) => { - result.errors.push(format!("插入失败: {e}")); - result.skipped += 1; + let msg = format!("Codex 会话文件解析失败 {}: {e}", file_path.display()); + log::warn!("[CODEX-SYNC] {msg}"); + result.errors.push(msg); } } } if result.imported > 0 { log::info!( - "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条", + "[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, ��描 {} 个文件", result.imported, - result.skipped + result.skipped, + result.files_scanned ); } Ok(result) } +/// 收集所有 Codex 会话 JSONL 文件 +fn collect_codex_session_files(codex_dir: &Path) -> Vec { + let mut files = Vec::new(); + + // 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录�� + let sessions_dir = codex_dir.join("sessions"); + if sessions_dir.is_dir() { + collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3); + } + + // 2. 扫描 archived_sessions/*.jsonl(扁平归档目录) + let archived_dir = codex_dir.join("archived_sessions"); + if archived_dir.is_dir() { + if let Ok(entries) = fs::read_dir(&archived_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } + } + } + + files +} + +/// 递归扫描目录下的 .jsonl 文件(限制最大深度) +fn collect_jsonl_recursive(dir: &Path, files: &mut Vec, depth: u32, max_depth: u32) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < max_depth { + collect_jsonl_recursive(&path, files, depth + 1, max_depth); + } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } +} + +/// 同步单�� Codex JSONL 文件,返回 (imported, skipped) +fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { + let file_path_str = file_path.to_string_lossy().to_string(); + + // 获取文件元数据 + let metadata = fs::metadata(file_path) + .map_err(|e| AppError::Config(format!("无法读取文���元数据: {e}")))?; + let file_modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // 检查同步状态 + let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?; + + // 文件未变化则跳过 + if file_modified <= last_modified { + return Ok((0, 0)); + } + + // 打开文件逐行解析 + let file = + fs::File::open(file_path).map_err(|e| AppError::Config(format!("无法打开文件: {e}")))?; + let reader = BufReader::new(file); + + let mut state = FileParseState { + session_id: None, + current_model: "unknown".to_string(), + prev_total: None, + event_index: 0, + }; + + let mut line_offset: i64 = 0; + let mut imported: u32 = 0; + let mut skipped: u32 = 0; + + for line_result in reader.lines() { + line_offset += 1; + + let line = match line_result { + Ok(l) => l, + Err(_) => continue, // 容忍不完整的最后一行 + }; + + if line.trim().is_empty() { + continue; + } + + // 快速过滤:在 JSON 反序列化前跳过无关行 + let is_event_msg = line.contains("\"event_msg\""); + let is_turn_context = line.contains("\"turn_context\""); + let is_session_meta = line.contains("\"session_meta\""); + + if !is_event_msg && !is_turn_context && !is_session_meta { + continue; + } + if is_event_msg && !line.contains("\"token_count\"") { + continue; + } + + let value: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let event_type = match value.get("type").and_then(|t| t.as_str()) { + Some(t) => t, + None => continue, + }; + + match event_type { + "session_meta" => { + if state.session_id.is_none() { + let payload = value.get("payload"); + state.session_id = payload + .and_then(|p| { + p.get("session_id") + .or_else(|| p.get("sessionId")) + .or_else(|| p.get("id")) + }) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "turn_context" => { + if let Some(payload) = value.get("payload") { + // model 可能在 payload.model 或 payload.info.model + if let Some(model) = payload + .get("model") + .or_else(|| payload.get("info").and_then(|info| info.get("model"))) + .and_then(|v| v.as_str()) + { + state.current_model = model.to_string(); + } + } + } + "event_msg" => { + let payload = match value.get("payload") { + Some(p) => p, + None => continue, + }; + + // 只处理 token_count 类型 + if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") { + continue; + } + + let info = match payload.get("info") { + Some(i) if !i.is_null() => i, + _ => continue, // info 为 null 的首个事件跳�� + }; + + // 提取模型(token_count 事件也可能携带 model) + if let Some(model) = info + .get("model") + .or_else(|| info.get("model_name")) + .or_else(|| payload.get("model")) + .and_then(|v| v.as_str()) + { + state.current_model = model.to_string(); + } + + // 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值) + let (cumulative, is_total) = if let Some(total) = info.get("total_token_usage") { + (parse_cumulative_tokens(total), true) + } else if let Some(last) = info.get("last_token_usage") { + (parse_cumulative_tokens(last), false) + } else { + continue; + }; + + let cumulative = match cumulative { + Some(c) => c, + None => continue, + }; + + let delta = if is_total { + // 累计值模式:计算与上次的 delta + let d = compute_delta(&state.prev_total, &cumulative); + state.prev_total = Some(cumulative); + d + } else { + // 增量值模式:直接使用 last_token_usage 的值 + DeltaTokens { + input: cumulative.input as u32, + cached_input: cumulative.cached_input as u32, + output: cumulative.output as u32, + } + }; + + if delta.is_zero() { + continue; // 跳过 task 边界的零 delta 事件 + } + + state.event_index += 1; + + // 跳过已处理的行(但仍需解析以恢复状态) + if line_offset <= last_offset { + continue; + } + + // 生成唯一 request_id + let session_id_str = state.session_id.as_deref().unwrap_or("unknown"); + let request_id = format!("codex_session:{}:{}", session_id_str, state.event_index); + + // 提取时间戳 + let timestamp = value + .get("timestamp") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + match insert_codex_session_entry( + db, + &request_id, + &delta, + &state.current_model, + state.session_id.as_deref(), + timestamp.as_deref(), + ) { + Ok(true) => imported += 1, + Ok(false) => skipped += 1, + Err(e) => { + log::warn!("[CODEX-SYNC] 插入失败 ({}): {e}", request_id); + skipped += 1; + } + } + } + _ => {} + } + } + + // 更新同步状态 + update_sync_state(db, &file_path_str, file_modified, line_offset)?; + + Ok((imported, skipped)) +} + +/// 插入单条 Codex 会话记录到 proxy_request_logs +fn insert_codex_session_entry( + db: &Database, + request_id: &str, + delta: &DeltaTokens, + model: &str, + session_id: Option<&str>, + timestamp: Option<&str>, +) -> Result { + let conn = lock_conn!(db.conn); + + // 检查是否已存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", + rusqlite::params![request_id], + |row| row.get::<_, i64>(0).map(|c| c > 0), + ) + .unwrap_or(false); + + if exists { + return Ok(false); + } + + // 解析时间戳 + let created_at = timestamp + .and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp()) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + // 计算费用 + let usage = TokenUsage { + input_tokens: delta.input, + output_tokens: delta.output, + cache_read_tokens: delta.cached_input, + cache_creation_tokens: 0, + model: Some(model.to_string()), + }; + + let pricing = find_codex_pricing(&conn, model); + let multiplier = Decimal::from(1); + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing + { + Some(p) => { + let cost = CostCalculator::calculate(&usage, &p, multiplier); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + }; + + conn.execute( + "INSERT OR IGNORE INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + rusqlite::params![ + request_id, + "_codex_session", // provider_id + "codex", // app_type + model, + model, // request_model = model + delta.input, + delta.output, + delta.cached_input, + 0i64, // cache_creation_tokens: Codex 日志无此数据 + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None, // error_message + session_id.map(|s| s.to_string()), + Some("codex_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "codex_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 Codex 会话日志失败: {e}")))?; + + Ok(true) +} + +/// 获取文件的同步状态 +fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { + let conn = lock_conn!(db.conn); + let result = conn.query_row( + "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ); + Ok(result.unwrap_or((0, 0))) +} + +/// 更新文件的同步状态 +fn update_sync_state( + db: &Database, + file_path: &str, + last_modified: i64, + last_offset: i64, +) -> Result<(), AppError> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![file_path, last_modified, last_offset, now], + ) + .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; + Ok(()) +} + /// 查找 Codex 模型定价 -fn find_codex_pricing( - conn: &rusqlite::Connection, - model_id: &str, -) -> Option { - // 精确匹配 +fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + // 精确匹�� let result = conn.query_row( "SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million @@ -252,3 +565,117 @@ fn find_codex_pricing( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_delta_first_event() { + let prev = None; + let current = CumulativeTokens { + input: 17934, + cached_input: 9600, + output: 454, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 17934); + assert_eq!(delta.cached_input, 9600); + assert_eq!(delta.output, 454); + assert!(!delta.is_zero()); + } + + #[test] + fn test_delta_subsequent_event() { + let prev = Some(CumulativeTokens { + input: 17934, + cached_input: 9600, + output: 454, + }); + let current = CumulativeTokens { + input: 36722, + cached_input: 27904, + output: 804, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 36722 - 17934); + assert_eq!(delta.cached_input, 27904 - 9600); + assert_eq!(delta.output, 804 - 454); + } + + #[test] + fn test_delta_zero_at_task_boundary() { + let prev = Some(CumulativeTokens { + input: 58346, + cached_input: 46976, + output: 1045, + }); + // task 边界:相同的累计值 + let current = CumulativeTokens { + input: 58346, + cached_input: 46976, + output: 1045, + }; + let delta = compute_delta(&prev, ¤t); + assert!(delta.is_zero()); + } + + #[test] + fn test_delta_saturating_sub() { + // 异常情况:当前值小于前值(不应发生,但需防护) + let prev = Some(CumulativeTokens { + input: 100, + cached_input: 50, + output: 30, + }); + let current = CumulativeTokens { + input: 80, + cached_input: 40, + output: 20, + }; + let delta = compute_delta(&prev, ¤t); + assert_eq!(delta.input, 0); + assert_eq!(delta.cached_input, 0); + assert_eq!(delta.output, 0); + assert!(delta.is_zero()); + } + + #[test] + fn test_parse_cumulative_tokens_valid() { + let json: serde_json::Value = serde_json::json!({ + "input_tokens": 17934, + "cached_input_tokens": 9600, + "output_tokens": 454, + "reasoning_output_tokens": 233, + "total_tokens": 18388 + }); + let tokens = parse_cumulative_tokens(&json).unwrap(); + assert_eq!(tokens.input, 17934); + assert_eq!(tokens.cached_input, 9600); + assert_eq!(tokens.output, 454); + } + + #[test] + fn test_parse_cumulative_tokens_null() { + let json = serde_json::Value::Null; + assert!(parse_cumulative_tokens(&json).is_none()); + } + + #[test] + fn test_parse_cumulative_tokens_alt_field_names() { + // 某些版本可能使用 cache_read_input_tokens 而非 cached_input_tokens + let json: serde_json::Value = serde_json::json!({ + "input_tokens": 1000, + "cache_read_input_tokens": 500, + "output_tokens": 200 + }); + let tokens = parse_cumulative_tokens(&json).unwrap(); + assert_eq!(tokens.cached_input, 500); + } + + #[test] + fn test_collect_codex_session_files_nonexistent() { + let files = collect_codex_session_files(Path::new("/nonexistent/path")); + assert!(files.is_empty()); + } +} diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index e068151e0..bc40d6f6f 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -15,6 +15,7 @@ const DATA_SOURCE_ICONS: Record = { proxy: , session_log: , codex_db: , + codex_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8fc98c2ff..ff0202aeb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "Proxy", "session_log": "Session Log", - "codex_db": "Codex DB" + "codex_db": "Codex DB", + "codex_session": "Codex Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e27cc8411..c4b2b1f39 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "プロキシ", "session_log": "セッションログ", - "codex_db": "Codex DB" + "codex_db": "Codex DB", + "codex_session": "Codex セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 84f783c00..fd39b3e0e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1023,7 +1023,8 @@ "dataSource": { "proxy": "代理", "session_log": "会话日志", - "codex_db": "Codex 数据库" + "codex_db": "Codex 数据库", + "codex_session": "Codex 会话日志" }, "sessionSync": { "trigger": "同步会话日志", From 8ad1bb7924c8b1395d70cf228e5784720765010f Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 19:02:32 +0800 Subject: [PATCH 034/128] feat: add Codex model name normalization for consistent pricing lookup Normalize model names from JSONL session logs before storage and pricing lookup: lowercase, strip provider prefix (openai/), strip date suffixes (-YYYY-MM-DD, -YYYYMMDD). Also clamp cached tokens to not exceed input. --- src-tauri/src/services/session_usage_codex.rs | 197 +++++++++++++++--- 1 file changed, 165 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index 2a6ab36ba..6d211d30c 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -55,6 +55,51 @@ struct FileParseState { event_index: u32, } +/// 归一化 Codex 模型名 +/// +/// 处理规则(按顺序): +/// 1. 转小写:`GLM-4.6` → `glm-4.6` +/// 2. 剥离 provider 前缀:`openai/gpt-5.4` → `gpt-5.4` +/// 3. 剥离 ISO 日期后缀:`gpt-5.4-2026-03-05` → `gpt-5.4` +/// 4. 剥离紧凑日期后缀:`gpt-5.4-20260305` → `gpt-5.4` +fn normalize_codex_model(raw: &str) -> String { + // Step 1: 小写 + let mut name = raw.to_lowercase(); + + // Step 2: 剥离 "provider/" 前缀(如 openai/, azure/) + if let Some(pos) = name.rfind('/') { + name = name[pos + 1..].to_string(); + } + + // Step 3: 剥离 ISO 日期后缀 -YYYY-MM-DD(正好 11 字符) + if name.len() > 11 { + let suffix = &name[name.len() - 11..]; + if suffix.as_bytes()[0] == b'-' + && suffix[1..5].chars().all(|c| c.is_ascii_digit()) + && suffix.as_bytes()[5] == b'-' + && suffix[6..8].chars().all(|c| c.is_ascii_digit()) + && suffix.as_bytes()[8] == b'-' + && suffix[9..11].chars().all(|c| c.is_ascii_digit()) + { + name.truncate(name.len() - 11); + } + } + + // Step 4: 剥离紧凑日期后缀 -YYYYMMDD(正好 9 字符) + if name.len() > 9 { + let parts: Vec<&str> = name.rsplitn(2, '-').collect(); + if parts.len() == 2 { + if let Some(suffix) = parts.first() { + if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) { + name = parts[1].to_string(); + } + } + } + } + + name +} + /// 计算两次累计值之间的 delta fn compute_delta(prev: &Option, current: &CumulativeTokens) -> DeltaTokens { match prev { @@ -273,7 +318,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), .or_else(|| payload.get("info").and_then(|info| info.get("model"))) .and_then(|v| v.as_str()) { - state.current_model = model.to_string(); + state.current_model = normalize_codex_model(model); } } } @@ -300,7 +345,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), .or_else(|| payload.get("model")) .and_then(|v| v.as_str()) { - state.current_model = model.to_string(); + state.current_model = normalize_codex_model(model); } // 优先用 total_token_usage(累计值),fallback 到 last_token_usage(增量值) @@ -331,6 +376,12 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), } }; + // 钳制:cached 不应超过 input(防护异常数据) + let delta = DeltaTokens { + cached_input: delta.cached_input.min(delta.input), + ..delta + }; + if delta.is_zero() { continue; // 跳过 task 边界的零 delta 事件 } @@ -521,10 +572,38 @@ fn update_sync_state( Ok(()) } -/// 查找 Codex 模型定价 +/// ��找 Codex 模型定价(带归一化) fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { - // 精确匹�� - let result = conn.query_row( + let normalized = normalize_codex_model(model_id); + + // 1. 精确匹配(归一化后的名称) + if let Some(pricing) = try_find_pricing(conn, &normalized) { + return Some(pricing); + } + + // 2. LIKE 模糊匹配(兜底) + let pattern = format!("{normalized}%"); + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", + rusqlite::params![pattern], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +/// 精确匹配定价查询 +fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + conn.query_row( "SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million FROM model_pricing WHERE model_id = ?1", @@ -537,33 +616,9 @@ fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option(3)?, )) }, - ); - - match result { - Ok((input, output, cache_read, cache_creation)) => { - ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok() - } - Err(_) => { - // 尝试 LIKE 匹配 - let pattern = format!("{model_id}%"); - conn.query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, cache_creation_cost_per_million - FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", - rusqlite::params![pattern], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .ok() - .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) - } - } + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) } #[cfg(test)] @@ -678,4 +733,82 @@ mod tests { let files = collect_codex_session_files(Path::new("/nonexistent/path")); assert!(files.is_empty()); } + + // ── 模型名归一化测试 ── + + #[test] + fn test_normalize_codex_model_lowercase() { + assert_eq!(normalize_codex_model("GLM-4.6"), "glm-4.6"); + assert_eq!(normalize_codex_model("DeepSeek-Chat"), "deepseek-chat"); + assert_eq!(normalize_codex_model("GPT-5.4"), "gpt-5.4"); + } + + #[test] + fn test_normalize_codex_model_strip_prefix() { + assert_eq!(normalize_codex_model("openai/gpt-5.4"), "gpt-5.4"); + assert_eq!(normalize_codex_model("azure/gpt-5.2-codex"), "gpt-5.2-codex"); + assert_eq!(normalize_codex_model("OPENAI/GPT-5.4"), "gpt-5.4"); + } + + #[test] + fn test_normalize_codex_model_strip_iso_date() { + assert_eq!(normalize_codex_model("gpt-5.4-2026-03-05"), "gpt-5.4"); + assert_eq!( + normalize_codex_model("gpt-5.4-pro-2026-03-05"), + "gpt-5.4-pro" + ); + } + + #[test] + fn test_normalize_codex_model_strip_compact_date() { + assert_eq!(normalize_codex_model("gpt-5.4-20260305"), "gpt-5.4"); + assert_eq!( + normalize_codex_model("claude-opus-4-6-20260206"), + "claude-opus-4-6" + ); + } + + #[test] + fn test_normalize_codex_model_no_change() { + assert_eq!(normalize_codex_model("gpt-5.4"), "gpt-5.4"); + assert_eq!(normalize_codex_model("gpt-5.2-codex"), "gpt-5.2-codex"); + assert_eq!(normalize_codex_model("o3"), "o3"); + assert_eq!(normalize_codex_model("deepseek-chat"), "deepseek-chat"); + } + + #[test] + fn test_normalize_codex_model_combined() { + // prefix + uppercase + ISO date + assert_eq!( + normalize_codex_model("openai/GPT-5.4-2026-03-05"), + "gpt-5.4" + ); + // prefix + compact date + assert_eq!( + normalize_codex_model("openai/gpt-5.4-20260305"), + "gpt-5.4" + ); + } + + #[test] + fn test_cached_clamped_to_input() { + // cached > input 的异常场景应被 min() 钳制 + let prev = Some(CumulativeTokens { + input: 100, + cached_input: 0, + output: 50, + }); + let current = CumulativeTokens { + input: 110, // delta = 10 + cached_input: 80, // delta = 80(异常:大于 input delta) + output: 60, + }; + let delta = compute_delta(&prev, ¤t); + // 钳制前:cached_input = 80, input = 10 + assert_eq!(delta.cached_input, 80); + assert_eq!(delta.input, 10); + // 实际钳制在调用侧:delta.cached_input.min(delta.input) + let clamped = delta.cached_input.min(delta.input); + assert_eq!(clamped, 10); + } } From f5d7064d5765bc48923873e7576649eed07210c8 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 19:48:06 +0800 Subject: [PATCH 035/128] feat: add Gemini CLI session log usage tracking Parse ~/.gemini/tmp/*/chats/session-*.json for precise per-message token data (input/output/cached/thoughts). Integrates with existing background sync and manual sync button alongside Claude and Codex. --- src-tauri/src/commands/usage.rs | 13 + src-tauri/src/lib.rs | 14 + src-tauri/src/services/mod.rs | 1 + .../src/services/session_usage_gemini.rs | 490 ++++++++++++++++++ src/components/usage/DataSourceBar.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 8 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/services/session_usage_gemini.rs diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 1f92456ef..9bf0bf08e 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -187,6 +187,19 @@ pub fn sync_session_usage( } } + // 同步 Gemini 使用数据 + match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) { + Ok(gemini_result) => { + result.imported += gemini_result.imported; + result.skipped += gemini_result.skipped; + result.files_scanned += gemini_result.files_scanned; + result.errors.extend(gemini_result.errors); + } + Err(e) => { + result.errors.push(format!("Gemini 同步失败: {e}")); + } + } + Ok(result) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 186215b89..6e2648ff9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -817,6 +817,13 @@ pub fn run() { { log::warn!("Codex usage initial sync failed: {e}"); } + if let Err(e) = + crate::services::session_usage_gemini::sync_gemini_usage( + &db_for_session_sync, + ) + { + log::warn!("Gemini usage initial sync failed: {e}"); + } // 定期同步 let mut interval = tokio::time::interval(std::time::Duration::from_secs( @@ -839,6 +846,13 @@ pub fn run() { { log::warn!("Codex usage periodic sync failed: {e}"); } + if let Err(e) = + crate::services::session_usage_gemini::sync_gemini_usage( + &db_for_session_sync, + ) + { + log::warn!("Gemini usage periodic sync failed: {e}"); + } } }); }); diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index d64711245..5a6bb798f 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod provider; pub mod proxy; pub mod session_usage; pub mod session_usage_codex; +pub mod session_usage_gemini; pub mod skill; pub mod speedtest; pub mod stream_check; diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs new file mode 100644 index 000000000..770994547 --- /dev/null +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -0,0 +1,490 @@ +//! Gemini CLI 会话日志使用追踪 +//! +//! 从 ~/.gemini/tmp//chats/session-*.json 中提取精确 token 使用数据。 +//! +//! ## 数据流 +//! ```text +//! ~/.gemini/tmp/*/chats/session-*.json → 全量解析 → 费用计算 → proxy_request_logs 表 +//! ``` +//! +//! ## 与 Claude/Codex 解析器的差异 +//! - JSON 格式(非 JSONL):每个文件是单个 JSON 对象,包含 messages 数组 +//! - 无需 delta 计算:tokens 字段是 per-message 独立值 +//! - 无需状态恢复:不依赖前一条消息的累计值 +//! - 天然去重:每条消息有唯一 id 字段 + +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::gemini_config::get_gemini_dir; +use crate::proxy::usage::calculator::{CostCalculator, ModelPricing}; +use crate::proxy::usage::parser::TokenUsage; +use crate::services::session_usage::SessionSyncResult; +use rust_decimal::Decimal; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// 从 Gemini message 中提取的 token 数据 +#[derive(Debug)] +struct GeminiTokens { + input: u32, + output: u32, + cached: u32, + thoughts: u32, +} + +/// 同步 Gemini 使用数据(从 JSON 会话日志) +pub fn sync_gemini_usage(db: &Database) -> Result { + let gemini_dir = get_gemini_dir(); + + let files = collect_gemini_session_files(&gemini_dir); + + let mut result = SessionSyncResult { + imported: 0, + skipped: 0, + files_scanned: files.len() as u32, + errors: vec![], + }; + + if files.is_empty() { + return Ok(result); + } + + for file_path in &files { + match sync_single_gemini_file(db, file_path) { + Ok((imported, skipped)) => { + result.imported += imported; + result.skipped += skipped; + } + Err(e) => { + let msg = format!("Gemini 会话文件解析失败 {}: {e}", file_path.display()); + log::warn!("[GEMINI-SYNC] {msg}"); + result.errors.push(msg); + } + } + } + + if result.imported > 0 { + log::info!( + "[GEMINI-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件", + result.imported, + result.skipped, + result.files_scanned + ); + } + + Ok(result) +} + +/// 收集所有 Gemini 会话 JSON 文件 +fn collect_gemini_session_files(gemini_dir: &Path) -> Vec { + let mut files = Vec::new(); + + let tmp_dir = gemini_dir.join("tmp"); + if !tmp_dir.is_dir() { + return files; + } + + // 遍历 tmp//chats/session-*.json + let project_dirs = match fs::read_dir(&tmp_dir) { + Ok(entries) => entries, + Err(_) => return files, + }; + + for entry in project_dirs.flatten() { + let chats_dir = entry.path().join("chats"); + if !chats_dir.is_dir() { + continue; + } + + let chat_files = match fs::read_dir(&chats_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for file_entry in chat_files.flatten() { + let path = file_entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + files.push(path); + } + } + } + + files +} + +/// 同步单个 Gemini 会话 JSON 文件,返回 (imported, skipped) +fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> { + let file_path_str = file_path.to_string_lossy().to_string(); + + // 获取文件元数据 + let metadata = fs::metadata(file_path) + .map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?; + let file_modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + // 检查同步状态 + let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?; + + // 文件未变化则跳过 + if file_modified <= last_modified { + return Ok((0, 0)); + } + + // 读取并解析整个 JSON 文件 + let content = fs::read_to_string(file_path) + .map_err(|e| AppError::Config(format!("无法读取文件: {e}")))?; + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| AppError::Config(format!("JSON 解析失败: {e}")))?; + + // 提取顶层 sessionId + let session_id = value + .get("sessionId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // 遍历 messages 数组 + let messages = match value.get("messages").and_then(|v| v.as_array()) { + Some(msgs) => msgs, + None => return Ok((0, 0)), + }; + + let mut imported: u32 = 0; + let mut skipped: u32 = 0; + let mut gemini_msg_count: i64 = 0; + + for msg in messages { + // 只处理 type == "gemini" 的消息 + if msg.get("type").and_then(|t| t.as_str()) != Some("gemini") { + continue; + } + + // 提取 tokens 对象 + let tokens_obj = match msg.get("tokens") { + Some(t) if t.is_object() => t, + _ => continue, + }; + + let tokens = parse_gemini_tokens(tokens_obj); + if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 { + continue; // 跳过全零的空 token 消息 + } + + gemini_msg_count += 1; + + // 提取消息 ID 和模型 + let message_id = msg + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let model = msg + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let timestamp = msg.get("timestamp").and_then(|v| v.as_str()); + + // 生成唯一 request_id + let session_id_str = session_id.as_deref().unwrap_or("unknown"); + let request_id = format!("gemini_session:{session_id_str}:{message_id}"); + + match insert_gemini_session_entry( + db, + &request_id, + &tokens, + model, + session_id.as_deref(), + timestamp, + ) { + Ok(true) => imported += 1, + Ok(false) => skipped += 1, + Err(e) => { + log::warn!("[GEMINI-SYNC] 插入失败 ({}): {e}", request_id); + skipped += 1; + } + } + } + + // 更新同步状态 + update_sync_state(db, &file_path_str, file_modified, gemini_msg_count)?; + + Ok((imported, skipped)) +} + +/// 从 tokens JSON 对象中提取 token 数据 +fn parse_gemini_tokens(tokens: &serde_json::Value) -> GeminiTokens { + GeminiTokens { + input: tokens + .get("input") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + output: tokens + .get("output") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + cached: tokens + .get("cached") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + thoughts: tokens + .get("thoughts") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32, + } +} + +/// 插入单条 Gemini 会话记录到 proxy_request_logs +fn insert_gemini_session_entry( + db: &Database, + request_id: &str, + tokens: &GeminiTokens, + model: &str, + session_id: Option<&str>, + timestamp: Option<&str>, +) -> Result { + let conn = lock_conn!(db.conn); + + // 检查是否已存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", + rusqlite::params![request_id], + |row| row.get::<_, i64>(0).map(|c| c > 0), + ) + .unwrap_or(false); + + if exists { + return Ok(false); + } + + // 解析时间戳 + let created_at = timestamp + .and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp()) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + // 合并 thoughts 到 output(思考 token 按输出计费) + let output_tokens = tokens.output + tokens.thoughts; + + // 计算费用 + let usage = TokenUsage { + input_tokens: tokens.input, + output_tokens, + cache_read_tokens: tokens.cached, + cache_creation_tokens: 0, + model: Some(model.to_string()), + }; + + let pricing = find_gemini_pricing(&conn, model); + let multiplier = Decimal::from(1); + let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing + { + Some(p) => { + let cost = CostCalculator::calculate(&usage, &p, multiplier); + ( + cost.input_cost.to_string(), + cost.output_cost.to_string(), + cost.cache_read_cost.to_string(), + cost.cache_creation_cost.to_string(), + cost.total_cost.to_string(), + ) + } + None => ( + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + "0".to_string(), + ), + }; + + conn.execute( + "INSERT OR IGNORE INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, + latency_ms, first_token_ms, status_code, error_message, session_id, + provider_type, is_streaming, cost_multiplier, created_at, data_source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + rusqlite::params![ + request_id, + "_gemini_session", // provider_id + "gemini", // app_type + model, + model, // request_model = model + tokens.input, + output_tokens, + tokens.cached, + 0i64, // cache_creation_tokens + input_cost, + output_cost, + cache_read_cost, + cache_creation_cost, + total_cost, + 0i64, // latency_ms + Option::::None, // first_token_ms + 200i64, // status_code + Option::::None, // error_message + session_id.map(|s| s.to_string()), + Some("gemini_session"), // provider_type + 1i64, // is_streaming + "1.0", // cost_multiplier + created_at, + "gemini_session", // data_source + ], + ) + .map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?; + + Ok(true) +} + +/// 获取文件的同步状态 +fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> { + let conn = lock_conn!(db.conn); + let result = conn.query_row( + "SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1", + rusqlite::params![file_path], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ); + Ok(result.unwrap_or((0, 0))) +} + +/// 更新文件的同步状态 +fn update_sync_state( + db: &Database, + file_path: &str, + last_modified: i64, + last_offset: i64, +) -> Result<(), AppError> { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![file_path, last_modified, last_offset, now], + ) + .map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?; + Ok(()) +} + +/// 查找 Gemini 模型定价 +fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + // 精确匹配 + if let Some(pricing) = try_find_pricing(conn, model_id) { + return Some(pricing); + } + + // LIKE 模糊匹配(兜底) + let pattern = format!("{model_id}%"); + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1", + rusqlite::params![pattern], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +/// 精确匹配定价查询 +fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option { + conn.query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id = ?1", + rusqlite::params![model_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .ok() + .and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collect_gemini_session_files_nonexistent() { + let files = collect_gemini_session_files(Path::new("/nonexistent/path")); + assert!(files.is_empty()); + } + + #[test] + fn test_parse_gemini_tokens() { + let json: serde_json::Value = serde_json::json!({ + "input": 8522, + "output": 29, + "cached": 3138, + "thoughts": 405, + "tool": 0, + "total": 8956 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 8522); + assert_eq!(tokens.output, 29); + assert_eq!(tokens.cached, 3138); + assert_eq!(tokens.thoughts, 405); + // output + thoughts = 29 + 405 = 434(用于计费) + assert_eq!(tokens.output + tokens.thoughts, 434); + } + + #[test] + fn test_parse_gemini_tokens_missing_fields() { + // 缺少某些字段时应返回 0 + let json: serde_json::Value = serde_json::json!({ + "input": 100, + "output": 50 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 100); + assert_eq!(tokens.output, 50); + assert_eq!(tokens.cached, 0); + assert_eq!(tokens.thoughts, 0); + } + + #[test] + fn test_parse_gemini_tokens_all_zero() { + let json: serde_json::Value = serde_json::json!({ + "input": 0, + "output": 0, + "cached": 0, + "thoughts": 0, + "tool": 0, + "total": 0 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.input, 0); + assert_eq!(tokens.output, 0); + // 全零会被 sync 逻辑跳过 + } +} diff --git a/src/components/usage/DataSourceBar.tsx b/src/components/usage/DataSourceBar.tsx index bc40d6f6f..7dabc51a8 100644 --- a/src/components/usage/DataSourceBar.tsx +++ b/src/components/usage/DataSourceBar.tsx @@ -16,6 +16,7 @@ const DATA_SOURCE_ICONS: Record = { session_log: , codex_db: , codex_session: , + gemini_session: , }; export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ff0202aeb..1236959d5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1024,7 +1024,8 @@ "proxy": "Proxy", "session_log": "Session Log", "codex_db": "Codex DB", - "codex_session": "Codex Session" + "codex_session": "Codex Session", + "gemini_session": "Gemini Session" }, "sessionSync": { "trigger": "Sync session logs", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c4b2b1f39..5a3545c95 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1024,7 +1024,8 @@ "proxy": "プロキシ", "session_log": "セッションログ", "codex_db": "Codex DB", - "codex_session": "Codex セッション" + "codex_session": "Codex セッション", + "gemini_session": "Gemini セッション" }, "sessionSync": { "trigger": "セッションログを同期", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index fd39b3e0e..f6cd70a42 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1024,7 +1024,8 @@ "proxy": "代理", "session_log": "会话日志", "codex_db": "Codex 数据库", - "codex_session": "Codex 会话日志" + "codex_session": "Codex 会话日志", + "gemini_session": "Gemini 会话日志" }, "sessionSync": { "trigger": "同步会话日志", From c0bcd19d443b8c342eb62328ae0b87203d6b59ea Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 21:33:41 +0800 Subject: [PATCH 036/128] fix: correct Gemini session sync accuracy issues - Use UPSERT with WHERE guard instead of INSERT OR IGNORE, so updated token values on existing messages are properly synced without unnecessary rewrites of unchanged rows - Include cached tokens in the skip-zero filter to stop silently discarding pure cache-hit records - Restrict file collection to session-*.json to match documented scope and prevent ingesting non-session JSON files --- .../src/services/session_usage_gemini.rs | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/services/session_usage_gemini.rs b/src-tauri/src/services/session_usage_gemini.rs index 770994547..1b20c69fa 100644 --- a/src-tauri/src/services/session_usage_gemini.rs +++ b/src-tauri/src/services/session_usage_gemini.rs @@ -104,7 +104,12 @@ fn collect_gemini_session_files(gemini_dir: &Path) -> Vec { for file_entry in chat_files.flatten() { let path = file_entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("json") { + let is_session = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("session-") && n.ends_with(".json")) + .unwrap_or(false); + if is_session { files.push(path); } } @@ -170,7 +175,7 @@ fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32) }; let tokens = parse_gemini_tokens(tokens_obj); - if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 { + if tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0 { continue; // 跳过全零的空 token 消息 } @@ -247,19 +252,6 @@ fn insert_gemini_session_entry( ) -> Result { let conn = lock_conn!(db.conn); - // 检查是否已存在 - let exists: bool = conn - .query_row( - "SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1", - rusqlite::params![request_id], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .unwrap_or(false); - - if exists { - return Ok(false); - } - // 解析时间戳 let created_at = timestamp .and_then(|ts| { @@ -309,14 +301,29 @@ fn insert_gemini_session_entry( ), }; + // 使用 UPSERT:新记录插入,已存在记录更新 token 和费用(Gemini 全量重读可能携带更新值) conn.execute( - "INSERT OR IGNORE INTO proxy_request_logs ( + "INSERT INTO proxy_request_logs ( request_id, provider_id, app_type, model, request_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24) + ON CONFLICT(request_id) DO UPDATE SET + model = excluded.model, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_read_tokens = excluded.cache_read_tokens, + input_cost_usd = excluded.input_cost_usd, + output_cost_usd = excluded.output_cost_usd, + cache_read_cost_usd = excluded.cache_read_cost_usd, + cache_creation_cost_usd = excluded.cache_creation_cost_usd, + total_cost_usd = excluded.total_cost_usd + WHERE input_tokens != excluded.input_tokens + OR output_tokens != excluded.output_tokens + OR cache_read_tokens != excluded.cache_read_tokens + OR model != excluded.model", rusqlite::params![ request_id, "_gemini_session", // provider_id @@ -346,7 +353,8 @@ fn insert_gemini_session_entry( ) .map_err(|e| AppError::Database(format!("插入 Gemini 会话日志失败: {e}")))?; - Ok(true) + // changes() > 0 表示新插入或已更新,== 0 表示值完全相同(无实际变更) + Ok(conn.changes() > 0) } /// 获取文件的同步状态 @@ -485,6 +493,23 @@ mod tests { let tokens = parse_gemini_tokens(&json); assert_eq!(tokens.input, 0); assert_eq!(tokens.output, 0); - // 全零会被 sync 逻辑跳过 + // 全零(包括 cached=0)会被 sync 逻辑跳过 + assert!(tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0); + } + + #[test] + fn test_parse_gemini_tokens_cache_only_not_skipped() { + // 纯缓存命中消息(input/output/thoughts=0 但 cached>0)不应被跳过 + let json: serde_json::Value = serde_json::json!({ + "input": 0, + "output": 0, + "cached": 5000, + "thoughts": 0 + }); + let tokens = parse_gemini_tokens(&json); + assert_eq!(tokens.cached, 5000); + // 跳过条件:所有四个字段都为 0 才跳过 + let should_skip = tokens.input == 0 && tokens.output == 0 && tokens.thoughts == 0 && tokens.cached == 0; + assert!(!should_skip, "纯缓存命中记录不应被跳过"); } } From 687ffc237d8ec38fd9d76797bb2b8fd26e138fd2 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 6 Apr 2026 22:41:35 +0800 Subject: [PATCH 037/128] feat: add per-app usage filtering (Claude/Codex/Gemini) Add dashboard-level app type filter to usage statistics, replacing the DataSourceBar with a more useful segmented control. All components (summary cards, trend chart, provider stats, model stats, request logs) now respond to the selected app filter. Backend: add optional app_type parameter to get_usage_summary, get_daily_trends, get_provider_stats, and get_model_stats queries. Frontend: new AppTypeFilter type, updated query keys with appType dimension for proper cache separation, and RequestLogTable local filter auto-locks when dashboard filter is active. --- src-tauri/src/commands/usage.rs | 24 ++- src-tauri/src/services/usage_stats.rs | 200 +++++++++++++------- src/components/usage/ModelStatsTable.tsx | 8 +- src/components/usage/ProviderStatsTable.tsx | 4 +- src/components/usage/RequestLogTable.tsx | 21 +- src/components/usage/UsageDashboard.tsx | 78 +++++++- src/components/usage/UsageSummaryCards.tsx | 4 +- src/components/usage/UsageTrendChart.tsx | 4 +- src/i18n/locales/en.json | 8 + src/i18n/locales/ja.json | 8 + src/i18n/locales/zh.json | 8 + src/lib/api/usage.ts | 14 +- src/lib/query/usage.ts | 75 +++++--- src/types/usage.ts | 2 + 14 files changed, 337 insertions(+), 121 deletions(-) diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 9bf0bf08e..79a3280fa 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -11,8 +11,11 @@ pub fn get_usage_summary( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result { - state.db.get_usage_summary(start_date, end_date) + state + .db + .get_usage_summary(start_date, end_date, app_type.as_deref()) } /// 获取每日趋势 @@ -21,20 +24,29 @@ pub fn get_usage_trends( state: State<'_, AppState>, start_date: Option, end_date: Option, + app_type: Option, ) -> Result, AppError> { - state.db.get_daily_trends(start_date, end_date) + state + .db + .get_daily_trends(start_date, end_date, app_type.as_deref()) } /// 获取 Provider 统计 #[tauri::command] -pub fn get_provider_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_provider_stats() +pub fn get_provider_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_provider_stats(app_type.as_deref()) } /// 获取模型统计 #[tauri::command] -pub fn get_model_stats(state: State<'_, AppState>) -> Result, AppError> { - state.db.get_model_stats() +pub fn get_model_stats( + state: State<'_, AppState>, + app_type: Option, +) -> Result, AppError> { + state.db.get_model_stats(app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index d03eea482..6aaf3da42 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -123,44 +123,54 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result { let conn = lock_conn!(self.conn); - let (where_clause, params_vec) = if start_date.is_some() || end_date.is_some() { - let mut conditions = Vec::new(); - let mut params = Vec::new(); + // Build detail WHERE clause + let mut conditions = Vec::new(); + let mut params_vec: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("created_at >= ?"); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("created_at <= ?"); - params.push(end); - } + if let Some(start) = start_date { + conditions.push("created_at >= ?"); + params_vec.push(Box::new(start)); + } + if let Some(end) = end_date { + conditions.push("created_at <= ?"); + params_vec.push(Box::new(end)); + } + if let Some(at) = app_type { + conditions.push("app_type = ?"); + params_vec.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let where_clause = if conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", conditions.join(" AND ")) }; - // Build rollup WHERE clause using date strings (use ? for sequential binding) - let (rollup_where, rollup_params) = if start_date.is_some() || end_date.is_some() { - let mut conditions: Vec = Vec::new(); - let mut params = Vec::new(); + // Build rollup WHERE clause using date strings + let mut rollup_conditions: Vec = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); - if let Some(start) = start_date { - conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(start); - } - if let Some(end) = end_date { - conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); - params.push(end); - } + if let Some(start) = start_date { + rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(start)); + } + if let Some(end) = end_date { + rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); + rollup_params.push(Box::new(end)); + } + if let Some(at) = app_type { + rollup_conditions.push("app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } - (format!("WHERE {}", conditions.join(" AND ")), params) + let rollup_where = if rollup_conditions.is_empty() { + String::new() } else { - (String::new(), Vec::new()) + format!("WHERE {}", rollup_conditions.join(" AND ")) }; let sql = format!( @@ -194,10 +204,11 @@ impl Database { ); // Combine params: detail params first, then rollup params - let mut all_params: Vec = params_vec; + let mut all_params: Vec> = params_vec; all_params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = all_params.iter().map(|p| p.as_ref()).collect(); - let result = conn.query_row(&sql, rusqlite::params_from_iter(all_params), |row| { + let result = conn.query_row(&sql, param_refs.as_slice(), |row| { let total_requests: i64 = row.get(0)?; let total_cost: f64 = row.get(1)?; let total_input_tokens: i64 = row.get(2)?; @@ -231,6 +242,7 @@ impl Database { &self, start_date: Option, end_date: Option, + app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -262,9 +274,15 @@ impl Database { bucket_count = 1; } + let app_type_filter = if app_type.is_some() { + "AND app_type = ?4" + } else { + "" + }; + // Query detail logs - let sql = " - SELECT + let sql = format!( + "SELECT CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, COUNT(*) as request_count, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, @@ -274,12 +292,13 @@ impl Database { COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens FROM proxy_request_logs - WHERE created_at >= ?1 AND created_at <= ?2 + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, DailyStats { @@ -293,24 +312,33 @@ impl Database { total_cache_read_tokens: row.get::<_, i64>(7)? as u64, }, )) - })?; + }; let mut map: HashMap = HashMap::new(); - for row in rows { - let (mut bucket_idx, stat) = row?; - if bucket_idx < 0 { - continue; + + // Collect rows into map (need to handle both param variants) + { + let rows = if let Some(at) = app_type { + stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? + } else { + stmt.query_map(params![start_ts, end_ts, bucket_seconds], row_mapper)? + }; + for row in rows { + let (mut bucket_idx, stat) = row?; + if bucket_idx < 0 { + continue; + } + if bucket_idx >= bucket_count { + bucket_idx = bucket_count - 1; + } + map.insert(bucket_idx, stat); } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - map.insert(bucket_idx, stat); } // Also query rollup data (daily granularity, only useful for daily buckets) if bucket_seconds >= 86400 { - let rollup_sql = " - SELECT + let rollup_sql = format!( + "SELECT CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx, COALESCE(SUM(request_count), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), @@ -320,12 +348,12 @@ impl Database { COALESCE(SUM(cache_creation_tokens), 0), COALESCE(SUM(cache_read_tokens), 0) FROM usage_daily_rollups - WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') + WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter} GROUP BY bucket_idx - ORDER BY bucket_idx ASC"; + ORDER BY bucket_idx ASC" + ); - let mut rstmt = conn.prepare(rollup_sql)?; - let rrows = rstmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + let rollup_row_mapper = |row: &rusqlite::Row| { Ok(( row.get::<_, i64>(0)?, ( @@ -338,7 +366,14 @@ impl Database { row.get::<_, i64>(7)? as u64, ), )) - })?; + }; + + let mut rstmt = conn.prepare(&rollup_sql)?; + let rrows = if let Some(at) = app_type { + rstmt.query_map(params![start_ts, end_ts, bucket_seconds, at], rollup_row_mapper)? + } else { + rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)? + }; for row in rrows { let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; @@ -400,11 +435,21 @@ impl Database { } /// 获取 Provider 统计 - pub fn get_provider_stats(&self) -> Result, AppError> { + pub fn get_provider_stats( + &self, + app_type: Option<&str>, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); + let (detail_where, rollup_where) = if app_type.is_some() { + ("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") + } else { + ("", "") + }; + // UNION detail logs + rollup data, then aggregate - let sql = "SELECT + let sql = format!( + "SELECT provider_id, app_type, provider_name, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -423,6 +468,7 @@ impl Database { COALESCE(SUM(l.latency_ms), 0) as latency_sum FROM proxy_request_logs l LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type + {detail_where} GROUP BY l.provider_id, l.app_type UNION ALL SELECT r.provider_id, r.app_type, @@ -434,13 +480,15 @@ impl Database { COALESCE(SUM(r.avg_latency_ms * r.request_count), 0) FROM usage_daily_rollups r LEFT JOIN providers p2 ON r.provider_id = p2.id AND r.app_type = p2.app_type + {rollup_where} GROUP BY r.provider_id, r.app_type ) GROUP BY provider_id, app_type - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; let success_rate = if request_count > 0 { @@ -460,7 +508,13 @@ impl Database { success_rate, avg_latency_ms: row.get::<_, f64>(7)? as u64, }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -471,11 +525,18 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self) -> Result, AppError> { + pub fn get_model_stats(&self, app_type: Option<&str>) -> Result, AppError> { let conn = lock_conn!(self.conn); + let (detail_where, rollup_where) = if app_type.is_some() { + ("WHERE app_type = ?1", "WHERE app_type = ?2") + } else { + ("", "") + }; + // UNION detail logs + rollup data - let sql = "SELECT + let sql = format!( + "SELECT model, SUM(request_count) as request_count, SUM(total_tokens) as total_tokens, @@ -486,6 +547,7 @@ impl Database { COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost FROM proxy_request_logs + {detail_where} GROUP BY model UNION ALL SELECT model, @@ -493,13 +555,15 @@ impl Database { COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) FROM usage_daily_rollups + {rollup_where} GROUP BY model ) GROUP BY model - ORDER BY total_cost DESC"; + ORDER BY total_cost DESC" + ); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; let avg_cost = if request_count > 0 { @@ -515,7 +579,13 @@ impl Database { total_cost: format!("{total_cost:.6}"), avg_cost_per_request: format!("{avg_cost:.6}"), }) - })?; + }; + + let rows = if let Some(at) = app_type { + stmt.query_map(params![at, at], row_mapper)? + } else { + stmt.query_map([], row_mapper)? + }; let mut stats = Vec::new(); for row in rows { @@ -1044,7 +1114,7 @@ mod tests { )?; } - let summary = db.get_usage_summary(None, None)?; + let summary = db.get_usage_summary(None, None, None)?; assert_eq!(summary.total_requests, 2); assert_eq!(summary.success_rate, 100.0); @@ -1079,7 +1149,7 @@ mod tests { )?; } - let stats = db.get_model_stats()?; + let stats = db.get_model_stats(None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 20f83e628..9f629d70b 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -11,12 +11,16 @@ import { useModelStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ModelStatsTableProps { + appType?: string; refreshIntervalMs: number; } -export function ModelStatsTable({ refreshIntervalMs }: ModelStatsTableProps) { +export function ModelStatsTable({ + appType, + refreshIntervalMs, +}: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats({ + const { data: stats, isLoading } = useModelStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index e21e1f6c0..993a436ba 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -11,14 +11,16 @@ import { useProviderStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; interface ProviderStatsTableProps { + appType?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ + appType, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats({ + const { data: stats, isLoading } = useProviderStats(appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 5d6bd0c17..fe485ec20 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -29,6 +29,7 @@ import { } from "./format"; interface RequestLogTableProps { + appType?: string; refreshIntervalMs: number; } @@ -37,7 +38,10 @@ const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; type TimeMode = "rolling" | "fixed"; -export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { +export function RequestLogTable({ + appType: dashboardAppType, + refreshIntervalMs, +}: RequestLogTableProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); @@ -56,8 +60,14 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) { const pageSize = 20; const [validationError, setValidationError] = useState(null); + // When dashboard-level app filter is active (not "all"), override the local appType filter + const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; + const effectiveFilters: LogFilters = dashboardAppTypeActive + ? { ...appliedFilters, appType: dashboardAppType } + : appliedFilters; + const { data: result, isLoading } = useRequestLogs({ - filters: appliedFilters, + filters: effectiveFilters, timeMode: appliedTimeMode, rollingWindowSeconds: ONE_DAY_SECONDS, page, @@ -174,13 +184,18 @@ export function RequestLogTable({ refreshIntervalMs }: RequestLogTableProps) {
+ + + + + + + {t("codexOauth.useDefaultAccount", "使用默认账号")} + + + {accounts.map((account) => ( + +
+ + {account.login} +
+
+ ))} +
+ +
+ )} + + {/* 已登录账号列表 */} + {hasAnyAccount && ( +
+ +
+ {accounts.map((account) => ( +
+
+ + {account.login} + {defaultAccountId === account.id && ( + + {t("codexOauth.defaultAccount", "默认")} + + )} + {selectedAccountId === account.id && ( + + {t("codexOauth.selected", "已选中")} + + )} +
+
+ {defaultAccountId !== account.id && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* 未认证 - 登录按钮 */} + {!hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 已有账号 - 添加更多按钮 */} + {hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 轮询中状态 */} + {isPolling && deviceCode && ( +
+
+ + {t("codexOauth.waitingForAuth", "等待授权中...")} +
+ +
+

+ {t("codexOauth.enterCode", "在浏览器中输入以下代码:")} +

+
+ + {deviceCode.user_code} + + +
+
+ + + +
+ +
+
+ )} + + {/* 错误状态 */} + {pollingState === "error" && error && ( +
+

{error}

+
+ + +
+
+ )} + + {/* 注销所有账号 */} + {hasAnyAccount && accounts.length > 1 && ( + + )} +
+ ); +}; + +export default CodexOAuthSection; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index eac36a651..f5f5f6dbb 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -82,6 +82,7 @@ import { useOmoDraftState, useOpenclawFormState, useCopilotAuth, + useCodexOauth, } from "./hooks"; import { CLAUDE_DEFAULT_CONFIG, @@ -346,11 +347,19 @@ export function ProviderForm({ // Copilot OAuth 认证状态(仅 Claude 应用需要) const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth(); + // Codex OAuth 认证状态(ChatGPT Plus/Pro 反代) + const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth(); + // 选中的 GitHub 账号 ID(多账号支持) const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< string | null >(() => resolveManagedAccountId(initialData?.meta, "github_copilot")); + // 选中的 ChatGPT 账号 ID(Codex OAuth 多账号支持) + const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const { codexAuth, codexConfig, @@ -782,6 +791,9 @@ export function ProviderForm({ templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com"); + const isCodexOauthProvider = + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth"; // GitHub Copilot 必须先登录才能添加 if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( @@ -791,10 +803,19 @@ export function ProviderForm({ ); return; } + // Codex OAuth 必须先登录才能添加 + if (isCodexOauthProvider && !isCodexOauthAuthenticated) { + toast.error( + t("codexOauth.loginRequired", { + defaultValue: "请先登录 ChatGPT 账号", + }), + ); + return; + } if (category !== "official" && category !== "cloud_provider") { if (appId === "claude") { - if (!baseUrl.trim()) { + if (!isCodexOauthProvider && !baseUrl.trim()) { toast.error( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", @@ -802,7 +823,7 @@ export function ProviderForm({ ); return; } - if (!isCopilotProvider && !apiKey.trim()) { + if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) { toast.error( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -1015,7 +1036,7 @@ export function ProviderForm({ ? useGeminiCommonConfigFlag : undefined, endpointAutoSelect, - // 保存 providerType(用于识别 Copilot 等特殊供应商) + // 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商) providerType, authBinding: isCopilotProvider ? { @@ -1023,7 +1044,13 @@ export function ProviderForm({ authProvider: "github_copilot", accountId: selectedGitHubAccountId ?? undefined, } - : undefined, + : isCodexOauthProvider + ? { + source: "managed_account", + authProvider: "codex_oauth", + accountId: selectedCodexAccountId ?? undefined, + } + : undefined, // GitHub Copilot 多账号:保存关联的账号 ID githubAccountId: isCopilotProvider && selectedGitHubAccountId @@ -1493,15 +1520,24 @@ export function ProviderForm({ initialData?.meta?.providerType === "github_copilot" || baseUrl.includes("githubcopilot.com") } + isCodexOauthPreset={ + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" + } usesOAuth={ templatePreset?.requiresOAuth === true || templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || - baseUrl.includes("githubcopilot.com") + baseUrl.includes("githubcopilot.com") || + templatePreset?.providerType === "codex_oauth" || + initialData?.meta?.providerType === "codex_oauth" } isCopilotAuthenticated={isCopilotAuthenticated} selectedGitHubAccountId={selectedGitHubAccountId} onGitHubAccountSelect={setSelectedGitHubAccountId} + isCodexOauthAuthenticated={isCodexOauthAuthenticated} + selectedCodexAccountId={selectedCodexAccountId} + onCodexAccountSelect={setSelectedCodexAccountId} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index f39983aeb..ddee534c7 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -18,3 +18,4 @@ export { useOpencodeFormState } from "./useOpencodeFormState"; export { useOmoDraftState } from "./useOmoDraftState"; export { useOpenclawFormState } from "./useOpenclawFormState"; export { useCopilotAuth } from "./useCopilotAuth"; +export { useCodexOauth } from "./useCodexOauth"; diff --git a/src/components/providers/forms/hooks/useCodexOauth.ts b/src/components/providers/forms/hooks/useCodexOauth.ts new file mode 100644 index 000000000..84530edf6 --- /dev/null +++ b/src/components/providers/forms/hooks/useCodexOauth.ts @@ -0,0 +1,10 @@ +import { useManagedAuth } from "./useManagedAuth"; + +/** + * Codex OAuth (ChatGPT Plus/Pro) 认证 hook + * + * 复用通用 useManagedAuth,仅指定 provider 为 "codex_oauth" + */ +export function useCodexOauth() { + return useManagedAuth("codex_oauth"); +} diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx index ee06b0bc7..e98bed41a 100644 --- a/src/components/settings/AuthCenterPanel.tsx +++ b/src/components/settings/AuthCenterPanel.tsx @@ -1,7 +1,8 @@ -import { Github, ShieldCheck } from "lucide-react"; +import { Github, ShieldCheck, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection"; +import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection"; export function AuthCenterPanel() { const { t } = useTranslation(); @@ -50,6 +51,25 @@ export function AuthCenterPanel() { + +
+
+
+ +
+
+

ChatGPT (Codex OAuth)

+

+ {t("settings.authCenter.codexOauthDescription", { + defaultValue: + "管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。", + })} +

+
+
+ + +
); } diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 2ed5dcd88..a29d00635 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -53,7 +53,8 @@ export interface ProviderPreset { // 供应商类型标识(用于特殊供应商检测) // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) - providerType?: "github_copilot"; + // - "codex_oauth": OpenAI Codex via ChatGPT Plus/Pro 反代(需要 OAuth 认证) + providerType?: "github_copilot" | "codex_oauth"; // 是否需要 OAuth 认证(而非 API Key) requiresOAuth?: boolean; @@ -708,6 +709,27 @@ export const providerPresets: ProviderPreset[] = [ icon: "github", iconColor: "#000000", }, + { + name: "Codex (ChatGPT Plus/Pro)", + websiteUrl: "https://openai.com/chatgpt/pricing", + settingsConfig: { + env: { + // base_url 由代理后端强制重写为 chatgpt.com/backend-api/codex + // 用户无需配置 + ANTHROPIC_BASE_URL: "https://chatgpt.com/backend-api/codex", + ANTHROPIC_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "gpt-5.1-codex-mini", + ANTHROPIC_DEFAULT_SONNET_MODEL: "gpt-5.1-codex", + ANTHROPIC_DEFAULT_OPUS_MODEL: "gpt-5.1-codex-max", + }, + }, + category: "third_party", + apiFormat: "openai_responses", + providerType: "codex_oauth", + requiresOAuth: true, + icon: "openai", + iconColor: "#000000", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dc6eb02c5..6d9f1e5a9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -845,6 +845,27 @@ "migrationFailed": "Legacy auth migration failed: {{error}}", "loadModelsFailed": "Failed to load Copilot models" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro Auth", + "notAuthenticated": "Not authenticated", + "loginWithChatGPT": "Sign in with ChatGPT", + "loginRequired": "Please sign in to ChatGPT first", + "waitingForAuth": "Waiting for authorization...", + "enterCode": "Enter the code in your browser:", + "accountCount": "{{count}} account(s)", + "selectAccount": "Select account", + "selectAccountPlaceholder": "Choose a ChatGPT account", + "useDefaultAccount": "Use default account", + "loggedInAccounts": "Logged in accounts", + "defaultAccount": "Default", + "selected": "Selected", + "removeAccount": "Remove account", + "setAsDefault": "Set as default", + "addAnotherAccount": "Add another account", + "logoutAll": "Logout all accounts", + "retry": "Retry", + "copyCode": "Copy code" + }, "endpointTest": { "title": "API Endpoint Management", "endpoints": "endpoints", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 47b08db43..853b466d0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -845,6 +845,27 @@ "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 認証", + "notAuthenticated": "未認証", + "loginWithChatGPT": "ChatGPT でログイン", + "loginRequired": "先に ChatGPT にログインしてください", + "waitingForAuth": "認証を待機中...", + "enterCode": "ブラウザで以下のコードを入力してください:", + "accountCount": "{{count}} アカウント", + "selectAccount": "アカウントを選択", + "selectAccountPlaceholder": "ChatGPT アカウントを選択", + "useDefaultAccount": "デフォルトアカウントを使用", + "loggedInAccounts": "ログイン済みアカウント", + "defaultAccount": "デフォルト", + "selected": "選択中", + "removeAccount": "アカウントを削除", + "setAsDefault": "デフォルトに設定", + "addAnotherAccount": "別のアカウントを追加", + "logoutAll": "すべてのアカウントをログアウト", + "retry": "再試行", + "copyCode": "コードをコピー" + }, "endpointTest": { "title": "API エンドポイント管理", "endpoints": "エンドポイント", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aaa944ab9..7e02c19d6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -845,6 +845,27 @@ "migrationFailed": "旧认证数据迁移失败:{{error}}", "loadModelsFailed": "加载 Copilot 模型列表失败" }, + "codexOauth": { + "authStatus": "ChatGPT Plus/Pro 认证", + "notAuthenticated": "未认证", + "loginWithChatGPT": "使用 ChatGPT 登录", + "loginRequired": "请先登录 ChatGPT 账号", + "waitingForAuth": "等待授权中...", + "enterCode": "请在浏览器中输入以下验证码:", + "accountCount": "{{count}} 个账号", + "selectAccount": "选择账号", + "selectAccountPlaceholder": "选择一个 ChatGPT 账号", + "useDefaultAccount": "使用默认账号", + "loggedInAccounts": "已登录账号", + "defaultAccount": "默认", + "selected": "已选中", + "removeAccount": "移除账号", + "setAsDefault": "设为默认", + "addAnotherAccount": "添加其他账号", + "logoutAll": "注销所有账号", + "retry": "重试", + "copyCode": "复制代码" + }, "endpointTest": { "title": "请求地址管理", "endpoints": "个端点", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 5e6ecde00..a4d840ffa 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; -export type ManagedAuthProvider = "github_copilot"; +export type ManagedAuthProvider = "github_copilot" | "codex_oauth"; export interface ManagedAuthAccount { id: string; From d164191bd1e13448454f7d7f08675119a6649810 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 12:11:05 +0800 Subject: [PATCH 042/128] feat: display subscription quota for Codex OAuth provider cards Codex OAuth (ChatGPT Plus/Pro) providers previously fell through to the default UsageFooter branch and showed no quota at all, while Copilot and official Codex providers already had a wham/usage-backed quota footer. This wires up the same five-hour / seven-day tier badges for codex_oauth provider cards by reusing the existing query_codex_quota function and SubscriptionQuotaFooter rendering, parameterized to keep both the CLI credential path ("codex") and the cc-switch managed OAuth path ("codex_oauth") working from a single source of truth. - Parameterize services::subscription::query_codex_quota with tool_label and expired_message; promote SubscriptionQuota constructors to pub(crate). The CLI path keeps its existing "codex" label and the "re-login with Codex CLI" message; the new path passes "codex_oauth" and a cc-switch-specific re-login hint. - Add a new get_codex_oauth_quota Tauri command in commands/codex_oauth.rs that resolves the ChatGPT account (explicit binding > default account > not_found), pulls a valid access_token from CodexOAuthManager (auto-refresh handled), and delegates to query_codex_quota. - Extract SubscriptionQuotaFooter's render body into a pure SubscriptionQuotaView component (props: quota / loading / refetch / appIdForExpiredHint / inline). The existing SubscriptionQuotaFooter becomes a thin wrapper with identical props and behavior, so CopilotQuotaFooter and the official Claude/Codex/Gemini paths are untouched. This avoids duplicating ~280 lines of five-state rendering. - Add CodexOauthQuotaFooter, a 38-line wrapper that calls the new useCodexOauthQuota hook and forwards to SubscriptionQuotaView. - ProviderCard inserts an isCodexOauth branch between isCopilot and isOfficial, keyed off PROVIDER_TYPES.CODEX_OAUTH (newly added to config/constants.ts to centralize the previously scattered string). - Frontend hook caches per (codex_oauth, accountId) so multiple cards bound to the same ChatGPT account share one fetch via react-query dedup; cards bound to different accounts get independent fetches. - No new i18n keys: existing subscription.fiveHour / sevenDay / expired / refresh / queryFailed / expiredHint are reused. --- src-tauri/src/commands/codex_oauth.rs | 49 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/subscription.rs | 45 ++++++++++++----- src/components/CodexOauthQuotaFooter.tsx | 38 ++++++++++++++ src/components/SubscriptionQuotaFooter.tsx | 58 ++++++++++++++++++---- src/components/providers/ProviderCard.tsx | 5 ++ src/config/constants.ts | 1 + src/lib/api/subscription.ts | 2 + src/lib/query/subscription.ts | 28 +++++++++++ 9 files changed, 204 insertions(+), 23 deletions(-) create mode 100644 src/components/CodexOauthQuotaFooter.tsx diff --git a/src-tauri/src/commands/codex_oauth.rs b/src-tauri/src/commands/codex_oauth.rs index 040de9140..7e83e0dc7 100644 --- a/src-tauri/src/commands/codex_oauth.rs +++ b/src-tauri/src/commands/codex_oauth.rs @@ -2,12 +2,57 @@ //! //! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。 //! -//! 注意:实际的命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, -//! 此处仅定义 State wrapper。 +//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端, +//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。 use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager; +use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota}; use std::sync::Arc; +use tauri::State; use tokio::sync::RwLock; /// Codex OAuth 认证状态 pub struct CodexOAuthState(pub Arc>); + +/// 查询 Codex OAuth (ChatGPT Plus/Pro) 订阅额度 +/// +/// - `account_id` 未指定时回退到 `CodexOAuthManager` 的默认账号 +/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染 +/// - 复用 `services::subscription::query_codex_quota`,因此 wham/usage 端点协议 +/// 与 Codex CLI 路径完全一致 +#[tauri::command(rename_all = "camelCase")] +pub async fn get_codex_oauth_quota( + account_id: Option, + state: State<'_, CodexOAuthState>, +) -> Result { + let manager = state.0.read().await; + + // 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found) + let resolved = match account_id { + Some(id) => Some(id), + None => manager.default_account_id().await, + }; + let Some(id) = resolved else { + return Ok(SubscriptionQuota::not_found("codex_oauth")); + }; + + // 获取(必要时自动刷新)access_token + let token = match manager.get_valid_token_for_account(&id).await { + Ok(t) => t, + Err(e) => { + return Ok(SubscriptionQuota::error( + "codex_oauth", + CredentialStatus::Expired, + format!("Codex OAuth token unavailable: {e}"), + )); + } + }; + + Ok(query_codex_quota( + &token, + Some(&id), + "codex_oauth", + "Codex OAuth access token expired or rejected. Please re-login via cc-switch.", + ) + .await) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62444ba62..34673168c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -963,6 +963,7 @@ pub fn run() { commands::testUsageScript, // subscription quota commands::get_subscription_quota, + commands::get_codex_oauth_quota, commands::get_coding_plan_quota, commands::get_balance, // New MCP via config.json (SSOT) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5c2d4b4da..ee4a0b115 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -60,7 +60,7 @@ pub struct SubscriptionQuota { } impl SubscriptionQuota { - fn not_found(tool: &str) -> Self { + pub(crate) fn not_found(tool: &str) -> Self { Self { tool: tool.to_string(), credential_status: CredentialStatus::NotFound, @@ -73,7 +73,7 @@ impl SubscriptionQuota { } } - fn error(tool: &str, status: CredentialStatus, message: String) -> Self { + pub(crate) fn error(tool: &str, status: CredentialStatus, message: String) -> Self { Self { tool: tool.to_string(), credential_status: status, @@ -621,8 +621,17 @@ fn unix_ts_to_iso(ts: i64) -> Option { chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.to_rfc3339()) } -/// 查询 Codex 官方订阅额度 -async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> SubscriptionQuota { +/// 查询 Codex / ChatGPT 反代订阅额度 +/// +/// 参数化 `tool_label` 和 `expired_message` 让该函数可被两个调用点共用: +/// - `"codex"` + "Please re-login with Codex CLI."(CLI 凭据路径) +/// - `"codex_oauth"` + "Please re-login via cc-switch."(cc-switch 自管 OAuth 路径) +pub(crate) async fn query_codex_quota( + access_token: &str, + account_id: Option<&str>, + tool_label: &str, + expired_message: &str, +) -> SubscriptionQuota { let client = crate::proxy::http_client::get(); let mut req = client @@ -639,7 +648,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(r) => r, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Network error: {e}"), ); @@ -650,16 +659,16 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Expired, - format!("Authentication failed (HTTP {status}). Please re-login with Codex CLI."), + format!("{expired_message} (HTTP {status})"), ); } if !status.is_success() { let body = resp.text().await.unwrap_or_default(); return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("API error (HTTP {status}): {body}"), ); @@ -669,7 +678,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs Ok(v) => v, Err(e) => { return SubscriptionQuota::error( - "codex", + tool_label, CredentialStatus::Valid, format!("Failed to parse API response: {e}"), ); @@ -697,7 +706,7 @@ async fn query_codex_quota(access_token: &str, account_id: Option<&str>) -> Subs } SubscriptionQuota { - tool: "codex".to_string(), + tool: tool_label.to_string(), credential_status: CredentialStatus::Valid, credential_message: None, success: true, @@ -1221,7 +1230,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { // 即使可能过期也尝试调用 API if let Some(token) = token { - let result = query_codex_quota(&token, account_id.as_deref()).await; + let result = query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await; if result.success { return Ok(result); } @@ -1234,7 +1249,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result { let token = token.expect("token must be Some when status is Valid"); - Ok(query_codex_quota(&token, account_id.as_deref()).await) + Ok(query_codex_quota( + &token, + account_id.as_deref(), + "codex", + "Authentication failed. Please re-login with Codex CLI.", + ) + .await) } } } diff --git a/src/components/CodexOauthQuotaFooter.tsx b/src/components/CodexOauthQuotaFooter.tsx new file mode 100644 index 000000000..be9121b58 --- /dev/null +++ b/src/components/CodexOauthQuotaFooter.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import type { ProviderMeta } from "@/types"; +import { useCodexOauthQuota } from "@/lib/query/subscription"; +import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter"; + +interface CodexOauthQuotaFooterProps { + meta?: ProviderMeta; + inline?: boolean; +} + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度 footer + * + * 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。 + * 数据源切换为 cc-switch 自管的 OAuth token 而非 Codex CLI 凭据。 + */ +const CodexOauthQuotaFooter: React.FC = ({ + meta, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useCodexOauthQuota(meta, true); + + return ( + + ); +}; + +export default CodexOauthQuotaFooter; diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 4226544e3..e345b58f9 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -3,13 +3,22 @@ import { RefreshCw, AlertCircle, Clock } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { AppId } from "@/lib/api"; import { useSubscriptionQuota } from "@/lib/query/subscription"; -import type { QuotaTier } from "@/types/subscription"; +import type { QuotaTier, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; } +interface SubscriptionQuotaViewProps { + quota: SubscriptionQuota | undefined; + loading: boolean; + refetch: () => void; + /** 用于 `subscription.expiredHint` 的 {tool} 插值;解耦了 hook 的 appId */ + appIdForExpiredHint: string; + inline?: boolean; +} + /** 已知 tier 名称的显示映射(官方订阅 + Token Plan 共用) */ export const TIER_I18N_KEYS: Record = { five_hour: "subscription.fiveHour", @@ -78,16 +87,22 @@ function formatRelativeTime( return t("usage.daysAgo", { count: Math.floor(diff / 86400) }); } -const SubscriptionQuotaFooter: React.FC = ({ - appId, +/** + * 纯展示组件:渲染 SubscriptionQuota 的 5 种状态(not_found / parse_error / + * expired / API 失败 / 成功),支持 inline / expanded 两种布局。 + * + * 数据源由调用方 hook 注入,方便不同的额度后端复用同一套渲染逻辑: + * - `SubscriptionQuotaFooter`(CLI 凭据路径,by appId) + * - `CodexOauthQuotaFooter`(cc-switch 自管 OAuth 路径,by ChatGPT account) + */ +export const SubscriptionQuotaView: React.FC = ({ + quota, + loading, + refetch, + appIdForExpiredHint, inline = false, }) => { const { t } = useTranslation(); - const { - data: quota, - isFetching: loading, - refetch, - } = useSubscriptionQuota(appId, true); // 定期更新相对时间显示 const [now, setNow] = React.useState(Date.now()); @@ -131,7 +146,7 @@ const SubscriptionQuotaFooter: React.FC = ({
{t("subscription.expired")} - {t("subscription.expiredHint", { tool: appId })} + {t("subscription.expiredHint", { tool: appIdForExpiredHint })}
@@ -364,4 +379,29 @@ const TierBar: React.FC<{ ); }; +/** + * CLI 凭据路径下的薄 wrapper:通过 useSubscriptionQuota(appId) 自取数据 + * 后转发到 SubscriptionQuotaView。对外 props/行为与重构前完全一致。 + */ +const SubscriptionQuotaFooter: React.FC = ({ + appId, + inline = false, +}) => { + const { + data: quota, + isFetching: loading, + refetch, + } = useSubscriptionQuota(appId, true); + + return ( + + ); +}; + export default SubscriptionQuotaFooter; diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 52f662a92..24166b00f 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon"; import UsageFooter from "@/components/UsageFooter"; import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter"; import CopilotQuotaFooter from "@/components/CopilotQuotaFooter"; +import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter"; import { PROVIDER_TYPES } from "@/config/constants"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge"; @@ -177,6 +178,8 @@ export function ProviderCard({ const isCopilot = provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT || provider.meta?.usage_script?.templateType === "github_copilot"; + const isCodexOauth = + provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw):使用 isInConfig 代替 isCurrent @@ -355,6 +358,8 @@ export function ProviderCard({
{isCopilot ? ( + ) : isCodexOauth ? ( + ) : isOfficial ? ( ) : hasMultiplePlans ? ( diff --git a/src/config/constants.ts b/src/config/constants.ts index 158d983e7..d6a3aab8d 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,6 +1,7 @@ // Provider 类型常量 export const PROVIDER_TYPES = { GITHUB_COPILOT: "github_copilot", + CODEX_OAUTH: "codex_oauth", } as const; // 用量脚本模板类型常量 diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 79f29eb19..d11000ffa 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -4,6 +4,8 @@ import type { SubscriptionQuota } from "@/types/subscription"; export const subscriptionApi = { getQuota: (tool: string): Promise => invoke("get_subscription_quota", { tool }), + getCodexOauthQuota: (accountId: string | null): Promise => + invoke("get_codex_oauth_quota", { accountId }), getCodingPlanQuota: ( baseUrl: string, apiKey: string, diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index c70545b77..8353fcca0 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -1,6 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { subscriptionApi } from "@/lib/api/subscription"; import type { AppId } from "@/lib/api/types"; +import type { ProviderMeta } from "@/types"; +import { resolveManagedAccountId } from "@/lib/authBinding"; +import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes @@ -15,3 +18,28 @@ export function useSubscriptionQuota(appId: AppId, enabled: boolean) { retry: 1, }); } + +/** + * Codex OAuth (ChatGPT Plus/Pro 反代) 订阅额度查询 hook + * + * 与 `useSubscriptionQuota` 平行:数据走 cc-switch 自管的 OAuth token, + * 而不是 Codex CLI 的 ~/.codex/auth.json。 + * + * Query key 包含 accountId,多张卡片绑定到同一账号时会自动去重共享请求。 + * accountId 为 null 时使用 "default" 占位,让后端 fallback 到默认账号。 + */ +export function useCodexOauthQuota( + meta: ProviderMeta | undefined, + enabled: boolean, +) { + const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH); + return useQuery({ + queryKey: ["codex_oauth", "quota", accountId ?? "default"], + queryFn: () => subscriptionApi.getCodexOauthQuota(accountId), + enabled, + refetchInterval: REFETCH_INTERVAL, + refetchOnWindowFocus: true, + staleTime: REFETCH_INTERVAL, + retry: 1, + }); +} From 5288238694cfe944962ef2b9d3c292bbc86f84dc Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 16:38:49 +0800 Subject: [PATCH 043/128] refactor: tighten OAuth Auth Center copy, layout, and icon - Trim Auth Center section descriptions to focus on user intent - Remove duplicate outer heading on the auth settings tab - Swap Sparkles glyph for CodexIcon on the ChatGPT card - Generalize codexOauth.authStatus to a neutral "Auth status" - Register settings.authCenter.* keys across zh/en/ja locales --- .../providers/forms/CodexOAuthSection.tsx | 2 +- src/components/settings/AuthCenterPanel.tsx | 13 ++++++------- src/components/settings/SettingsPage.tsx | 18 ------------------ src/i18n/locales/en.json | 9 ++++++++- src/i18n/locales/ja.json | 9 ++++++++- src/i18n/locales/zh.json | 9 ++++++++- 6 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/components/providers/forms/CodexOAuthSection.tsx b/src/components/providers/forms/CodexOAuthSection.tsx index a45bd2186..8f66b4dca 100644 --- a/src/components/providers/forms/CodexOAuthSection.tsx +++ b/src/components/providers/forms/CodexOAuthSection.tsx @@ -89,7 +89,7 @@ export const CodexOAuthSection: React.FC = ({
{/* 认证状态标题 */}
- + {t("settings.authCenter.description", { defaultValue: - "集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。", + "在 Claude Code 中使用您的其他订阅,请注意合规风险。", })}

@@ -42,8 +43,7 @@ export function AuthCenterPanel() {

GitHub Copilot

{t("settings.authCenter.copilotDescription", { - defaultValue: - "管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。", + defaultValue: "管理 GitHub Copilot 账号", })}

@@ -55,14 +55,13 @@ export function AuthCenterPanel() {
- +

ChatGPT (Codex OAuth)

{t("settings.authCenter.codexOauthDescription", { - defaultValue: - "管理 ChatGPT Plus/Pro 账号,用于将 Claude Code 请求反代到 Codex 后端。仅限个人开发使用。", + defaultValue: "管理 ChatGPT 账号", })}

diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5acd350dd..d10e57f30 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,7 +9,6 @@ import { ScrollText, HardDriveDownload, FlaskConical, - KeyRound, } from "lucide-react"; import { toast } from "sonner"; import { @@ -272,23 +271,6 @@ export function SettingsPage({ transition={{ duration: 0.3 }} className="space-y-6" > -
- -
-

- {t("settings.authCenter.heading", { - defaultValue: "认证中心", - })} -

-

- {t("settings.authCenter.headingDescription", { - defaultValue: - "统一管理可跨应用复用的 OAuth 账号和默认认证来源。", - })} -

-
-
- diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6d9f1e5a9..f2475be26 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -225,6 +225,13 @@ "tabGeneral": "General", "tabAdvanced": "Advanced", "tabProxy": "Proxy", + "authCenter": { + "title": "OAuth Authentication Center", + "description": "Use your other subscriptions in Claude Code — please be mindful of compliance risks.", + "beta": "Beta", + "copilotDescription": "Manage GitHub Copilot accounts", + "codexOauthDescription": "Manage ChatGPT accounts" + }, "advanced": { "configDir": { "title": "Configuration Directory", @@ -846,7 +853,7 @@ "loadModelsFailed": "Failed to load Copilot models" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro Auth", + "authStatus": "Auth status", "notAuthenticated": "Not authenticated", "loginWithChatGPT": "Sign in with ChatGPT", "loginRequired": "Please sign in to ChatGPT first", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 853b466d0..3aa2f7006 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -225,6 +225,13 @@ "tabGeneral": "一般", "tabAdvanced": "詳細", "tabProxy": "プロキシ", + "authCenter": { + "title": "OAuth 認証センター", + "description": "Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。", + "beta": "Beta", + "copilotDescription": "GitHub Copilot アカウントを管理します", + "codexOauthDescription": "ChatGPT アカウントを管理します" + }, "advanced": { "configDir": { "title": "設定ディレクトリ", @@ -846,7 +853,7 @@ "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro 認証", + "authStatus": "認証状態", "notAuthenticated": "未認証", "loginWithChatGPT": "ChatGPT でログイン", "loginRequired": "先に ChatGPT にログインしてください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7e02c19d6..7148b5bde 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -225,6 +225,13 @@ "tabGeneral": "通用", "tabAdvanced": "高级", "tabProxy": "代理", + "authCenter": { + "title": "OAuth 认证中心", + "description": "在 Claude Code 中使用您的其他订阅,请注意合规风险。", + "beta": "Beta", + "copilotDescription": "管理 GitHub Copilot 账号", + "codexOauthDescription": "管理 ChatGPT 账号" + }, "advanced": { "configDir": { "title": "配置文件目录", @@ -846,7 +853,7 @@ "loadModelsFailed": "加载 Copilot 模型列表失败" }, "codexOauth": { - "authStatus": "ChatGPT Plus/Pro 认证", + "authStatus": "认证状态", "notAuthenticated": "未认证", "loginWithChatGPT": "使用 ChatGPT 登录", "loginRequired": "请先登录 ChatGPT 账号", From 3a164622616533e02f98c86ff4bcbfa2c1cafe74 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 16:43:45 +0800 Subject: [PATCH 044/128] i18n(zh): unify Skills terminology in settings labels Use "Skills" consistently in skillStorage title/description and skillSync title to match the upstream Agent Skills wording and the existing English label style used elsewhere on the settings page. --- src/i18n/locales/zh.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7148b5bde..be42ae4e7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -486,8 +486,8 @@ "opencodeDesc": "OpenCode CLI" }, "skillStorage": { - "title": "技能存储位置", - "description": "选择 CC Switch 存放技能主副本的目录", + "title": "Skills 存储位置", + "description": "选择 CC Switch 存放 Skills 主副本的目录", "ccSwitch": "CC Switch", "unified": "~/.agents/skills", "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", @@ -498,7 +498,7 @@ "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" }, "skillSync": { - "title": "Skill 同步方式", + "title": "Skills 同步方式", "description": "选择 Skills 的文件同步策略", "symlink": "软连接", "copy": "文件复制", From 64c068415e9e4a2be6aac6979d9f026f0ed089ae Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Apr 2026 17:53:20 +0800 Subject: [PATCH 045/128] fix(linux): repair unresponsive UI on startup and full-screen panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux users reported the window UI (including native title bar buttons) couldn't receive clicks until manually maximizing and restoring the window. Root causes: (1) Tauri webview did not acquire focus on startup so first clicks were consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface input region failed to renegotiate on the visible:false + show() path under some WebKitGTK/compositor combinations. - Add linux_fix::nudge_main_window helper that performs set_focus plus a ±1px no-op resize after window show, with a 500ms reconciliation readback to compensate for dropped resize requests on slow compositors. - Wire the helper into every window re-show path: normal startup, deeplink, single_instance, tray show_main, and lightweight exit. - Set WEBKIT_DISABLE_COMPOSITING_MODE=1 at startup to avoid resize crashes and Wayland surface negotiation issues. - Remove data-tauri-drag-region on Linux from App.tsx header and the shared FullScreenPanel (used by all provider/MCP/workspace forms) to avoid Tauri #13440 in Wayland sessions. Extract drag-region constants to src/lib/platform.ts for reuse. All Rust changes are gated by #[cfg(target_os = "linux")]; frontend changes preserve macOS/Windows behavior via runtime isLinux() checks. Known limitation: tiling Wayland compositors ignore set_size, so GDK_BACKEND=x11 remains the user-side workaround. --- CHANGELOG.md | 5 + src-tauri/src/lib.rs | 18 ++++ src-tauri/src/lightweight.rs | 8 ++ src-tauri/src/linux_fix.rs | 121 ++++++++++++++++++++++ src-tauri/src/main.rs | 6 ++ src-tauri/src/tray.rs | 4 + src/App.tsx | 27 +++-- src/components/common/FullScreenPanel.tsx | 38 ++++--- src/lib/platform.ts | 17 +++ 9 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/linux_fix.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ac0c336..62c98b0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit). +- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved. + --- ## [3.12.3] - 2026-03-24 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 34673168c..393c88346 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod lightweight; +#[cfg(target_os = "linux")] +mod linux_fix; mod mcp; mod openclaw_config; mod opencode_config; @@ -132,6 +134,10 @@ fn handle_deeplink_url( let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } log::info!("✓ Window shown and focused"); } } @@ -229,6 +235,10 @@ pub fn run() { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } })); } @@ -899,6 +909,14 @@ pub fn run() { // 正常启动模式:显示窗口 let _ = window.show(); log::info!("正常启动模式:主窗口已显示"); + + // Linux: 解决首次启动 UI 无响应问题(Tauri #10746 + wry #637)。 + // 启动时 webview 未获取焦点 + surface 尺寸协商失败,导致点击无效。 + // 这里做 set_focus + 伪 resize,等价于无视觉版本的"最大化-还原"。 + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } } diff --git a/src-tauri/src/lightweight.rs b/src-tauri/src/lightweight.rs index c16af79ef..78e5c9174 100644 --- a/src-tauri/src/lightweight.rs +++ b/src-tauri/src/lightweight.rs @@ -36,6 +36,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "windows")] { let _ = window.set_skip_taskbar(false); @@ -66,6 +70,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/linux_fix.rs b/src-tauri/src/linux_fix.rs new file mode 100644 index 000000000..35bace1a3 --- /dev/null +++ b/src-tauri/src/linux_fix.rs @@ -0,0 +1,121 @@ +//! Linux 专用的主窗口恢复补丁。 +//! +//! 解决 Tauri 2.x 在部分 Linux 发行版(尤其是 Wayland / 某些 WebKitGTK +//! 版本)上启动后 UI 无法响应点击的问题: +//! +//! - **失效模式 A**(Tauri #10746 / wry #637):webview 在 `show()` 后 +//! 没有获得 keyboard focus,导致首次点击被 X11/Wayland 用作 +//! click-to-activate 而非传给 webview。 +//! - **失效模式 B**:GTK surface 与 WebKitWebView 的 input region 尺寸 +//! 协商在 `visible:false` → `show()` 的路径上失败,整窗永远不响应 +//! 点击,只有重新 `size_allocate`(例如最大化-还原)才能恢复。 +//! +//! 本模块导出 [`nudge_main_window`],它通过「显式 set_focus + 无视觉 +//! 版本的 ±1px 伪 resize」精确模拟用户手动最大化再还原的 workaround, +//! 但肉眼无法察觉。所有"让主窗口出现在用户面前"的路径(正常启动、 +//! deeplink 唤起、single_instance 回调、托盘 show_main、lightweight +//! 退出)都应在现有 `set_focus()` 之后追加一次调用。 + +use std::time::Duration; + +use tauri::{PhysicalSize, WebviewWindow}; + +/// 在 webview realize 之后的延迟,等 GTK 主循环把 realize 事件处理完。 +/// 200ms 是社区经验值;太短 set_focus 仍会无效,太长会让首屏可交互 +/// 时间被肉眼感知到。 +const REALIZE_WAIT: Duration = Duration::from_millis(200); + +/// ±1px 伪 resize 两步之间的间隔,确保 GTK 先处理了第一次 +/// `size_allocate` 再收到第二次 resize。放宽到 100ms 是因为 Tao 在 Linux +/// 上的尺寸 API 是异步的(底层走 `gtk_window_resize` → 合成器 configure), +/// 太短会让合成器把两次连续 resize coalesce 成一次。 +const RESIZE_GAP: Duration = Duration::from_millis(100); + +/// 尺寸对账回读前的额外等待。200ms + 100ms + 500ms = 总共 ~800ms 后 +/// 校验窗口尺寸是否回到 original。这个时间足够所有合成器处理完 +/// resize 消息队列。 +const RECONCILE_WAIT: Duration = Duration::from_millis(500); + +/// 对主窗口执行 Linux 专用的「focus + surface 重激活」序列。 +/// +/// 调用是 fire-and-forget:内部 spawn 一个异步任务在 ~250ms 后完成。 +/// 调用线程立即返回,不阻塞 UI。 +pub(crate) fn nudge_main_window(window: WebviewWindow) { + // 第一次 set_focus:webview 可能还没 realize,这一次通常是无效的, + // 但成本极低(线程安全,内部 run_on_main_thread),顺手做掉。 + let _ = window.set_focus(); + + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REALIZE_WAIT).await; + + // 第二次 set_focus:此时 webview realize 已完成,在绝大多数 + // 发行版上这一次会真的生效,消除失效模式 A。 + let _ = window.set_focus(); + + // 伪 resize:读取当前 inner_size,先加 1px 再还原。这会触发 + // GTK 的 size-allocate → WebKitWebViewBase::size_allocate → + // 重新 attach input surface,消除失效模式 B。 + // + // 使用 PhysicalSize 避免跨 DPI 的逻辑坐标漂移;saturating_add + // 防止极端尺寸溢出。 + match window.inner_size() { + Ok(original) => { + let bumped = PhysicalSize::new(original.width.saturating_add(1), original.height); + let _ = window.set_size(bumped); + tokio::time::sleep(RESIZE_GAP).await; + let _ = window.set_size(original); + log::info!("Linux: 已对主窗口执行 focus + surface 重激活"); + + // 尺寸对账回读:Tao Linux 的尺寸 API 是异步的,`set_size` 只是把 + // resize 请求送进 GTK 主循环队列,合成器可能会 coalesce 两次连续 + // 请求(尤其是第二次 `set_size(original)`),导致窗口永久停留在 + // width+1。这里等合成器处理完队列后读一次实际尺寸,发现 drift 就 + // 再补一次 `set_size(original)` 兜底。 + // + // 已知限制:tiling Wayland 合成器(sway/river/hyprland)会完全忽略 + // `set_size`,此时对账永远 drift=0(因为两次 set_size 都是 no-op), + // 看起来"没问题"但失效模式 B 其实没被修复;这是已知限制,需要用户 + // 侧用 GDK_BACKEND=x11 绕过,README 应该有说明。 + tokio::time::sleep(RECONCILE_WAIT).await; + match window.inner_size() { + Ok(after) => { + if after.width != original.width || after.height != original.height { + log::info!( + "Linux nudge 尺寸 drift: expected={}x{}, got={}x{},已补偿", + original.width, + original.height, + after.width, + after.height + ); + let _ = window.set_size(original); + // 最终校验:如果补偿后仍然不一致,记 warn 让用户/开发者 + // 知道对账失败。这时窗口会停在非预期尺寸(通常是 +1px), + // 属于极端兜底场景。 + if let Ok(final_size) = window.inner_size() { + if final_size.width != original.width + || final_size.height != original.height + { + log::warn!( + "Linux nudge 尺寸 drift 补偿后仍不一致: expected={}x{}, got={}x{}", + original.width, + original.height, + final_size.width, + final_size.height + ); + } + } + } + } + Err(e) => { + log::warn!("Linux nudge: 对账回读 inner_size 失败: {e}"); + } + } + } + Err(e) => { + // 极罕见的失败路径;只做了 set_focus 也比什么都不做强, + // 不要让 resize 失败把整个补丁吞掉。 + log::warn!("Linux nudge: 读取 inner_size 失败,跳过伪 resize: {e}"); + } + } + }); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d1f85285..9d81b848f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,12 @@ fn main() { if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } + // 禁用 WebKitGTK 合成模式,规避 resize 时 webview 崩溃以及部分 Wayland + // 合成器下的 surface 协商问题(整窗 UI 点击无响应、必须最大化-还原才能恢复)。 + // 参考: https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } } cc_switch_lib::run(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 9f9b06c58..31af81b42 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -424,6 +424,10 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "macos")] { apply_tray_policy(app, true); diff --git a/src/App.tsx b/src/App.tsx index 750c5ba2f..63d6d05cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,12 @@ import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; -import { isWindows, isLinux } from "@/lib/platform"; +import { + isWindows, + isLinux, + DRAG_REGION_ATTR, + DRAG_REGION_STYLE, +} from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; @@ -876,11 +881,13 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > -
+ {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {showEnvBanner && envConflicts.length > 0 && (
= ({ className="fixed inset-0 z-[60] flex flex-col" style={{ backgroundColor: "hsl(var(--background))" }} > - {/* Drag region - match App.tsx */} -
+ {/* Drag region - match App.tsx. Linux 上 DRAG_BAR_HEIGHT=0, + 直接跳过整个元素;macOS 保留 28px 拖拽占位。 */} + {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {/* Header - match App.tsx */}
= ({ >
+ + + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f2475be26..e41632edf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -42,6 +42,12 @@ "enabled": "Enabled", "notSet": "Not Set" }, + "firstRunNotice": { + "title": "Welcome to CC Switch", + "bodyDefault": "CC Switch lets you switch between multiple Claude Code / Codex / Gemini CLI providers with a single click. If you already had these tools configured, CC Switch has saved your existing setup as a provider named “default” so none of your previous configuration is lost.", + "bodyOfficial": "An “Official” preset is also included in the list — click it any time you want to switch back to the official default. CC Switch automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how CC Switch works 😊", + "confirm": "Got it" + }, "apiKeyInput": { "placeholder": "Enter API Key", "show": "Show API Key", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3aa2f7006..200d14acd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -42,6 +42,12 @@ "enabled": "有効", "notSet": "未設定" }, + "firstRunNotice": { + "title": "CC Switch へようこそ", + "bodyDefault": "CC Switch は Claude Code / Codex / Gemini CLI の複数プロバイダーをワンクリックで切り替えられるツールです。すでにこれらのツールを設定済みの場合、CC Switch は現在の設定を「default」という名前のプロバイダーとして自動的に保存するので、既存の設定が失われることはありません。", + "bodyOfficial": "リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが CC Switch の仕組みです 😊", + "confirm": "了解しました" + }, "apiKeyInput": { "placeholder": "API Key を入力", "show": "API Key を表示", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index be42ae4e7..52a318141 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -42,6 +42,12 @@ "enabled": "已开启", "notSet": "未设置" }, + "firstRunNotice": { + "title": "欢迎使用 CC Switch", + "bodyDefault": "CC Switch 可以帮你在 Claude Code / Codex / Gemini CLI 的多个供应商之间一键切换。如果你之前已经配置过这些工具,CC Switch 会自动把现有设置保存为名为 “default” 的供应商,保证你的配置不会丢失。", + "bodyOfficial": "列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 CC Switch 会自动把当前配置备份回 default,可以放心来回切。CC Switch 就是这样工作的 😊", + "confirm": "我知道了" + }, "apiKeyInput": { "placeholder": "请输入API Key", "show": "显示API Key", diff --git a/src/types.ts b/src/types.ts index f3abcb0f3..d7229ec98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -264,6 +264,8 @@ export interface Settings { enableFailoverToggle?: boolean; // User has confirmed the failover toggle first-run notice failoverConfirmed?: boolean; + // User has confirmed the first-run welcome notice + firstRunNoticeConfirmed?: boolean; // User has confirmed the auto-sync traffic warning autoSyncConfirmed?: boolean; // 首选语言(可选,默认中文) From bafe9e820dce9e1200b65e30ff03f5ccd2208502 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 15:16:35 +0800 Subject: [PATCH 061/128] fix(notifications): remove duplicate toast when switching to proxy providers When switching to Copilot/ChatGPT/OpenAI-format providers with the proxy not running, two toasts appeared: a "proxy required" warning followed by a "switch success" toast. Unify the post-switch toast logic so that all provider types show a single success toast, and skip it entirely when a proxy-required warning was already shown. --- src/hooks/useProviderActions.ts | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index 346de6e5b..85d125c34 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -200,27 +200,8 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) { ); } - // 根据供应商类型显示不同的成功提示 - if ( - !proxyRequiredReason && - activeApp === "claude" && - provider.category !== "official" && - (isCopilotProvider || - provider.meta?.apiFormat === "openai_chat" || - provider.meta?.apiFormat === "openai_responses") - ) { - // OpenAI format provider: show proxy hint (skip if warning already shown) - toast.info( - isCopilotProvider - ? t("notifications.copilotProxyHint") - : t("notifications.openAIFormatHint"), - { - duration: 5000, - closeButton: true, - }, - ); - } else { - // 普通供应商:显示切换成功 + // 若已弹过 proxyRequired 警告则不再弹 success + if (!proxyRequiredReason) { // OpenCode/OpenClaw: show "added to config" message instead of "switched" const isMultiProviderApp = activeApp === "opencode" || activeApp === "openclaw"; From 521571c4ca31d4346731f9703a0e53dcd9c104da Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 15:19:51 +0800 Subject: [PATCH 062/128] fix(usage): only show CLI subscription quota for active provider CLI-credential-based subscriptions (Claude/Codex/Gemini) read from a single global credential file, so the quota always reflects the last CLI login rather than a specific provider. Showing it on non-current cards is misleading when multiple official subscriptions exist. Apply the same isCurrent + autoQuery pattern already used by Copilot and Codex OAuth: only query and render the quota footer when the provider is the currently active one. --- src/components/SubscriptionQuotaFooter.tsx | 6 +++++- src/components/providers/ProviderCard.tsx | 2 +- src/lib/query/subscription.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index e345b58f9..fce6ea09e 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -8,6 +8,7 @@ import type { QuotaTier, SubscriptionQuota } from "@/types/subscription"; interface SubscriptionQuotaFooterProps { appId: AppId; inline?: boolean; + isCurrent?: boolean; } interface SubscriptionQuotaViewProps { @@ -386,12 +387,15 @@ const TierBar: React.FC<{ const SubscriptionQuotaFooter: React.FC = ({ appId, inline = false, + isCurrent = false, }) => { const { data: quota, isFetching: loading, refetch, - } = useSubscriptionQuota(appId, true); + } = useSubscriptionQuota(appId, isCurrent, isCurrent); + + if (!isCurrent) return null; return ( ) : isOfficial ? ( - + ) : hasMultiplePlans ? (
diff --git a/src/lib/query/subscription.ts b/src/lib/query/subscription.ts index 71248e7f6..f79dd1d6b 100644 --- a/src/lib/query/subscription.ts +++ b/src/lib/query/subscription.ts @@ -7,13 +7,18 @@ import { PROVIDER_TYPES } from "@/config/constants"; const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes -export function useSubscriptionQuota(appId: AppId, enabled: boolean) { +export function useSubscriptionQuota( + appId: AppId, + enabled: boolean, + autoQuery = false, +) { return useQuery({ queryKey: ["subscription", "quota", appId], queryFn: () => subscriptionApi.getQuota(appId), enabled: enabled && ["claude", "codex", "gemini"].includes(appId), - refetchInterval: REFETCH_INTERVAL, - refetchOnWindowFocus: true, + refetchInterval: autoQuery ? REFETCH_INTERVAL : false, + refetchIntervalInBackground: autoQuery, + refetchOnWindowFocus: autoQuery, staleTime: REFETCH_INTERVAL, retry: 1, }); From ecb1fed5db6a54b357a279e9e7b5c691848c065d Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 16:20:22 +0800 Subject: [PATCH 063/128] feat(common-config): add guide info and empty state to common config editor Add an informational alert block at the top of the common config snippet editor modal (Claude/Codex/Gemini) explaining what the feature is, why it exists, and how to use it. Also add an empty state prompt when no snippet has been extracted yet, guiding users to click "Extract from Editor". Includes i18n support for zh/en/ja. --- .../forms/CodexCommonConfigModal.tsx | 30 ++++++++++++++-- .../providers/forms/CommonConfigEditor.tsx | 35 +++++++++++++++---- .../forms/GeminiCommonConfigModal.tsx | 32 +++++++++++++++-- src/i18n/locales/en.json | 9 +++++ src/i18n/locales/ja.json | 9 +++++ src/i18n/locales/zh.json | 9 +++++ 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx index 67204ff59..0c0207d91 100644 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Save, Download, Loader2 } from "lucide-react"; +import { Save, Download, Loader2, Package } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; @@ -100,9 +100,35 @@ export const CodexCommonConfigModal: React.FC = ({ } >
-

+

+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+

{t("codexConfig.commonConfigHint")}

+ {(!draftValue || draftValue.trim() === "") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )}
-

- {t("claudeConfig.commonConfigHint", { - defaultValue: "通用配置片段将合并到所有启用它的供应商配置中", - })} -

+
+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+ {(!commonConfigSnippet || + commonConfigSnippet.trim() === "" || + commonConfigSnippet.trim() === "{}") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )}
-

+

+

+ {t("commonConfig.guideTitle")} +

+

+ {t("commonConfig.guidePurpose")} +

+

+ {t("commonConfig.guideUsage")} +

+

+ {t("commonConfig.guideReExtract")} +

+

+ {t("commonConfig.guideReassurance")} +

+
+

{t("geminiConfig.commonConfigHint", { defaultValue: "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", })}

+ {(!draftValue || + draftValue.trim() === "" || + draftValue.trim() === "{}") && ( +
+ +

+ {t("commonConfig.emptyTitle")} +

+

{t("commonConfig.emptyHint")}

+
+ )} Date: Thu, 9 Apr 2026 16:48:21 +0800 Subject: [PATCH 064/128] feat(common-config): show first-run notice dialog when editing providers Display a one-time informational dialog explaining the Common Config Snippet feature when users first open the add/edit provider form. Uses a derived isOpen state from settings to avoid race conditions. Adds commonConfigConfirmed flag to both TS and Rust settings types. --- src-tauri/src/settings.rs | 4 ++ .../providers/forms/ProviderForm.tsx | 40 ++++++++++++++++--- src/i18n/locales/en.json | 5 +++ src/i18n/locales/ja.json | 5 +++ src/i18n/locales/zh.json | 5 +++ src/types.ts | 2 + 6 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8c937092e..22e415a4c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -208,6 +208,9 @@ pub struct AppSettings { /// User has confirmed the first-run welcome notice #[serde(default, skip_serializing_if = "Option::is_none")] pub first_run_notice_confirmed: Option, + /// User has confirmed the common config first-run notice + #[serde(default, skip_serializing_if = "Option::is_none")] + pub common_config_confirmed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -301,6 +304,7 @@ impl Default for AppSettings { enable_failover_toggle: false, failover_confirmed: None, first_run_notice_confirmed: None, + common_config_confirmed: None, language: None, visible_apps: None, claude_config_dir: None, diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index f5f5f6dbb..a49eb1092 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1,14 +1,14 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; -import { providersApi, type AppId } from "@/lib/api"; +import { providersApi, settingsApi, type AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta, @@ -84,6 +84,8 @@ import { useCopilotAuth, useCodexOauth, } from "./hooks"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { useSettingsQuery } from "@/lib/query"; import { CLAUDE_DEFAULT_CONFIG, CODEX_DEFAULT_CONFIG, @@ -141,6 +143,22 @@ export function ProviderForm({ }: ProviderFormProps) { const { t } = useTranslation(); const isEditMode = Boolean(initialData); + const queryClient = useQueryClient(); + const { data: settingsData } = useSettingsQuery(); + const showCommonConfigNotice = + settingsData != null && settingsData.commonConfigConfirmed !== true; + + const handleCommonConfigConfirm = async () => { + try { + if (settingsData) { + const { webdavSync: _, ...rest } = settingsData; + await settingsApi.save({ ...rest, commonConfigConfirmed: true }); + await queryClient.invalidateQueries({ queryKey: ["settings"] }); + } + } catch (error) { + console.error("Failed to save commonConfigConfirmed:", error); + } + }; const [selectedPresetId, setSelectedPresetId] = useState( initialData ? null : "custom", @@ -1342,7 +1360,8 @@ export function ProviderForm({ ); return ( -
+ <> +
)} - - + + + + void handleCommonConfigConfirm()} + onCancel={() => void handleCommonConfigConfirm()} + /> + ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e2b314a78..5a0f16b42 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -232,6 +232,11 @@ "title": "Enable Auto Sync", "message": "When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\n\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.", "confirm": "I understand, enable" + }, + "commonConfig": { + "title": "About Common Config", + "message": "The \"Common Config Snippet\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\n\nHow to use:\n① Edit a provider → click \"Edit Common Config\" → \"Extract from Editor\"\n② When creating a new provider, check \"Write Common Config\" (enabled by default)\n\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.", + "confirm": "Got it" } }, "settings": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a13e00628..bb0c8bf43 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -232,6 +232,11 @@ "title": "自動同期を有効にする", "message": "自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\n\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。", "confirm": "理解しました、有効にする" + }, + "commonConfig": { + "title": "共通設定について", + "message": "「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\n\n使い方:\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\n② 新規プロバイダー作成時に「共通設定を書き込む」をチェック(デフォルトでオン)\n\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。", + "confirm": "わかりました" } }, "settings": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb9472515..8696647dd 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -232,6 +232,11 @@ "title": "开启自动同步", "message": "开启自动同步后,每次数据库变更都会自动上传到 WebDAV 服务器。\n\n这可能会产生较高的网络流量消耗,请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。", "confirm": "我已了解,继续开启" + }, + "commonConfig": { + "title": "关于通用配置", + "message": "「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\n\n使用方法:\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\n② 新建供应商时勾选「写入通用配置」(默认已勾选)\n\n如果您新安装了插件或 Hook,请重新提取一次通用配置。", + "confirm": "我知道了" } }, "settings": { diff --git a/src/types.ts b/src/types.ts index d7229ec98..c1b674418 100644 --- a/src/types.ts +++ b/src/types.ts @@ -268,6 +268,8 @@ export interface Settings { firstRunNoticeConfirmed?: boolean; // User has confirmed the auto-sync traffic warning autoSyncConfirmed?: boolean; + // User has confirmed the common config first-run notice + commonConfigConfirmed?: boolean; // 首选语言(可选,默认中文) language?: "en" | "zh" | "ja"; From 129b1b45b057353a30c8bfa655847d64f9cb8857 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 17:13:19 +0800 Subject: [PATCH 065/128] docs: add Shengsuanyun as sponsor in all README languages --- README.md | 5 +++++ README_JA.md | 5 +++++ README_ZH.md | 5 +++++ assets/partners/logos/shengsuanyun.svg | 4 ++++ 4 files changed, 19 insertions(+) create mode 100644 assets/partners/logos/shengsuanyun.svg diff --git a/README.md b/README.md index f5f222812..2933f95ad 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free. + +Shengsuanyun +Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with monitoring dashboards showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via this link as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up. + + AIGoCode Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up! diff --git a/README_JA.md b/README_JA.md index 0340b4c35..2616c5da2 100644 --- a/README_JA.md +++ b/README_JA.md @@ -41,6 +41,11 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設 SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。このリンクから登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。 + +Shengsuanyun +胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、監視ダッシュボードは常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。このリンクから新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。 + + AIGoCode 本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、このリンクから登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます! diff --git a/README_ZH.md b/README_ZH.md index 6577ceadb..a80ec67a6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -41,6 +41,11 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构 感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过此链接注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。 + +Shengsuanyun +感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、监测接口日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用此链接注册新用户可获10元模力及首充10%赠送。 + + AIGoCode 感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过此链接注册的用户首次充值可以获得额外10%奖励额度! diff --git a/assets/partners/logos/shengsuanyun.svg b/assets/partners/logos/shengsuanyun.svg new file mode 100644 index 000000000..e9ceb5b82 --- /dev/null +++ b/assets/partners/logos/shengsuanyun.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From 97152c0f21dcd4a2c7333b520a20bb1f3c5cbfe2 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 17:25:15 +0800 Subject: [PATCH 066/128] docs: add LionCC as sponsor in all README languages --- README.md | 5 +++++ README_JA.md | 5 +++++ README_ZH.md | 5 +++++ assets/partners/logos/lioncc.png | Bin 0 -> 27305 bytes 4 files changed, 15 insertions(+) create mode 100644 assets/partners/logos/lioncc.png diff --git a/README.md b/README.md index 2933f95ad..9f18acfa5 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click here to purchase! + +LionCC +Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click here to register! + + diff --git a/README_JA.md b/README_JA.md index 2616c5da2..7e401cab2 100644 --- a/README_JA.md +++ b/README_JA.md @@ -112,6 +112,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。こちらから購入してください! + +LionCC +LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。こちらから登録してください! + + diff --git a/README_ZH.md b/README_ZH.md index a80ec67a6..8c7460ee8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -113,6 +113,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击这里购买! + +LionCC +感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击这里注册! + + diff --git a/assets/partners/logos/lioncc.png b/assets/partners/logos/lioncc.png new file mode 100644 index 0000000000000000000000000000000000000000..3f7975e04334bf64ffc095ce2cc26407bda8f6f9 GIT binary patch literal 27305 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 Date: Thu, 9 Apr 2026 17:40:46 +0800 Subject: [PATCH 067/128] docs: add DDS as sponsor in all README languages --- README.md | 6 ++++++ README_JA.md | 7 +++++++ README_ZH.md | 5 +++++ assets/partners/logos/dds.png | Bin 0 -> 101161 bytes 4 files changed, 18 insertions(+) create mode 100644 assets/partners/logos/dds.png diff --git a/README.md b/README.md index 9f18acfa5..a173e85d9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,12 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click here to register! + +DDS +Thanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude API proxy service. We provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services. +Exclusive benefit for CC Switch users: Register via the link below and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)! + + diff --git a/README_JA.md b/README_JA.md index 7e401cab2..6d1e41603 100644 --- a/README_JA.md +++ b/README_JA.md @@ -117,6 +117,13 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。こちらから登録してください! + +DDS +本プロジェクトのスポンサーである DDS に感謝いたします! DDS(呆呆獣 / DDS Hub)は、Claude に特化した信頼性とパフォーマンスの高い API プロキシサービスです。個人および企業ユーザーの皆様に、圧倒的なコストパフォーマンスを誇る Claude 直結アクセラレーションサービスを提供しています。Claude Haiku / Opus / Sonnet などのフルスペックモデルを完全サポートし、安定した低遅延のアクセスを実現します。 +1,000人民元以上のチャージで領収書(発票)の発行が可能です。さらに、企業のお客様にはカスタマイズされたグループ管理や専用テクニカルサポートをご提供しています。 +CC Switch ユーザー限定特典: 専用リンクからご登録いただくと、初回チャージ時に 10% の追加ボーナスクレジット をプレゼントいたします!(※チャージ完了後、グループ管理人へご連絡の上お受け取りください。) + + diff --git a/README_ZH.md b/README_ZH.md index 8c7460ee8..b60d1b080 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -118,6 +118,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更 感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击这里注册! + +DDS +感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。CC Switch 用户专属福利:通过此链接注册后,首单充值可额外赠送 10% 额度(充值后请联系群主领取)! + + diff --git a/assets/partners/logos/dds.png b/assets/partners/logos/dds.png new file mode 100644 index 0000000000000000000000000000000000000000..ded569ea180b360d65586af0842a953ff159e486 GIT binary patch literal 101161 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�*3$b{TV-T!?xvS(@b_UjR4hGL$rTJiu2 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 Date: Thu, 9 Apr 2026 21:13:32 +0800 Subject: [PATCH 068/128] style: fix prettier formatting in ProviderCard and ProviderForm --- src/components/providers/ProviderCard.tsx | 6 +- .../providers/forms/ProviderForm.tsx | 948 +++++++++--------- 2 files changed, 484 insertions(+), 470 deletions(-) diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 3f999937d..e85973e5b 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -369,7 +369,11 @@ export function ProviderCard({ isCurrent={isCurrent} /> ) : isOfficial ? ( - + ) : hasMultiplePlans ? (
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index a49eb1092..35de812d7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1362,416 +1362,422 @@ export function ProviderForm({ return ( <>
- - {!initialData && ( - - )} - - - - - opencodeForm.setOpencodeProviderKey( - e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), - ) - } - placeholder={t("opencode.providerKeyPlaceholder")} - disabled={ - isProviderKeyLocked || isProviderKeyLockStateLoading - } - className={ - (additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && - !isProviderKeyLocked) || - (opencodeForm.opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - )) - ? "border-destructive" - : "" - } - /> - {additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && - !isProviderKeyLocked && ( -

- {t("opencode.providerKeyDuplicate")} -

- )} - {opencodeForm.opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - ) && ( -

- {t("opencode.providerKeyInvalid")} -

- )} - {!( - additiveExistingProviderKeys.includes( - opencodeForm.opencodeProviderKey, - ) && !isProviderKeyLocked - ) && - (opencodeForm.opencodeProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test( - opencodeForm.opencodeProviderKey, - )) && ( -

- {isProviderKeyLocked - ? t("opencode.providerKeyLockedHint", { - defaultValue: - "该供应商已添加到应用配置中,供应商标识不可修改", - }) - : t("opencode.providerKeyHint")} -

- )} -
- ) : appId === "openclaw" ? ( -
- - - openclawForm.setOpenclawProviderKey( - e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), - ) - } - placeholder={t("openclaw.providerKeyPlaceholder")} - disabled={ - isProviderKeyLocked || isProviderKeyLockStateLoading - } - className={ - (additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && - !isProviderKeyLocked) || - (openclawForm.openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - )) - ? "border-destructive" - : "" - } - /> - {additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && - !isProviderKeyLocked && ( -

- {t("openclaw.providerKeyDuplicate")} -

- )} - {openclawForm.openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - ) && ( -

- {t("openclaw.providerKeyInvalid")} -

- )} - {!( - additiveExistingProviderKeys.includes( - openclawForm.openclawProviderKey, - ) && !isProviderKeyLocked - ) && - (openclawForm.openclawProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test( - openclawForm.openclawProviderKey, - )) && ( -

- {isProviderKeyLocked - ? t("openclaw.providerKeyLockedHint", { - defaultValue: - "该供应商已添加到应用配置中,供应商标识不可修改", - }) - : t("openclaw.providerKeyHint")} -

- )} -
- ) : undefined - } - /> - - {appId === "claude" && ( - - )} - - {appId === "codex" && ( - - )} - - {appId === "gemini" && ( - - )} - - {appId === "opencode" && !isAnyOmoCategory && ( - - )} - - {appId === "opencode" && - (category === "omo" || category === "omo-slim") && ( - + {!initialData && ( + )} - {/* OpenClaw 专属字段 */} - {appId === "openclaw" && ( - + + + opencodeForm.setOpencodeProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("opencode.providerKeyPlaceholder")} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } + className={ + (additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && + !isProviderKeyLocked) || + (opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) + ? "border-destructive" + : "" + } + /> + {additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && + !isProviderKeyLocked && ( +

+ {t("opencode.providerKeyDuplicate")} +

+ )} + {opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + ) && ( +

+ {t("opencode.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + opencodeForm.opencodeProviderKey, + ) && !isProviderKeyLocked + ) && + (opencodeForm.opencodeProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) && ( +

+ {isProviderKeyLocked + ? t("opencode.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("opencode.providerKeyHint")} +

+ )} +
+ ) : appId === "openclaw" ? ( +
+ + + openclawForm.setOpenclawProviderKey( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ) + } + placeholder={t("openclaw.providerKeyPlaceholder")} + disabled={ + isProviderKeyLocked || isProviderKeyLockStateLoading + } + className={ + (additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && + !isProviderKeyLocked) || + (openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) + ? "border-destructive" + : "" + } + /> + {additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && + !isProviderKeyLocked && ( +

+ {t("openclaw.providerKeyDuplicate")} +

+ )} + {openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + ) && ( +

+ {t("openclaw.providerKeyInvalid")} +

+ )} + {!( + additiveExistingProviderKeys.includes( + openclawForm.openclawProviderKey, + ) && !isProviderKeyLocked + ) && + (openclawForm.openclawProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) && ( +

+ {isProviderKeyLocked + ? t("openclaw.providerKeyLockedHint", { + defaultValue: + "该供应商已添加到应用配置中,供应商标识不可修改", + }) + : t("openclaw.providerKeyHint")} +

+ )} +
+ ) : undefined + } /> - )} - {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} - {appId === "codex" ? ( - <> - - {settingsConfigErrorField} - - ) : appId === "gemini" ? ( - <> - - {settingsConfigErrorField} - - ) : appId === "opencode" && - (category === "omo" || category === "omo-slim") ? ( -
- - {}} - rows={14} - showValidation={false} - language="json" + )} + + {appId === "codex" && ( + -
- ) : appId === "opencode" && - category !== "omo" && - category !== "omo-slim" ? ( - <> + )} + + {appId === "gemini" && ( + + )} + + {appId === "opencode" && !isAnyOmoCategory && ( + + )} + + {appId === "opencode" && + (category === "omo" || category === "omo-slim") && ( + + )} + + {/* OpenClaw 专属字段 */} + {appId === "openclaw" && ( + + )} + + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} + {appId === "codex" ? ( + <> + + {settingsConfigErrorField} + + ) : appId === "gemini" ? ( + <> + + {settingsConfigErrorField} + + ) : appId === "opencode" && + (category === "omo" || category === "omo-slim") ? (
- + form.setValue("settingsConfig", config)} - placeholder={`{ + value={omoDraft.mergedOmoJsonPreview} + onChange={() => {}} + rows={14} + showValidation={false} + language="json" + /> +
+ ) : appId === "opencode" && + category !== "omo" && + category !== "omo-slim" ? ( + <> +
+ + form.setValue("settingsConfig", config)} + placeholder={`{ "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://your-api-endpoint.com", @@ -1779,82 +1785,86 @@ export function ProviderForm({ }, "models": {} }`} - rows={14} - showValidation={true} - language="json" - /> -
- {settingsConfigErrorField} - - ) : appId === "openclaw" ? ( - <> -
- - form.setValue("settingsConfig", config)} - placeholder={`{ + rows={14} + showValidation={true} + language="json" + /> +
+ {settingsConfigErrorField} + + ) : appId === "openclaw" ? ( + <> +
+ + form.setValue("settingsConfig", config)} + placeholder={`{ "baseUrl": "https://api.example.com/v1", "apiKey": "your-api-key-here", "api": "openai-completions", "models": [] }`} - rows={14} - showValidation={true} - language="json" + rows={14} + showValidation={true} + language="json" + /> +
+ ( + + + + )} /> + + ) : ( + <> + form.setValue("settingsConfig", value)} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={handleCommonConfigToggle} + commonConfigSnippet={commonConfigSnippet} + onCommonConfigSnippetChange={handleCommonConfigSnippetChange} + commonConfigError={commonConfigError} + onEditClick={() => setIsCommonConfigModalOpen(true)} + isModalOpen={isCommonConfigModalOpen} + onModalClose={() => setIsCommonConfigModalOpen(false)} + onExtract={handleClaudeExtract} + isExtracting={isClaudeExtracting} + /> + {settingsConfigErrorField} + + )} + + {!isAnyOmoCategory && + appId !== "opencode" && + appId !== "openclaw" && ( + + )} + + {showButtons && ( +
+ +
- ( - - - - )} - /> - - ) : ( - <> - form.setValue("settingsConfig", value)} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - onEditClick={() => setIsCommonConfigModalOpen(true)} - isModalOpen={isCommonConfigModalOpen} - onModalClose={() => setIsCommonConfigModalOpen(false)} - onExtract={handleClaudeExtract} - isExtracting={isClaudeExtracting} - /> - {settingsConfigErrorField} - - )} - - {!isAnyOmoCategory && appId !== "opencode" && appId !== "openclaw" && ( - - )} - - {showButtons && ( -
- - -
- )} + )} From 5ac6fb5315e74f689fd881e84f208efeba07d9da Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 9 Apr 2026 23:18:06 +0800 Subject: [PATCH 069/128] feat(session-manager): extract meaningful titles for Claude sessions Instead of showing directory basenames for all Claude sessions, extract titles from JSONL content with a priority chain: 1. custom-title metadata (set via /rename in Claude Code) 2. First real user message (skipping /clear, /compact caveats) 3. Directory basename (fallback) --- .../src/session_manager/providers/claude.rs | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index 865b9efa9..226613d13 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -12,6 +12,7 @@ use super::utils::{ }; const PROVIDER_ID: &str = "claude"; +const TITLE_MAX_CHARS: usize = 80; pub fn scan_sessions() -> Vec { let root = get_claude_config_dir().join("projects"); @@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option { let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; + let mut first_user_message: Option = None; - // Extract metadata from head lines + // Extract metadata and first user message from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, @@ -151,11 +153,41 @@ fn parse_session(path: &Path) -> Option { if created_at.is_none() { created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + // Extract first real user message as title candidate + // Skip system-injected caveats and slash commands (e.g. /clear, /compact) + if first_user_message.is_none() { + let is_user = value.get("type").and_then(Value::as_str) == Some("user") + || value + .get("message") + .and_then(|m| m.get("role")) + .and_then(Value::as_str) + == Some("user"); + if is_user { + if let Some(message) = value.get("message") { + let text = message.get("content").map(extract_text).unwrap_or_default(); + let trimmed = text.trim(); + if !trimmed.is_empty() + && !trimmed.contains("") + && !trimmed.starts_with("") + { + first_user_message = Some(trimmed.to_string()); + } + } + } + } + if session_id.is_some() + && project_dir.is_some() + && created_at.is_some() + && first_user_message.is_some() + { + break; + } } - // Extract last_active_at and summary from tail lines (reverse order) + // Extract last_active_at, summary, and custom-title from tail lines (reverse order) let mut last_active_at: Option = None; let mut summary: Option = None; + let mut custom_title: Option = None; for line in tail.iter().rev() { let value: Value = match serde_json::from_str(line) { @@ -165,6 +197,16 @@ fn parse_session(path: &Path) -> Option { if last_active_at.is_none() { last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } + // Look for custom-title entry (take the last one, i.e. first in reverse) + if custom_title.is_none() + && value.get("type").and_then(Value::as_str) == Some("custom-title") + { + custom_title = value + .get("customTitle") + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } if summary.is_none() { if value.get("isMeta").and_then(Value::as_bool) == Some(true) { continue; @@ -176,7 +218,7 @@ fn parse_session(path: &Path) -> Option { } } } - if last_active_at.is_some() && summary.is_some() { + if last_active_at.is_some() && summary.is_some() && custom_title.is_some() { break; } } @@ -184,10 +226,16 @@ fn parse_session(path: &Path) -> Option { let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); let session_id = session_id?; - let title = project_dir - .as_deref() - .and_then(path_basename) - .map(|value| value.to_string()); + // Title priority: custom-title > first user message > directory basename + let title = custom_title + .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) + .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))) + .or_else(|| { + project_dir + .as_deref() + .and_then(path_basename) + .map(|v| v.to_string()) + }); let summary = summary.map(|text| truncate_summary(&text, 160)); @@ -336,4 +384,117 @@ mod tests { assert_eq!(msgs[0].role, "user"); assert!(msgs[0].content.contains("Please continue")); } + + #[test] + fn parse_session_uses_first_user_message_as_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-abc.jsonl"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"sessionId\":\"session-abc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); + } + + #[test] + fn parse_session_custom_title_overrides_first_message() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-def.jsonl"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-def\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"sessionId\":\"session-def\",\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n", + "{\"type\":\"custom-title\",\"customTitle\":\"fix-login-bug\",\"sessionId\":\"session-def\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("fix-login-bug")); + } + + #[test] + fn parse_session_falls_back_to_dir_basename() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-ghi.jsonl"); + std::fs::write( + &path, + concat!( + "{\"sessionId\":\"session-ghi\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + // No user message and no custom-title → falls back to dir basename + assert_eq!(meta.title.as_deref(), Some("my-project")); + } + + #[test] + fn parse_session_truncates_long_title() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-trunc.jsonl"); + let long_msg = "a".repeat(200); + std::fs::write( + &path, + format!( + "{{\"sessionId\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\ + {{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"sessionId\":\"session-trunc\",\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + let title = meta.title.unwrap(); + assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." + assert!(title.ends_with("...")); + } + + #[test] + fn parse_session_new_format_with_snapshot() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-new.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"请帮我重构这个函数\"},\"sessionId\":\"session-new\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"OK\"},\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("请帮我重构这个函数")); + } + + #[test] + fn parse_session_skips_command_caveat_and_slash_commands() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("session-clear.jsonl"); + std::fs::write( + &path, + concat!( + "{\"type\":\"file-history-snapshot\",\"messageId\":\"msg-1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"Caveat: The messages below were generated by the user while running local commands.\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:00Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"/clear\\nclear\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:00:01Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\"timestamp\":\"2026-03-06T10:00:02Z\",\"cwd\":\"/tmp/project\"}\n", + "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"帮我看看工作区的改动\"},\"sessionId\":\"session-clear\",\"timestamp\":\"2026-03-06T10:01:00Z\",\"cwd\":\"/tmp/project\"}\n", + ), + ) + .expect("write"); + + let meta = parse_session(&path).unwrap(); + assert_eq!(meta.title.as_deref(), Some("帮我看看工作区的改动")); + } } From 308314baacfff769f90d12ff4d8281bf75ae2b1f Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 00:14:50 +0800 Subject: [PATCH 070/128] fix(session-manager): improve session search accuracy and Chinese support - Pre-filter sessions by provider before indexing to prevent result truncation when FlexSearch limit cuts across providers - Switch tokenizer from "forward" to "full" for Chinese substring matching - Preserve FlexSearch relevance ranking when search query is present --- src/hooks/useSessionSearch.ts | 49 ++++++++++++----------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts index 2f1b8c920..5d9710233 100644 --- a/src/hooks/useSessionSearch.ts +++ b/src/hooks/useSessionSearch.ts @@ -19,14 +19,18 @@ export function useSessionSearch({ sessions, providerFilter, }: UseSessionSearchOptions): UseSessionSearchResult { + const filteredByProvider = useMemo(() => { + if (providerFilter === "all") return sessions; + return sessions.filter((s) => s.providerId === providerFilter); + }, [sessions, providerFilter]); + const index = useMemo(() => { - // 使用 forward tokenizer 支持中文前缀搜索 const nextIndex = new FlexSearch.Index({ - tokenize: "forward", + tokenize: "full", resolution: 9, }); - sessions.forEach((session, idx) => { + filteredByProvider.forEach((session, idx) => { const metaContent = [ session.sessionId, session.title, @@ -41,49 +45,28 @@ export function useSessionSearch({ }); return nextIndex; - }, [sessions]); + }, [filteredByProvider]); - // 搜索函数 const search = useCallback( (query: string): SessionMeta[] => { - const needle = query.trim().toLowerCase(); + const needle = query.trim(); - // 先按 provider 过滤 - let filtered = sessions; - if (providerFilter !== "all") { - filtered = sessions.filter((s) => s.providerId === providerFilter); - } - - // 如果没有搜索词,返回按时间排序的结果 if (!needle) { - return [...filtered].sort((a, b) => { + return [...filteredByProvider].sort((a, b) => { const aTs = a.lastActiveAt ?? a.createdAt ?? 0; const bTs = b.lastActiveAt ?? b.createdAt ?? 0; return bTs - aTs; }); } - // 使用 FlexSearch 搜索 - const results = index.search(needle, { limit: 100 }) as number[]; + const results = index.search(needle, { + limit: filteredByProvider.length, + }) as number[]; - // 转换为 session 并过滤 - const matchedSessions = results - .map((idx) => sessions[idx]) - .filter( - (session) => - session && - (providerFilter === "all" || session.providerId === providerFilter), - ); - - // 按时间排序 - return matchedSessions.sort((a, b) => { - const aTs = a.lastActiveAt ?? a.createdAt ?? 0; - const bTs = b.lastActiveAt ?? b.createdAt ?? 0; - return bTs - aTs; - }); + return results.map((idx) => filteredByProvider[idx]); }, - [index, providerFilter, sessions], + [index, filteredByProvider], ); - return useMemo(() => ({ search }), [search]); + return { search }; } From f9fb9085acb3d1f6df6213246aa3a43200609251 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:30:37 +0800 Subject: [PATCH 071/128] feat(session-manager): highlight search keywords in session titles and messages --- src/components/sessions/SessionItem.tsx | 7 +++++- .../sessions/SessionManagerPage.tsx | 2 ++ .../sessions/SessionMessageItem.tsx | 13 +++++++++-- src/components/sessions/utils.ts | 22 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/components/sessions/SessionItem.tsx b/src/components/sessions/SessionItem.tsx index dc2841c33..1cacc1405 100644 --- a/src/components/sessions/SessionItem.tsx +++ b/src/components/sessions/SessionItem.tsx @@ -15,6 +15,7 @@ import { getProviderIconName, getProviderLabel, getSessionKey, + highlightText, } from "./utils"; interface SessionItemProps { @@ -23,6 +24,7 @@ interface SessionItemProps { selectionMode: boolean; isChecked: boolean; isCheckDisabled?: boolean; + searchQuery?: string; onSelect: (key: string) => void; onToggleChecked: (checked: boolean) => void; } @@ -33,6 +35,7 @@ export function SessionItem({ selectionMode, isChecked, isCheckDisabled = false, + searchQuery, onSelect, onToggleChecked, }: SessionItemProps) { @@ -82,7 +85,9 @@ export function SessionItem({ {getProviderLabel(session.providerId, t)} - {title} + + {searchQuery ? highlightText(title, searchQuery) : title} + { if (el) messageRefs.current.set(index, el); }} diff --git a/src/components/sessions/SessionMessageItem.tsx b/src/components/sessions/SessionMessageItem.tsx index 2293241bf..1dd4d2b62 100644 --- a/src/components/sessions/SessionMessageItem.tsx +++ b/src/components/sessions/SessionMessageItem.tsx @@ -9,12 +9,18 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { SessionMessage } from "@/types"; -import { formatTimestamp, getRoleLabel, getRoleTone } from "./utils"; +import { + formatTimestamp, + getRoleLabel, + getRoleTone, + highlightText, +} from "./utils"; interface SessionMessageItemProps { message: SessionMessage; index: number; isActive: boolean; + searchQuery?: string; setRef: (el: HTMLDivElement | null) => void; onCopy: (content: string) => void; } @@ -22,6 +28,7 @@ interface SessionMessageItemProps { export function SessionMessageItem({ message, isActive, + searchQuery, setRef, onCopy, }: SessionMessageItemProps) { @@ -68,7 +75,9 @@ export function SessionMessageItem({ )}
- {message.content} + {searchQuery + ? highlightText(message.content, searchQuery) + : message.content}
); diff --git a/src/components/sessions/utils.ts b/src/components/sessions/utils.ts index 633d7136d..1c2c677a9 100644 --- a/src/components/sessions/utils.ts +++ b/src/components/sessions/utils.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; +import { createElement } from "react"; import { SessionMeta } from "@/types"; export const getSessionKey = (session: SessionMeta) => @@ -78,3 +80,23 @@ export const formatSessionTitle = (session: SessionMeta) => { session.sessionId.slice(0, 8) ); }; + +export const highlightText = (text: string, query: string): ReactNode => { + if (!query) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + if (parts.length === 1) return text; + return parts.map((part, i) => + i % 2 === 1 + ? createElement( + "mark", + { + key: i, + className: + "bg-yellow-200/60 dark:bg-yellow-500/30 text-inherit rounded-sm px-0.5", + }, + part, + ) + : part, + ); +}; From cc1530f997f19649abc076a9bfcaf5668e8716e6 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 08:33:13 +0800 Subject: [PATCH 072/128] docs: add working directory feature implementation plan Design document for per-directory state switching (providers, MCP, skills, prompts). Not yet implemented - saved for future reference. --- docs/working-directory-plan.md | 597 +++++++++++++++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/working-directory-plan.md diff --git a/docs/working-directory-plan.md b/docs/working-directory-plan.md new file mode 100644 index 000000000..ae2426146 --- /dev/null +++ b/docs/working-directory-plan.md @@ -0,0 +1,597 @@ +# CC-Switch "工作目录" 功能 — 实施方案 + +## Context + +CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。 + +本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。 + +--- + +## 一、需要按目录区分的实体(完整清单) + +| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 | +|------|-------------|---------|-----------|------| +| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 | +| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 | +| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 | +| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills | +| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 | +| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 | +| Settings | key-value | flat table | NO | 全局用户偏好 | +| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 | +| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 | +| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 | + +> 原计划遗漏了 **Failover Queue** 和 **Provider Health 清除**。 + +--- + +## 二、数据库变更(Schema v8 → v9) + +### 新增 5 张表 + +```sql +-- 1. 工作目录注册表 +CREATE TABLE IF NOT EXISTS working_directories ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + name TEXT, + is_current BOOLEAN NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0 +); + +-- 2. Provider 状态快照 (is_current + in_failover_queue) +-- 每个目录保存所有 provider 的两个状态标志 +CREATE TABLE IF NOT EXISTS dir_provider_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + is_current BOOLEAN NOT NULL DEFAULT 0, + in_failover_queue BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, app_type, provider_id) +); + +-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开) +CREATE TABLE IF NOT EXISTS dir_mcp_state ( + dir_id TEXT NOT NULL, + mcp_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, mcp_id) +); + +-- 4. Skill 启用状态快照 (直接镜像 4 列) +CREATE TABLE IF NOT EXISTS dir_skill_state ( + dir_id TEXT NOT NULL, + skill_id TEXT NOT NULL, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + enabled_opencode BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (dir_id, skill_id) +); + +-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id) +CREATE TABLE IF NOT EXISTS dir_prompt_state ( + dir_id TEXT NOT NULL, + app_type TEXT NOT NULL, + prompt_id TEXT NOT NULL, + PRIMARY KEY (dir_id, app_type) +); +``` + +### 设计决策说明 + +**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**: +- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列 +- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行) +- 未来增加新 app 时,两边同步加列即可 + +**Prompt 只存 `(dir_id, app_type, prompt_id)`**: +- 每个 app_type 最多一个 enabled prompt,不需要存 boolean +- 无记录 = 该 app 无激活 prompt + +**Provider 合并 `is_current` + `in_failover_queue`**: +- 两个标志都是 per `(app_type, provider_id)` 的状态 +- 存在同一表中避免多表 JOIN + +### 迁移脚本 + +在 `schema.rs` 中: +- `create_tables_on_conn()` 添加 5 个 CREATE TABLE +- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__` 行 +- `SCHEMA_VERSION` 升至 9 +- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }` + +```rust +fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> { + // 创建 5 张表(使用 IF NOT EXISTS,幂等) + // ... + // 插入 __default__ 虚拟目录,代表"全局默认"状态 + conn.execute( + "INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \ + VALUES ('__default__', '__default__', NULL, 0, ?1)", + [crate::database::get_unix_timestamp()?], + )?; + Ok(()) +} +``` + +--- + +## 三、后端实现 + +### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs` + +所有方法都是 `impl Database` 块,遵循现有 DAO 模式。 + +**关键方法签名**(需要 `_on_conn` 变体支持事务): + +```rust +// ═══ 工作目录 CRUD ═══ +pub fn list_working_directories(&self) -> Result, AppError> +pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError> +pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError> +pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError> +pub fn get_current_working_directory(&self) -> Result, AppError> + +// 使用 _on_conn 变体,在 Service 层的事务中调用 +fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError> + +// ═══ 快照写入 ═══ (都有 _on_conn 变体) +fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> +fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> + +// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照) +fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result +``` + +**snapshot_providers 实现思路**: +```sql +-- 先清除旧快照 +DELETE FROM dir_provider_state WHERE dir_id = ?1; +-- 从主表复制当前状态 +INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue) +SELECT ?1, app_type, id, is_current, in_failover_queue +FROM providers +WHERE is_current = 1 OR in_failover_queue = 1; +``` + +**apply_provider_snapshot 实现思路**: +```sql +-- 检查是否有快照 +SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false + +-- 在事务中:先清除主表所有 is_current 和 in_failover_queue +UPDATE providers SET is_current = 0; +UPDATE providers SET in_failover_queue = 0; + +-- 从快照恢复 +UPDATE providers SET is_current = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1); + +UPDATE providers SET in_failover_queue = 1 +WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1); +``` + +**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列): +```sql +DELETE FROM dir_mcp_state WHERE dir_id = ?1; +INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode) +SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode +FROM mcp_servers; +``` + +**apply_mcp_snapshot 实现思路**: +```sql +-- 先全部禁用 +UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0; + +-- 从快照恢复 +UPDATE mcp_servers SET + enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), + enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id) +WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1); +``` + +### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs` + +```rust +use crate::store::AppState; +use crate::error::AppError; +use crate::database::lock_conn; +use crate::app_config::AppType; +use crate::services::{McpService, ProviderService, SkillService}; +use crate::config::write_text_file; +use crate::prompt_files::prompt_file_path; + +pub struct WorkingDirService; + +impl WorkingDirService { + /// 核心切换逻辑 + pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> { + // ═══ 前置检查 ═══ + // 1. 检查代理接管状态,若活跃则拒绝切换 + // 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active + // (因为 ProxyService::is_running() 是 async,而此函数是 sync) + Self::check_proxy_not_active(state)?; + + // ═══ Phase 1: 回填 Prompt ═══ + // 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt + // 这样即使用户手动编辑了 live 文件,内容也不会丢失 + Self::backfill_prompt_content(state)?; + + // ═══ Phase 2: 数据库操作(事务) ═══ + { + let conn = lock_conn!(state.db.conn); + conn.execute("BEGIN IMMEDIATE", [])?; + + let result = (|| -> Result<(), AppError> { + // 获取当前工作目录 + let current = Self::get_current_dir_id_on_conn(&conn)?; + + // 保存当前状态到旧目录 + if let Some(old_id) = ¤t { + Database::snapshot_providers_on_conn(&conn, old_id)?; + Database::snapshot_mcp_on_conn(&conn, old_id)?; + Database::snapshot_skills_on_conn(&conn, old_id)?; + Database::snapshot_prompts_on_conn(&conn, old_id)?; + } else { + // 无当前目录 = 全局模式,保存到 __default__ + Database::snapshot_providers_on_conn(&conn, "__default__")?; + Database::snapshot_mcp_on_conn(&conn, "__default__")?; + Database::snapshot_skills_on_conn(&conn, "__default__")?; + Database::snapshot_prompts_on_conn(&conn, "__default__")?; + } + + // 加载目标目录快照(如果有的话) + // 如果无快照(首次进入),保持主表不变 + Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?; + Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?; + + // 更新 is_current 标记 + Database::set_current_working_directory_on_conn(&conn, target_dir_id)?; + + Ok(()) + })(); + + match result { + Ok(()) => conn.execute("COMMIT", [])?, + Err(e) => { + let _ = conn.execute("ROLLBACK", []); + return Err(e); + } + }; + } + // conn 锁在此处释放 + + // ═══ Phase 3: 同步 live 配置文件 ═══ + Self::sync_all_live(state)?; + + // ═══ Phase 4: 清除 Provider Health ═══ + state.db.clear_all_provider_health()?; + + Ok(()) + } + + /// 回填 live prompt 文件内容到 DB(切换前调用) + fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + if !path.exists() { continue; } + let live_content = std::fs::read_to_string(&path).unwrap_or_default(); + if live_content.trim().is_empty() { continue; } + + let mut prompts = state.db.get_prompts(app.as_str())?; + if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) { + prompt.content = live_content; + prompt.updated_at = Some(get_unix_timestamp()?); + state.db.save_prompt(app.as_str(), prompt)?; + } + } + Ok(()) + } + + /// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用) + /// 注意:不做回填!只写入。区别于 PromptService::enable_prompt() + fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> { + for app in AppType::all() { + let path = prompt_file_path(&app)?; + let prompts = state.db.get_prompts(app.as_str())?; + if let Some(prompt) = prompts.values().find(|p| p.enabled) { + write_text_file(&path, &prompt.content)?; + } + // 无 enabled prompt 时不清空文件(保留现状) + } + Ok(()) + } + + /// 同步所有 live 配置(Provider + MCP + Skill + Prompt) + fn sync_all_live(state: &AppState) -> Result<(), AppError> { + // 1. Provider → live files + ProviderService::sync_current_to_live(state)?; + // sync_current_to_live 内部已调用 McpService::sync_all_enabled() + + // 2. Skills → app dirs (循环每个 app) + for app in AppType::all() { + let _ = SkillService::sync_to_app(&state.db, &app); + } + + // 3. Prompts → live files + Self::write_prompts_to_live(state)?; + + Ok(()) + } + + /// 检查代理是否活跃(同步检查数据库标志) + fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> { + // 检查 proxy_config 表中 live_takeover_active 列 + // 如果有任何 app 的 live_takeover_active = 1,拒绝切换 + let conn = lock_conn!(state.db.conn); + let active: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)", + [], |r| r.get(0) + ).unwrap_or(false); + + if active { + return Err(AppError::Message( + "代理接管模式运行中,请先停止代理再切换工作目录".into() + )); + } + Ok(()) + } +} +``` + +### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs` + +遵循现有模式:`State<'_, AppState>` + `Result` + `.map_err(|e| e.to_string())`。 + +```rust +#[tauri::command] +pub fn list_working_directories(state: State<'_, AppState>) -> Result, String> + +#[tauri::command] +pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option) -> Result + +#[tauri::command] +pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> + +#[tauri::command] +pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String> + +#[tauri::command] +pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> +// 调用 WorkingDirService::switch() + +#[tauri::command] +pub fn get_current_working_directory(state: State<'_, AppState>) -> Result, String> +``` + +### 3.4 需修改的现有文件 + +| 文件 | 修改内容 | +|------|---------| +| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` | +| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao | +| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` | +| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` | +| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` | +| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 | + +### 3.5 可能需要新增的 DAO 辅助方法 + +`src-tauri/src/database/dao/failover.rs`: +```rust +/// 清除所有 provider_health 记录(切换目录时调用) +pub fn clear_all_provider_health(&self) -> Result<(), AppError> +``` + +--- + +## 四、前端实现 + +### 4.1 API — `src/lib/api/workingDir.ts` + +```typescript +import { invoke } from "@tauri-apps/api/core"; + +export interface WorkingDirectory { + id: string; + path: string; + name?: string; + isCurrent: boolean; + createdAt: number; +} + +export const workingDirApi = { + list: () => invoke("list_working_directories"), + add: (path: string, name?: string) => + invoke("add_working_directory", { path, name }), + delete: (id: string) => invoke("delete_working_directory", { id }), + rename: (id: string, name: string) => + invoke("rename_working_directory", { id, name }), + switch: (id: string) => invoke("switch_working_directory", { id }), + getCurrent: () => + invoke("get_current_working_directory"), +}; +``` + +### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx` + +**位置**:Header toolbar,靠近 AppSwitcher。 + +**功能**: +- 下拉菜单显示已注册目录列表 +- 当前目录高亮 +- "浏览…" 按钮调用 Tauri 文件夹选择对话框 +- 右键菜单:重命名、删除 +- "__default__(全局)" 选项恢复到全局状态 +- 切换后 invalidate 所有相关 React Query + +**切换后的 Query Invalidation**: +```typescript +// 需要验证实际的 queryKey 名称 +queryClient.invalidateQueries({ queryKey: ["providers"] }); +queryClient.invalidateQueries({ queryKey: ["mcp-servers"] }); +queryClient.invalidateQueries({ queryKey: ["installed-skills"] }); +queryClient.invalidateQueries({ queryKey: ["prompts"] }); +queryClient.invalidateQueries({ queryKey: ["workingDirectories"] }); +``` + +### 4.3 i18n + +三个文件都需更新: +- `src/i18n/locales/zh.json` +- `src/i18n/locales/en.json` +- `src/i18n/locales/ja.json` + +--- + +## 五、切换流程时序 + +``` +用户选择目录 B + │ + ├── 1. check_proxy_not_active() + │ → 如果代理接管中,返回错误,终止 + │ + ├── 2. backfill_prompt_content() + │ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content + │ → 保护用户手动编辑的 prompt 不丢失 + │ + ├── 3. BEGIN TRANSACTION + │ ├── snapshot(old_dir / __default__) + │ │ ├── providers → dir_provider_state (is_current + in_failover_queue) + │ │ ├── mcp_servers → dir_mcp_state (4 列直接复制) + │ │ ├── skills → dir_skill_state (4 列直接复制) + │ │ └── prompts → dir_prompt_state (enabled prompt_id) + │ │ + │ ├── apply(target_dir) + │ │ ├── dir_provider_state → providers + │ │ ├── dir_mcp_state → mcp_servers + │ │ ├── dir_skill_state → skills + │ │ └── dir_prompt_state → prompts + │ │ + │ └── set_current_working_directory(target_dir) + │ + ├── COMMIT + │ + ├── 4. sync_all_live() + │ ├── ProviderService::sync_current_to_live(state) + │ │ └── 内部已调用 McpService::sync_all_enabled() + │ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) } + │ └── write_prompts_to_live() ← 无回填,直接写 + │ + └── 5. clear_all_provider_health() + → 清除运行时熔断器状态 +``` + +--- + +## 六、边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 | +| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 | +| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 | +| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 | +| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 | +| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 | +| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 | +| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 | +| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 | + +--- + +## 七、实施顺序 + +### Phase 1: 数据库 +1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()` +2. `database/mod.rs` — `SCHEMA_VERSION = 9` + 迁移分支 +3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体) +4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()` +5. `database/dao/mod.rs` — 注册模块 + +### Phase 2: 服务 + 命令 +6. `services/working_dir.rs` — `WorkingDirService::switch()` 等 +7. `commands/working_dir.rs` — 6 个 Tauri 命令 +8. `services/mod.rs` — 注册模块 +9. `commands/mod.rs` — 注册模块 +10. `lib.rs` — invoke_handler 注册 + +### Phase 3: 前端 +11. `src/lib/api/workingDir.ts` — API 封装 +12. `src/types.ts` — WorkingDirectory 类型 +13. `src/components/WorkingDirSwitcher.tsx` — UI 组件 +14. `src/App.tsx` — 集成到 header toolbar +15. `src/i18n/locales/{zh,en,ja}.json` — 国际化 + +### Phase 4: 优化(可选) +16. 启动恢复检查(DB 状态 vs live 文件一致性) +17. 托盘菜单显示当前工作目录 + +--- + +## 八、关键文件索引 + +### 新增文件(5 个) +- `src-tauri/src/database/dao/working_dir.rs` +- `src-tauri/src/services/working_dir.rs` +- `src-tauri/src/commands/working_dir.rs` +- `src/lib/api/workingDir.ts` +- `src/components/WorkingDirSwitcher.tsx` + +### 必须修改的文件(7 个) +- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移 +- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环 +- `src-tauri/src/database/dao/mod.rs` — 模块注册 +- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health +- `src-tauri/src/services/mod.rs` — 模块注册 +- `src-tauri/src/commands/mod.rs` — 模块注册 +- `src-tauri/src/lib.rs` — invoke_handler + +### 必须修改的前端文件(4 个) +- `src/App.tsx` — 集成 WorkingDirSwitcher +- `src/types.ts` — WorkingDirectory 接口 +- `src/i18n/locales/zh.json` — 中文 +- `src/i18n/locales/en.json` — 英文 +- `src/i18n/locales/ja.json` — 日文 + +### 参考文件(理解现有模式) +- `src-tauri/src/services/mcp.rs` — `sync_all_enabled()` (line 165) +- `src-tauri/src/services/skill.rs` — `sync_to_app()` (line 1707) +- `src-tauri/src/services/provider/mod.rs` — `sync_current_to_live()` (line 1552) +- `src-tauri/src/services/prompt.rs` — `enable_prompt()` (line 73) — 理解回填逻辑 +- `src-tauri/src/prompt_files.rs` — prompt 文件路径 +- `src-tauri/src/config.rs` — `write_text_file()` (line 176) + +--- + +## 九、验证计划 + +### 后端验证 +1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`) + - 快照/恢复往返一致性 + - 新增/删除实体后的 apply 行为 + - `__default__` 全局状态保护 + - 事务回滚测试 +2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化 + +### 前端验证 +1. `pnpm typecheck` — TypeScript 类型检查 +2. `pnpm lint` — ESLint 检查 +3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新 From c4458cf28086cf37b5b61770a3a189faab2d6afc Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:08:09 +0800 Subject: [PATCH 073/128] fix: update tests for InstalledSkill new fields and missing hook mocks - Add content_hash and updated_at fields to 4 InstalledSkill literals in skill_sync.rs - Add useCheckSkillUpdates and useUpdateSkill to UnifiedSkillsPanel test mock - Suppress unused import warning in auto_launch.rs test module --- src-tauri/src/auto_launch.rs | 1 + src-tauri/tests/skill_sync.rs | 8 ++++++++ tests/components/UnifiedSkillsPanel.test.tsx | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/src-tauri/src/auto_launch.rs b/src-tauri/src/auto_launch.rs index c69ae46ad..2f44e8971 100644 --- a/src-tauri/src/auto_launch.rs +++ b/src-tauri/src/auto_launch.rs @@ -70,6 +70,7 @@ pub fn is_auto_launch_enabled() -> Result { #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[cfg(target_os = "macos")] diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs index 3d4cf6814..b1e0c3a61 100644 --- a/src-tauri/tests/skill_sync.rs +++ b/src-tauri/tests/skill_sync.rs @@ -110,6 +110,8 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() { opencode: false, }, installed_at: 0, + content_hash: None, + updated_at: 0, }) .expect("save disabled skill"); @@ -154,6 +156,8 @@ fn uninstall_skill_creates_backup_before_removing_ssot() { opencode: false, }, installed_at: 123, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -222,6 +226,8 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() { opencode: false, }, installed_at: 456, + content_hash: None, + updated_at: 0, }) .expect("save skill"); @@ -303,6 +309,8 @@ fn delete_skill_backup_removes_backup_directory() { opencode: false, }, installed_at: 789, + content_hash: None, + updated_at: 0, }) .expect("save skill"); diff --git a/tests/components/UnifiedSkillsPanel.test.tsx b/tests/components/UnifiedSkillsPanel.test.tsx index 9fb641c49..38518ad9b 100644 --- a/tests/components/UnifiedSkillsPanel.test.tsx +++ b/tests/components/UnifiedSkillsPanel.test.tsx @@ -64,6 +64,15 @@ vi.mock("@/hooks/useSkills", () => ({ useInstallSkillsFromZip: () => ({ mutateAsync: installFromZipMock, }), + useCheckSkillUpdates: () => ({ + data: [], + refetch: vi.fn(), + isFetching: false, + }), + useUpdateSkill: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), })); describe("UnifiedSkillsPanel", () => { From b85e44994923471308c72d8d0e606ea0b220df5b Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:20:47 +0800 Subject: [PATCH 074/128] fix: guard migrations against missing tables and fix highlighted text assertion - Make migrate_v6_to_v7 check skills table existence before ALTER - Make migrate_v7_to_v8 check model_pricing table existence before UPDATE - Fix SessionManagerPage test: use getByRole heading instead of getAllByText which breaks when highlightText splits text across elements --- src-tauri/src/database/schema.rs | 67 ++++++++++++-------- tests/components/SessionManagerPage.test.tsx | 4 +- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 2a9e1ded7..95abaa8e0 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1072,8 +1072,15 @@ impl Database { /// v6 -> v7: Skills 更新检测支持(content_hash + updated_at) fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> { - Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?; - Self::add_column_if_missing(conn, "skills", "updated_at", "INTEGER NOT NULL DEFAULT 0")?; + if Self::table_exists(conn, "skills")? { + Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?; + Self::add_column_if_missing( + conn, + "skills", + "updated_at", + "INTEGER NOT NULL DEFAULT 0", + )?; + } log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列"); Ok(()) } @@ -1103,32 +1110,36 @@ impl Database { .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?; // 3. 修正国产模型定价:之前误将 CNY 值存为 USD 字段,统一转换为 USD - let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ - ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), - ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), - ("deepseek-v3", "0.28", "1.11", "0.028", "0"), - ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), - ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), - ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), - ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), - ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), - ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), - ("minimax-m2", "0.27", "0.95", "0.03", "0"), - ("glm-4.7", "0.39", "1.75", "0.04", "0"), - ("glm-4.6", "0.28", "1.11", "0.03", "0"), - ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), - ]; - for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { - conn.execute( - "UPDATE model_pricing SET - input_cost_per_million = ?2, - output_cost_per_million = ?3, - cache_read_cost_per_million = ?4, - cache_creation_cost_per_million = ?5 - WHERE model_id = ?1", - rusqlite::params![model_id, input, output, cache_read, cache_creation], - ) - .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?; + if Self::table_exists(conn, "model_pricing")? { + let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[ + ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"), + ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"), + ("deepseek-v3", "0.28", "1.11", "0.028", "0"), + ("doubao-seed-code", "0.17", "1.11", "0.02", "0"), + ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"), + ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"), + ("minimax-m2.1", "0.27", "0.95", "0.03", "0"), + ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"), + ("minimax-m2", "0.27", "0.95", "0.03", "0"), + ("glm-4.7", "0.39", "1.75", "0.04", "0"), + ("glm-4.6", "0.28", "1.11", "0.03", "0"), + ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"), + ]; + for (model_id, input, output, cache_read, cache_creation) in pricing_fixes { + conn.execute( + "UPDATE model_pricing SET + input_cost_per_million = ?2, + output_cost_per_million = ?3, + cache_read_cost_per_million = ?4, + cache_creation_cost_per_million = ?5 + WHERE model_id = ?1", + rusqlite::params![model_id, input, output, cache_read, cache_creation], + ) + .map_err(|e| { + AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) + })?; + } } log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价"); diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index a6e99115d..3b095ffd7 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -184,7 +184,9 @@ describe("SessionManagerPage", () => { }); await waitFor(() => - expect(screen.getAllByText("Alpha Session")).toHaveLength(2), + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), ); fireEvent.click(screen.getByRole("button", { name: /删除会话/i })); From 489c7c75eab5f7402303df733120bdaa84513109 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 10 Apr 2026 11:23:08 +0800 Subject: [PATCH 075/128] style: apply cargo fmt to schema migration code --- src-tauri/src/database/schema.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 95abaa8e0..d755116fd 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -1136,9 +1136,7 @@ impl Database { WHERE model_id = ?1", rusqlite::params![model_id, input, output, cache_read, cache_creation], ) - .map_err(|e| { - AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")) - })?; + .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?; } } From 68df60bd4a61833fe022c932af618f66cbc78601 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:47:18 +0800 Subject: [PATCH 076/128] feat(provider): add TheRouter presets for OpenCode and OpenClaw (#1892) Co-authored-by: max --- src/config/openclawProviderPresets.ts | 66 +++++++++++++++++++ src/config/opencodeProviderPresets.ts | 31 +++++++++ .../therouterOpenCodeOpenClawPresets.test.ts | 50 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/config/therouterOpenCodeOpenClawPresets.test.ts diff --git a/src/config/openclawProviderPresets.ts b/src/config/openclawProviderPresets.ts index cf9ca37c5..965c4a6a3 100644 --- a/src/config/openclawProviderPresets.ts +++ b/src/config/openclawProviderPresets.ts @@ -719,6 +719,72 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + baseUrl: "https://api.therouter.ai/v1", + apiKey: "", + api: "openai-completions", + models: [ + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextWindow: 1000000, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }, + { + id: "openai/gpt-5.3-codex", + name: "GPT-5.3 Codex", + contextWindow: 400000, + cost: { input: 5, output: 40, cacheRead: 0.5 }, + }, + { + id: "openai/gpt-5.2", + name: "GPT-5.2", + contextWindow: 400000, + cost: { input: 1.75, output: 14, cacheRead: 0.175 }, + }, + { + id: "google/gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + contextWindow: 1000000, + cost: { input: 0.5, output: 3, cacheRead: 0.05 }, + }, + { + id: "qwen/qwen3-coder-480b", + name: "Qwen3 Coder 480B", + contextWindow: 262144, + cost: { input: 0.6, output: 2.35 }, + }, + ], + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + suggestedDefaults: { + model: { + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }, + modelCatalog: { + "therouter/anthropic/claude-sonnet-4.6": { alias: "Sonnet" }, + "therouter/openai/gpt-5.2": { alias: "GPT-5.2" }, + "therouter/google/gemini-3-flash-preview": { alias: "Gemini Flash" }, + "therouter/openai/gpt-5.3-codex": { alias: "Codex" }, + "therouter/qwen/qwen3-coder-480b": { alias: "Qwen Coder" }, + }, + }, + }, { name: "ModelScope", websiteUrl: "https://modelscope.cn", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 18a830fca..9696ad086 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -879,6 +879,37 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "TheRouter", + options: { + baseURL: "https://api.therouter.ai/v1", + apiKey: "", + setCacheKey: true, + }, + models: { + "anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, + "openai/gpt-5.3-codex": { name: "GPT-5.3 Codex" }, + "openai/gpt-5.2": { name: "GPT-5.2" }, + "google/gemini-3-flash-preview": { + name: "Gemini 3 Flash Preview", + }, + "qwen/qwen3-coder-480b": { name: "Qwen3 Coder 480B" }, + }, + }, + category: "aggregator", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "sk-...", + editorValue: "", + }, + }, + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/tests/config/therouterOpenCodeOpenClawPresets.test.ts b/tests/config/therouterOpenCodeOpenClawPresets.test.ts new file mode 100644 index 000000000..29279f9d7 --- /dev/null +++ b/tests/config/therouterOpenCodeOpenClawPresets.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { opencodeProviderPresets } from "@/config/opencodeProviderPresets"; +import { openclawProviderPresets } from "@/config/openclawProviderPresets"; + +describe("TheRouter OpenCode and OpenClaw presets", () => { + it("uses OpenAI-compatible config for OpenCode", () => { + const preset = opencodeProviderPresets.find((item) => item.name === "TheRouter"); + const models = preset?.settingsConfig.models ?? {}; + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.npm).toBe("@ai-sdk/openai-compatible"); + expect(preset?.settingsConfig.options?.baseURL).toBe( + "https://api.therouter.ai/v1", + ); + expect(preset?.settingsConfig.options?.setCacheKey).toBe(true); + expect(models).toHaveProperty("openai/gpt-5.3-codex"); + expect(models).toHaveProperty("anthropic/claude-sonnet-4.6"); + expect(models).toHaveProperty("google/gemini-3-flash-preview"); + }); + + it("uses OpenAI completions config for OpenClaw", () => { + const preset = openclawProviderPresets.find((item) => item.name === "TheRouter"); + const modelIds = (preset?.settingsConfig.models ?? []).map((model) => model.id); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.settingsConfig.baseUrl).toBe("https://api.therouter.ai/v1"); + expect(preset?.settingsConfig.api).toBe("openai-completions"); + expect(modelIds).toEqual( + expect.arrayContaining([ + "anthropic/claude-sonnet-4.6", + "openai/gpt-5.3-codex", + "openai/gpt-5.2", + "google/gemini-3-flash-preview", + ]), + ); + expect(preset?.suggestedDefaults?.model).toEqual({ + primary: "therouter/anthropic/claude-sonnet-4.6", + fallbacks: [ + "therouter/openai/gpt-5.2", + "therouter/google/gemini-3-flash-preview", + ], + }); + }); +}); From 7f1963ab493d02c71ec936b2ed72c05a31434809 Mon Sep 17 00:00:00 2001 From: Max <6111715+cmzz@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:48:14 +0800 Subject: [PATCH 077/128] feat(provider): add TheRouter presets for Claude, Codex, and Gemini (#1891) * feat(provider): add TheRouter presets for Claude and Codex * feat(provider): add TheRouter Gemini preset --------- Co-authored-by: max --- src/config/claudeProviderPresets.ts | 18 +++++ src/config/codexProviderPresets.ts | 13 ++++ src/config/geminiProviderPresets.ts | 16 +++++ tests/config/therouterProviderPresets.test.ts | 66 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 tests/config/therouterProviderPresets.test.ts diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 94f9f60e7..96353e626 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -671,6 +671,24 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.therouter.ai", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "anthropic/claude-haiku-4.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "anthropic/claude-opus-4.6", + }, + }, + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "Novita AI", websiteUrl: "https://novita.ai", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index dd0a687fe..97a0375bc 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -365,4 +365,17 @@ requires_openai_auth = true`, icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "therouter", + "https://api.therouter.ai/v1", + "openai/gpt-5.3-codex", + ), + endpointCandidates: ["https://api.therouter.ai/v1"], + category: "aggregator", + }, ]; diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index aaff2e257..4b7339b5e 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -241,6 +241,22 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "TheRouter", + websiteUrl: "https://therouter.ai", + apiKeyUrl: "https://dashboard.therouter.ai", + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://api.therouter.ai", + GEMINI_MODEL: "gemini-2.5-pro", + }, + }, + baseURL: "https://api.therouter.ai", + model: "gemini-2.5-pro", + description: "TheRouter", + category: "aggregator", + endpointCandidates: ["https://api.therouter.ai"], + }, { name: "自定义", websiteUrl: "", diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts new file mode 100644 index 000000000..97b77a24c --- /dev/null +++ b/tests/config/therouterProviderPresets.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { providerPresets } from "@/config/claudeProviderPresets"; +import { codexProviderPresets } from "@/config/codexProviderPresets"; +import { geminiProviderPresets } from "@/config/geminiProviderPresets"; + +describe("TheRouter provider presets", () => { + it("uses the Anthropic-compatible root endpoint for Claude", () => { + const preset = providerPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe(""); + expect(env.ANTHROPIC_API_KEY).toBe(""); + expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-4.6"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe( + "anthropic/claude-haiku-4.5", + ); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe( + "anthropic/claude-sonnet-4.6", + ); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( + "anthropic/claude-opus-4.6", + ); + }); + + it("uses the OpenAI-compatible v1 endpoint for Codex", () => { + const preset = codexProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual([ + "https://api.therouter.ai/v1", + ]); + expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" }); + expect(preset?.config).toContain('model_provider = "therouter"'); + expect(preset?.config).toContain('model = "openai/gpt-5.3-codex"'); + expect(preset?.config).toContain( + 'base_url = "https://api.therouter.ai/v1"', + ); + expect(preset?.config).toContain('wire_api = "responses"'); + }); + + it("uses the Gemini-native root endpoint for Gemini", () => { + const preset = geminiProviderPresets.find((item) => item.name === "TheRouter"); + + expect(preset).toBeDefined(); + expect(preset?.websiteUrl).toBe("https://therouter.ai"); + expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); + expect(preset?.category).toBe("aggregator"); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]); + expect(preset?.baseURL).toBe("https://api.therouter.ai"); + expect(preset?.model).toBe("gemini-2.5-pro"); + + const env = (preset?.settingsConfig as { env: Record }).env; + expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai"); + expect(env.GEMINI_MODEL).toBe("gemini-2.5-pro"); + }); +}); From afdda27bd5ca9585945f780e47749450e998032e Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:24:25 +0800 Subject: [PATCH 078/128] Restore auth tab localization in settings (#1985) The settings page already routes the auth tab label through the shared i18n key, but the locale bundles never defined that key. Adding the missing entries fixes the label with the same simple pattern used by the other tabs. Constraint: Keep the fix aligned with the existing settings-tab i18n flow Rejected: Add component-level bilingual rendering | unnecessary for a missing translation key Confidence: high Scope-risk: narrow Reversibility: clean Directive: When adding settings tabs, define locale keys in every bundled language before relying on fallback text Tested: pnpm format:check; pnpm typecheck Not-tested: Manual verification in the desktop UI --- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + 3 files changed, 3 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5a0f16b42..b03a54c65 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -243,6 +243,7 @@ "title": "Settings", "general": "General", "tabGeneral": "General", + "tabAuth": "Auth", "tabAdvanced": "Advanced", "tabProxy": "Proxy", "authCenter": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bb0c8bf43..90409338d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -243,6 +243,7 @@ "title": "設定", "general": "一般", "tabGeneral": "一般", + "tabAuth": "認証", "tabAdvanced": "詳細", "tabProxy": "プロキシ", "authCenter": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 8696647dd..66128f02d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -243,6 +243,7 @@ "title": "设置", "general": "通用", "tabGeneral": "通用", + "tabAuth": "认证", "tabAdvanced": "高级", "tabProxy": "代理", "authCenter": { From 8c32610a7de81fd8e9b8cc0e6df971e3cf887349 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:34:18 +0800 Subject: [PATCH 079/128] Align Thinking fallback with main-model-only Claude mappings (#1984) The Claude provider form reopened with an empty Thinking model after users saved only a main model. This updates model-state hydration to mirror the existing Haiku-style fallback semantics: read ANTHROPIC_REASONING_MODEL when present, otherwise display ANTHROPIC_MODEL, without writing a synthetic reasoning field back into config. Constraint: Existing Haiku, Sonnet, and Opus selectors already rely on read-time fallback behavior Rejected: Persist ANTHROPIC_REASONING_MODEL from ANTHROPIC_MODEL automatically | would diverge from Haiku behavior and silently rewrite saved config Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Thinking fallback read-only unless all model-mapping fields are intentionally migrated to write-through semantics Tested: pnpm typecheck Tested: pnpm test:unit (1 unrelated pre-existing failure in tests/components/UnifiedSkillsPanel.test.tsx mock setup) Not-tested: Manual add-provider reopen flow in the desktop UI --- .../providers/forms/hooks/useModelState.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 46a1c6d7e..01278c4b5 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -14,10 +14,11 @@ function parseModelsFromConfig(settingsConfig: string) { const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -92,10 +93,11 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; - const reasoning = + const explicitReasoning = typeof env.ANTHROPIC_REASONING_MODEL === "string" ? env.ANTHROPIC_REASONING_MODEL : ""; + const reasoning = explicitReasoning || model; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -148,16 +150,17 @@ export function useModelState({ ? JSON.parse(settingsConfig) : { env: {} }; if (!currentConfig.env) currentConfig.env = {}; + const env = currentConfig.env as Record; // 新键仅写入;旧键不再写入 const trimmed = value.trim(); if (trimmed) { - currentConfig.env[field] = trimmed; + env[field] = trimmed; } else { - delete currentConfig.env[field]; + delete env[field]; } // 删除旧键 - delete currentConfig.env["ANTHROPIC_SMALL_FAST_MODEL"]; + delete env["ANTHROPIC_SMALL_FAST_MODEL"]; onConfigChange(JSON.stringify(currentConfig, null, 2)); } catch (err) { From e4b58c72065dfb3d509091763dc9e525ba37cc4d Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:35:16 +0800 Subject: [PATCH 080/128] Let Kaku users launch sessions from their chosen terminal (#1954) (#1983) Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows. Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests Not-tested: End-to-end launch against a locally installed Kaku.app Related: #1954 --- src-tauri/src/commands/misc.rs | 1 + src-tauri/src/session_manager/terminal/mod.rs | 94 +++++++++++++++---- src-tauri/src/settings.rs | 2 +- src/components/settings/TerminalSettings.tsx | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/types.ts | 2 +- 8 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 5001e1f37..623166e5a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -948,6 +948,7 @@ exec bash --norc --noprofile "kitty" => launch_macos_open_app("kitty", &script_file, false), "ghostty" => launch_macos_open_app("Ghostty", &script_file, true), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), + "kaku" => launch_macos_open_app("Kaku", &script_file, true), _ => launch_macos_terminal_app(&script_file), // "terminal" or default }; diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index ab13466ba..3e35df30e 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -20,6 +20,7 @@ pub fn launch_terminal( "ghostty" => launch_ghostty(command, cwd), "kitty" => launch_kitty(command, cwd), "wezterm" => launch_wezterm(command, cwd), + "kaku" => launch_kaku(command, cwd), "alacritty" => launch_alacritty(command, cwd), "custom" => launch_custom(command, cwd, custom_config), _ => Err(format!("Unsupported terminal target: {target}")), @@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> { fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { // wezterm start --cwd ... -- command // To invoke via `open`, we use `open -na "WezTerm" --args start ...` - - let full_command = build_shell_command(command, None); - - let mut args = vec!["-na", "WezTerm", "--args", "start"]; - - if let Some(dir) = cwd { - args.push("--cwd"); - args.push(dir); - } - - // Invoke shell to run the command string (to handle pipes, etc) - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - args.push("--"); - args.push(&shell); - args.push("-c"); - args.push(&full_command); + let args = build_wezterm_compatible_args("WezTerm", command, cwd); let status = Command::new("open") - .args(&args) + .args(args.iter().map(String::as_str)) .status() .map_err(|e| format!("Failed to launch WezTerm: {e}"))?; @@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> { } } +fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> { + // Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint. + let args = build_wezterm_compatible_args("Kaku", command, cwd); + + let status = Command::new("open") + .args(args.iter().map(String::as_str)) + .status() + .map_err(|e| format!("Failed to launch Kaku: {e}"))?; + + if status.success() { + Ok(()) + } else { + Err("Failed to launch Kaku.".to_string()) + } +} + +fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell) +} + +fn build_wezterm_compatible_args_with_shell( + app_name: &str, + command: &str, + cwd: Option<&str>, + shell: &str, +) -> Vec { + let full_command = build_shell_command(command, None); + let mut args = vec![ + "-na".to_string(), + app_name.to_string(), + "--args".to_string(), + "start".to_string(), + ]; + + if let Some(dir) = cwd { + args.push("--cwd".to_string()); + args.push(dir.to_string()); + } + + // Invoke shell to run the command string (to handle pipes, etc) + args.push("--".to_string()); + args.push(shell.to_string()); + args.push("-c".to_string()); + args.push(full_command); + args +} + fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> { // Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command let full_command = build_shell_command(command, None); @@ -305,4 +339,30 @@ mod tests { "raw:echo foo\\\\\\\\bar\\npwd\\n" ); } + + #[test] + fn wezterm_compatible_terminals_use_start_and_cwd_arguments() { + let args = build_wezterm_compatible_args_with_shell( + "Kaku", + "claude --resume abc-123", + Some("/tmp/project dir"), + "/bin/zsh", + ); + + assert_eq!( + args, + vec![ + "-na".to_string(), + "Kaku".to_string(), + "--args".to_string(), + "start".to_string(), + "--cwd".to_string(), + "/tmp/project dir".to_string(), + "--".to_string(), + "/bin/zsh".to_string(), + "-c".to_string(), + "claude --resume abc-123".to_string(), + ] + ); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 22e415a4c..c9b4a4b41 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -273,7 +273,7 @@ pub struct AppSettings { // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) - /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/components/settings/TerminalSettings.tsx b/src/components/settings/TerminalSettings.tsx index c1d62a158..4cf43d418 100644 --- a/src/components/settings/TerminalSettings.tsx +++ b/src/components/settings/TerminalSettings.tsx @@ -16,6 +16,7 @@ const MACOS_TERMINALS = [ { value: "kitty", labelKey: "settings.terminal.options.macos.kitty" }, { value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" }, { value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" }, + { value: "kaku", labelKey: "settings.terminal.options.macos.kaku" }, ] as const; const WINDOWS_TERMINALS = [ diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b03a54c65..8e3040bcf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "Command Prompt", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 90409338d..e8bb0238d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "コマンドプロンプト", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 66128f02d..00990ace8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -536,7 +536,8 @@ "alacritty": "Alacritty", "kitty": "Kitty", "ghostty": "Ghostty", - "wezterm": "WezTerm" + "wezterm": "WezTerm", + "kaku": "Kaku" }, "windows": { "cmd": "命令提示符", diff --git a/src/types.ts b/src/types.ts index c1b674418..90cb06a22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -313,7 +313,7 @@ export interface Settings { // ===== 终端设置 ===== // 首选终端应用(可选,默认使用系统默认终端) - // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" + // macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku" // Windows: "cmd" | "powershell" | "wt" // Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" preferredTerminal?: string; From 3aef5217cbe4796f4e03e8f2c65857b0d4460c26 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Fri, 10 Apr 2026 22:36:32 +0800 Subject: [PATCH 081/128] Restore first-class OMO Slim council support (#1981) (#1982) cc-switch could already persist arbitrary OMO Slim agent keys and top-level fields, but the built-in metadata and UI copy still reflected the pre-council agent set. This made the upstream council feature look unsupported and pushed users toward manual JSON-only setup. Promote council to a built-in OMO Slim agent, add copy that points top-level plugin settings at Other Fields, and lock the behavior with regression tests. Constraint: oh-my-opencode-slim exposes council through both agents.council and top-level council config Rejected: Add a dedicated council editor UI now | too much surface area for issue #1981 Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep OMO Slim built-in agent metadata aligned with upstream agent additions before shipping UI support Tested: pnpm exec vitest run tests/utils/omoConfig.test.ts tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts Tested: pnpm typecheck Not-tested: End-to-end validation against a live oh-my-opencode-slim installation Related: farion1231/cc-switch#1981 --- .../providers/forms/OmoFormFields.tsx | 22 ++++++++--- src/i18n/locales/en.json | 7 +++- src/i18n/locales/ja.json | 7 +++- src/i18n/locales/zh.json | 7 +++- src/types/omo.ts | 9 +++++ tests/utils/omoConfig.test.ts | 38 +++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx index 52cced9cb..c1a316515 100644 --- a/src/components/providers/forms/OmoFormFields.tsx +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -1264,12 +1264,22 @@ export function OmoFormFields({ ) : undefined, maxHeightClass: "max-h-[500px]", children: ( -