From c12364a940786e5a3626d20a1566706a50178825 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 12 May 2026 21:31:30 +0800 Subject: [PATCH] feat(claude-desktop): rework Claude Code import flow - Derive route keys from the upstream model name (pass-through style) instead of fixed Claude aliases, and translate the legacy [1M] suffix into the supports1m field at the import boundary. Three Claude aliases mapped to the same upstream now collapse to a single route (e.g. MiniMax-M2 across SONNET/OPUS/HAIKU env produces one claude-MiniMax-M2 -> MiniMax-M2 row), with [1M] OR-aggregated. - Add an import-time safety net that rebuilds claude-desktop-official when missing, so users who deleted it can recover via the normal import button without losing customizations on other providers. - Hide API key and endpoint URL inputs in the official provider edit form to mirror Claude Code's behavior and prevent user confusion. - Reword the empty-state import button label for clarity. --- src-tauri/src/claude_desktop_config.rs | 94 ++- src-tauri/src/commands/provider.rs | 251 ++++++- src-tauri/src/database/dao/providers.rs | 134 ++++ .../providers/ProviderEmptyState.tsx | 2 +- .../forms/ClaudeDesktopProviderForm.tsx | 711 ++++++++++-------- src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- 8 files changed, 840 insertions(+), 361 deletions(-) diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 9c11d9ad1..1280551d0 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -22,6 +22,13 @@ const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token"; const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop"; const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z"; +/// Claude Desktop 模型菜单识别的 route ID 前缀。 +pub const CLAUDE_ROUTE_PREFIX: &str = "claude-"; +/// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。 +pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-"; +/// cc-switch 历史约定的 1M 上下文标记(ASCII 小写形式,匹配时用 `eq_ignore_ascii_case`)。 +pub const LEGACY_ONE_M_MARKER: &str = "[1m]"; + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClaudeDesktopDefaultRoute { @@ -210,8 +217,49 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode { pub fn is_claude_safe_model_id(model: &str) -> bool { let normalized = model.trim().to_ascii_lowercase(); - (normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-")) - && !normalized.contains("[1m]") + (normalized.starts_with(CLAUDE_ROUTE_PREFIX) + || normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX)) + && !normalized.contains(LEGACY_ONE_M_MARKER) +} + +/// 把上游 model 名标准化为 Claude Desktop 路由表 key(即 UI 左列内容)。 +/// 复现前端 `desktopRouteIdFromModel` + `normalizeDesktopRouteSuffix` 的规则: +/// 1. trim +/// 2. 剥离已有 `anthropic/claude-` 或 `claude-` 前缀(长前缀优先匹配) +/// 3. 去 `models/` 前缀(Gemini 风格 `models/gemini-2.0-flash`) +/// 4. 把空格 / 制表 / 正反斜杠替换成 `-` +/// 5. 拼上 `claude-` 前缀 +/// +/// 示例: +/// "MiniMax-M2" → "claude-MiniMax-M2" +/// "models/gemini-2.0-flash" → "claude-gemini-2.0-flash" +/// "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5-20250929"(剥后再拼,不双重) +/// "anthropic/claude-3-5-sonnet" → "claude-3-5-sonnet"(与前端一致:anthropic/claude- 被替换为 claude-) +pub fn derive_desktop_route_id(upstream_model: &str) -> String { + let trimmed = upstream_model.trim(); + let lower = trimmed.to_ascii_lowercase(); + let suffix = if lower.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX) { + &trimmed[ANTHROPIC_CLAUDE_ROUTE_PREFIX.len()..] + } else if lower.starts_with(CLAUDE_ROUTE_PREFIX) { + &trimmed[CLAUDE_ROUTE_PREFIX.len()..] + } else { + trimmed + }; + let without_models_prefix = suffix + .strip_prefix("models/") + .or_else(|| suffix.strip_prefix("Models/")) + .unwrap_or(suffix); + let normalized: String = without_models_prefix + .chars() + .map(|c| { + if matches!(c, ' ' | '\t' | '/' | '\\') { + '-' + } else { + c + } + }) + .collect(); + format!("{CLAUDE_ROUTE_PREFIX}{normalized}") } fn inference_model_json(spec: &InferenceModelSpec) -> Value { @@ -1386,4 +1434,46 @@ mod tests { ); assert!(!is_compatible_direct_provider(&missing_bearer)); } + + // === derive_desktop_route_id === + + #[test] + fn derive_basic_adds_prefix() { + assert_eq!(derive_desktop_route_id("MiniMax-M2"), "claude-MiniMax-M2"); + } + + #[test] + fn derive_strips_gemini_prefix() { + assert_eq!( + derive_desktop_route_id("models/gemini-2.0-flash"), + "claude-gemini-2.0-flash" + ); + } + + #[test] + fn derive_keeps_existing_claude_prefix() { + assert_eq!( + derive_desktop_route_id("claude-sonnet-4-5-20250929"), + "claude-sonnet-4-5-20250929" + ); + } + + #[test] + fn derive_strips_anthropic_claude_prefix_uses_claude() { + // 与前端 normalizeDesktopRouteSuffix 一致:剥离 anthropic/claude- 前缀后用 claude- 替代 + assert_eq!( + derive_desktop_route_id("anthropic/claude-3-5-sonnet"), + "claude-3-5-sonnet" + ); + } + + #[test] + fn derive_replaces_whitespace_and_slash() { + assert_eq!(derive_desktop_route_id("my model/v2"), "claude-my-model-v2"); + } + + #[test] + fn derive_preserves_case_and_dot() { + assert_eq!(derive_desktop_route_id("GLM-4.6"), "claude-GLM-4.6"); + } } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index fbba2560c..af133582a 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -206,6 +206,16 @@ pub fn import_claude_desktop_providers_from_claude( imported += 1; } + // Safety net: 用户可能手动删除过 claude-desktop-official seed。 + // 用户主动点 import 是"重新整理 ClaudeDesktop 表"的隐式信号,把官方入口补回来。 + // 失败只 warn,不影响 imported 主流程;imported 计数语义保持纯净。 + if let Err(e) = state.db.ensure_official_seed_by_id( + crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + AppType::ClaudeDesktop, + ) { + log::warn!("Failed to ensure claude-desktop-official seed during import: {e}"); + } + Ok(imported) } @@ -231,7 +241,7 @@ fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { .all(crate::claude_desktop_config::is_claude_safe_model_id) } -fn suggested_claude_desktop_routes( +pub(crate) fn suggested_claude_desktop_routes( provider: &Provider, ) -> Option> { let env = provider @@ -239,7 +249,7 @@ fn suggested_claude_desktop_routes( .get("env") .and_then(|value| value.as_object())?; let mut routes = std::collections::HashMap::new(); - let supports_1m = !matches!( + let supports_1m_default = !matches!( provider .meta .as_ref() @@ -250,39 +260,54 @@ fn suggested_claude_desktop_routes( fn add_route( routes: &mut std::collections::HashMap, env: &serde_json::Map, - route_id: &str, env_key: &str, - supports_1m: bool, + supports_1m_default: bool, ) { - if let Some(model) = env + let Some(raw_model) = env .get(env_key) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) - { - routes.insert( - route_id.to_string(), - crate::provider::ClaudeDesktopModelRoute { - model: model.to_string(), - supports_1m: Some(supports_1m), - }, - ); + else { + return; + }; + + // Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀, + // 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。 + let marker = crate::claude_desktop_config::LEGACY_ONE_M_MARKER.as_bytes(); + let raw_bytes = raw_model.as_bytes(); + let has_1m_marker = raw_bytes.len() >= marker.len() + && raw_bytes[raw_bytes.len() - marker.len()..].eq_ignore_ascii_case(marker); + let stripped_model: &str = if has_1m_marker { + raw_model[..raw_model.len() - marker.len()].trim_end() + } else { + raw_model + }; + if stripped_model.is_empty() { + return; } + let effective_supports_1m = supports_1m_default || has_1m_marker; + let route_key = crate::claude_desktop_config::derive_desktop_route_id(stripped_model); + + routes + .entry(route_key) + .and_modify(|existing| { + let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m; + existing.supports_1m = Some(merged); + }) + .or_insert_with(|| crate::provider::ClaudeDesktopModelRoute { + model: stripped_model.to_string(), + supports_1m: Some(effective_supports_1m), + }); } for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { - add_route(&mut routes, env, spec.route_id, spec.env_key, supports_1m); + add_route(&mut routes, env, spec.env_key, supports_1m_default); } - let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0]; - if !routes.contains_key(primary_route.route_id) { - add_route( - &mut routes, - env, - primary_route.route_id, - "ANTHROPIC_MODEL", - supports_1m, - ); + // 三个 default env_key 全空时用 ANTHROPIC_MODEL 派生兜底路由。 + if routes.is_empty() { + add_route(&mut routes, env, "ANTHROPIC_MODEL", supports_1m_default); } (!routes.is_empty()).then_some(routes) @@ -672,3 +697,183 @@ pub fn get_opencode_live_provider_ids() -> Result, String> { // ============================================================================ // OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs // ============================================================================ + +#[cfg(test)] +mod import_claude_desktop_tests { + use super::suggested_claude_desktop_routes; + use crate::provider::{Provider, ProviderMeta}; + use serde_json::json; + + fn make_provider(env: serde_json::Value, provider_type: Option<&str>) -> Provider { + let mut p = Provider::with_id( + "test-claude".to_string(), + "Test".to_string(), + json!({ "env": env }), + None, + ); + if let Some(pt) = provider_type { + p.meta = Some(ProviderMeta { + provider_type: Some(pt.to_string()), + ..ProviderMeta::default() + }); + } + p + } + + #[test] + fn route_strips_1m_suffix_and_sets_supports_1m() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929[1M]", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + // env 已带 claude- 前缀,派生 key 不重复加前缀 + let r = routes + .get("claude-sonnet-4-5-20250929") + .expect("derived route present"); + assert_eq!(r.model, "claude-sonnet-4-5-20250929"); + assert!( + !r.model.to_ascii_lowercase().contains("[1m]"), + "model must not contain [1m] suffix" + ); + assert_eq!(r.supports_1m, Some(true)); + } + + #[test] + fn route_preserves_model_without_suffix() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + let r = routes.get("claude-kimi-k2").expect("derived route present"); + assert_eq!(r.model, "kimi-k2"); + // 默认 provider_type 缺省 → supports_1m_default = true + assert_eq!(r.supports_1m, Some(true)); + } + + #[test] + fn route_1m_suffix_overrides_provider_type_default() { + // github_copilot 默认 supports_1m_default = false,但 [1M] 后缀应强制 true + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex[1M]", + }), + Some("github_copilot"), + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + let r = routes + .get("claude-gpt-5-codex") + .expect("derived route present"); + assert_eq!(r.model, "gpt-5-codex"); + assert_eq!(r.supports_1m, Some(true)); + } + + #[test] + fn route_github_copilot_without_suffix_keeps_false() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex", + }), + Some("github_copilot"), + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + let r = routes + .get("claude-gpt-5-codex") + .expect("derived route present"); + assert_eq!(r.model, "gpt-5-codex"); + assert_eq!(r.supports_1m, Some(false)); + } + + #[test] + fn same_upstream_across_three_aliases_merges_to_one_route() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + assert_eq!(routes.len(), 1, "three aliases → one merged route"); + let r = routes + .get("claude-MiniMax-M2") + .expect("merged route present"); + assert_eq!(r.model, "MiniMax-M2"); + } + + #[test] + fn same_upstream_with_partial_1m_marker_takes_or_aggregation() { + // sonnet 带 [1M],opus/haiku 不带 → 合并后 supports_1m == Some(true) + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2[1M]", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + assert_eq!(routes.len(), 1); + let r = routes + .get("claude-MiniMax-M2") + .expect("merged route present"); + assert_eq!(r.supports_1m, Some(true)); + } + + #[test] + fn different_upstream_models_produce_separate_routes() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "GLM-4.6", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "GLM-4-Air", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "GLM-4-Flash", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + assert_eq!(routes.len(), 3); + assert_eq!(routes.get("claude-GLM-4.6").unwrap().model, "GLM-4.6"); + assert_eq!(routes.get("claude-GLM-4-Air").unwrap().model, "GLM-4-Air"); + assert_eq!( + routes.get("claude-GLM-4-Flash").unwrap().model, + "GLM-4-Flash" + ); + } + + #[test] + fn anthropic_model_fallback_only_triggers_when_empty() { + // 三个 default env_key 都不填,仅 ANTHROPIC_MODEL + let p = make_provider( + json!({ + "ANTHROPIC_MODEL": "kimi-k2", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + assert_eq!(routes.len(), 1); + let r = routes + .get("claude-kimi-k2") + .expect("fallback route present"); + assert_eq!(r.model, "kimi-k2"); + } + + #[test] + fn existing_claude_prefix_not_duplicated() { + let p = make_provider( + json!({ + "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929", + }), + None, + ); + let routes = suggested_claude_desktop_routes(&p).expect("routes built"); + // key 应是 "claude-sonnet-4-5-20250929",不出现双重 claude- 前缀 + assert!(routes.contains_key("claude-sonnet-4-5-20250929")); + assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929")); + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index f51b06299..b86611348 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -649,4 +649,138 @@ impl Database { Ok(inserted) } + + /// 按 id 兜底插入单条 official seed(仅当目标表中该 id 不存在时插入)。 + /// + /// 与 `init_default_official_providers` 不同: + /// - 不触碰 `official_providers_seeded` 全局 flag,是 on-demand 修复 + /// - 只处理一条 seed,由调用方决定 id + app_type + /// - 已存在则尊重用户自定义,不覆盖 + /// + /// 返回 Ok(true) 表示插入了新行,Ok(false) 表示已存在被跳过。 + pub fn ensure_official_seed_by_id( + &self, + seed_id: &str, + app_type: crate::app_config::AppType, + ) -> Result { + use crate::database::dao::providers_seed::OFFICIAL_SEEDS; + + let seed = OFFICIAL_SEEDS + .iter() + .find(|s| s.id == seed_id && s.app_type == app_type) + .ok_or_else(|| { + AppError::Database(format!( + "unknown official seed: id={seed_id}, app_type={}", + app_type.as_str() + )) + })?; + + let app_type_str = seed.app_type.as_str(); + + if self.get_provider_by_id(seed_id, app_type_str)?.is_some() { + return Ok(false); + } + + let settings_config: serde_json::Value = serde_json::from_str(seed.settings_config_json) + .map_err(|e| { + AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id)) + })?; + + let next_sort_index = self.next_sort_index_for_app(app_type_str)?; + let now_ms = chrono::Utc::now().timestamp_millis(); + + let mut provider = Provider::with_id( + seed.id.to_string(), + seed.name.to_string(), + settings_config, + Some(seed.website_url.to_string()), + ); + provider.category = Some("official".to_string()); + provider.icon = Some(seed.icon.to_string()); + provider.icon_color = Some(seed.icon_color.to_string()); + provider.sort_index = Some(next_sort_index); + provider.created_at = Some(now_ms); + + self.save_provider(app_type_str, &provider)?; + + Ok(true) + } +} + +#[cfg(test)] +mod ensure_official_seed_tests { + use crate::app_config::AppType; + use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID}; + + #[test] + fn ensure_inserts_when_missing() { + let db = Database::memory().expect("memory db"); + let inserted = db + .ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop) + .expect("ensure ok"); + assert!(inserted, "should insert when missing"); + + let provider = db + .get_provider_by_id( + CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + AppType::ClaudeDesktop.as_str(), + ) + .expect("query ok") + .expect("provider exists after ensure"); + + assert_eq!(provider.id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID); + assert_eq!(provider.name, "Claude Desktop Official"); + assert_eq!(provider.category.as_deref(), Some("official")); + assert_eq!(provider.icon.as_deref(), Some("anthropic")); + assert_eq!(provider.icon_color.as_deref(), Some("#D4915D")); + } + + #[test] + fn ensure_skips_when_present_and_preserves_customization() { + let db = Database::memory().expect("memory db"); + db.init_default_official_providers().expect("seed"); + + let mut renamed = db + .get_provider_by_id( + CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + AppType::ClaudeDesktop.as_str(), + ) + .expect("query ok") + .expect("seed present"); + renamed.name = "My Custom Backup".to_string(); + db.save_provider(AppType::ClaudeDesktop.as_str(), &renamed) + .expect("save customization"); + + let inserted = db + .ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop) + .expect("ensure ok"); + assert!(!inserted, "should skip when present"); + + let after = db + .get_provider_by_id( + CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + AppType::ClaudeDesktop.as_str(), + ) + .expect("query ok") + .expect("still present"); + assert_eq!( + after.name, "My Custom Backup", + "customization must not be overwritten" + ); + } + + #[test] + fn ensure_rejects_unknown_seed() { + let db = Database::memory().expect("memory db"); + let result = db.ensure_official_seed_by_id("nonexistent-id", AppType::ClaudeDesktop); + assert!(result.is_err(), "unknown seed id should be Err"); + } + + #[test] + fn ensure_rejects_seed_app_type_mismatch() { + let db = Database::memory().expect("memory db"); + let result = + db.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::Claude); + assert!(result.is_err(), "(id, app_type) mismatch should be Err"); + } } diff --git a/src/components/providers/ProviderEmptyState.tsx b/src/components/providers/ProviderEmptyState.tsx index ded2fed4c..648e9ab9e 100644 --- a/src/components/providers/ProviderEmptyState.tsx +++ b/src/components/providers/ProviderEmptyState.tsx @@ -38,7 +38,7 @@ export function ProviderEmptyState({ {appId === "claude-desktop" ? t("provider.importFromClaude", { - defaultValue: "从 Claude 导入兼容供应商", + defaultValue: "将 Claude Code 中已有的供应商导入", }) : t("provider.importCurrent")} diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index be278adc6..e03626fa9 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -239,6 +239,7 @@ export function ClaudeDesktopProviderForm({ showButtons = true, }: ClaudeDesktopProviderFormProps) { const { t } = useTranslation(); + const isOfficial = initialData?.category === "official"; const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct"; const [mode, setMode] = useState<"direct" | "proxy">(initialMode); const needsModelMapping = mode === "proxy"; @@ -508,6 +509,30 @@ export function ClaudeDesktopProviderForm({ ); return; } + if (isOfficial) { + // 官方供应商使用 Claude Desktop 内置 1P 模式,保持空 env 占位; + // 不写 claudeDesktopMode / claudeDesktopModelRoutes / apiFormat, + // 与启动 seed 的 OFFICIAL_SEEDS 占位语义一致。 + const settingsConfig = clonePlainRecord(initialData?.settingsConfig); + settingsConfig.env = {}; + const meta: ProviderMeta = { ...(initialData?.meta ?? {}) }; + delete meta.claudeDesktopMode; + delete meta.claudeDesktopModelRoutes; + delete meta.apiFormat; + delete meta.endpointAutoSelect; + delete meta.isFullUrl; + await onSubmit({ + ...values, + name: values.name.trim(), + websiteUrl: values.websiteUrl?.trim() ?? "", + notes: values.notes?.trim() ?? "", + settingsConfig: JSON.stringify(settingsConfig, null, 2), + meta, + presetId: activePreset?.id, + presetCategory: "official", + }); + return; + } if (!baseUrl.trim()) { toast.error( t("providerForm.fetchModelsNeedEndpoint", { @@ -688,361 +713,383 @@ export function ClaudeDesktopProviderForm({ - {usesManagedOAuth ? ( -
- {activeProviderType === "github_copilot" ? ( - - ) : ( - - )} -
- ) : ( -
- - setApiKey(event.target.value)} - type="password" - placeholder="sk-..." - /> + {isOfficial && ( +
+ {t("claudeDesktop.officialNotice", { + defaultValue: + "Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。", + })}
)} - setBaseUrl(v)} - placeholder={t("providerForm.apiEndpointPlaceholder")} - hint={ - needsModelMapping && apiFormat === "openai_responses" - ? t("providerForm.apiHintResponses") - : needsModelMapping && apiFormat === "openai_chat" - ? t("providerForm.apiHintOAI") - : needsModelMapping && apiFormat === "gemini_native" - ? t("providerForm.apiHintGeminiNative") - : t("providerForm.apiHint") - } - showManageButton={false} - /> + {!isOfficial && ( + <> + {usesManagedOAuth ? ( +
+ {activeProviderType === "github_copilot" ? ( + + ) : ( + + )} +
+ ) : ( +
+ + setApiKey(event.target.value)} + type="password" + placeholder="sk-..." + /> +
+ )} -
-
-
- -

- {needsModelMapping - ? t("claudeDesktop.modelMappingOnHint", { - defaultValue: - "Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。", - }) - : t("claudeDesktop.modelMappingOffHint", { - defaultValue: - "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。", - })} -

-
- setBaseUrl(v)} + placeholder={t("providerForm.apiEndpointPlaceholder")} + hint={ + needsModelMapping && apiFormat === "openai_responses" + ? t("providerForm.apiHintResponses") + : needsModelMapping && apiFormat === "openai_chat" + ? t("providerForm.apiHintOAI") + : needsModelMapping && apiFormat === "gemini_native" + ? t("providerForm.apiHintGeminiNative") + : t("providerForm.apiHint") + } + showManageButton={false} /> -
-
- {needsModelMapping && ( -
-
- - -
- -
-
-
+
+
+
- {renderActionButtons( - () => - setRoutes((current) => [ - ...current, - nextRouteRow(current, defaultProxyRouteRows), - ]), - t("claudeDesktop.addRoute", { defaultValue: "添加模型" }), - )} -
-

- {t("claudeDesktop.routeMapHint", { - defaultValue: - "左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。", - })} -

-
- -
- - {t("claudeDesktop.routeModelLabel", { - defaultValue: "Desktop 显示模型", - })} - - - {t("claudeDesktop.upstreamModelLabel", { - defaultValue: "实际请求模型", - })} - - - {t("claudeDesktop.supports1mLabel", { - defaultValue: "声明支持 1M", - })} - - -
- {routes.map((route, index) => ( -
-
- - {CLAUDE_ROUTE_PREFIX} - - - updateRoute(index, { - route: desktopRouteIdFromInput(event.target.value), +

+ {needsModelMapping + ? t("claudeDesktop.modelMappingOnHint", { + defaultValue: + "Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。", }) - } - placeholder="gpt-5.5" - className="rounded-l-none" - /> -

-
- - updateRoute(index, { model: event.target.value }) - } - placeholder="kimi-k2 / deepseek-chat" - className="flex-1" - /> - {fetchedModels.length > 0 && ( - - updateRoute(index, { - model: id, - route: route.route || desktopRouteIdFromModel(id), - }) - } - /> - )} -
- -
+ +
+
+ + {needsModelMapping && ( +
+
+ +
- ))} -
-
- )} - {!needsModelMapping && ( - - - - - {!directModelsExpanded && ( -

- {t("claudeDesktop.directModelListCollapsedHint", { - defaultValue: - "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。", - })} -

- )} - -
-
-

- {t("claudeDesktop.directModelListHint", { - defaultValue: - "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。", - })} -

- {renderActionButtons( - () => - setRoutes((current) => [ - ...current, - createRouteRow({ - route: "", - model: "", - supports1m: false, +
+
+
+ + {renderActionButtons( + () => + setRoutes((current) => [ + ...current, + nextRouteRow(current, defaultProxyRouteRows), + ]), + t("claudeDesktop.addRoute", { + defaultValue: "添加模型", }), - ]), - t("claudeDesktop.addModel", { defaultValue: "添加模型" }), - )} -
+ )} +
+

+ {t("claudeDesktop.routeMapHint", { + defaultValue: + "左侧决定 Claude Desktop 模型菜单里的可见模型 ID;1M 只控制是否向 Claude Desktop 声明支持 1M 上下文,实际请求模型按右侧填写内容发送。", + })} +

+
- {routes.length > 0 ? ( -
- {routes.map((route, index) => ( -
-
- - updateRoute(index, { route: event.target.value }) - } - placeholder="claude-deepseek-chat" - className="flex-1" - /> - {fetchedModels.length > 0 && ( - - updateRoute(index, { route: id }) - } - /> - )} -
-
+ ))} +
- - - )} + )} - ( - - - - - - - )} - /> + {!needsModelMapping && ( + + + + + {!directModelsExpanded && ( +

+ {t("claudeDesktop.directModelListCollapsedHint", { + defaultValue: + "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。", + })} +

+ )} + +
+
+

+ {t("claudeDesktop.directModelListHint", { + defaultValue: + "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;这些模型名会原样发送给供应商。", + })} +

+ {renderActionButtons( + () => + setRoutes((current) => [ + ...current, + createRouteRow({ + route: "", + model: "", + supports1m: false, + }), + ]), + t("claudeDesktop.addModel", { + defaultValue: "添加模型", + }), + )} +
+ + {routes.length > 0 ? ( +
+ {routes.map((route, index) => ( +
+
+ + updateRoute(index, { + route: event.target.value, + }) + } + placeholder="claude-deepseek-chat" + className="flex-1" + /> + {fetchedModels.length > 0 && ( + + updateRoute(index, { route: id }) + } + /> + )} +
+ + +
+ ))} +
+ ) : null} +
+
+
+ )} + + ( + + + + + + + )} + /> + + )} {showButtons && (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 965e31c48..0f06444bd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -104,7 +104,7 @@ "noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider", "noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers", "importCurrent": "Import Current Config", - "importFromClaude": "Import Compatible Providers from Claude", + "importFromClaude": "Import existing providers from Claude Code", "importCurrentDescription": "Import current live config as default provider", "currentlyUsing": "Currently Using", "enable": "Enable", @@ -174,6 +174,7 @@ } }, "claudeDesktop": { + "officialNotice": "Claude Desktop official providers use the built-in 1P sign-in. No API key or endpoint URL is required.", "mode": "Model handling", "modeDirect": "Direct", "modeProxy": "Requires routing", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0176f494b..ab4f750ca 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -104,7 +104,7 @@ "noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます", "noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます", "importCurrent": "現在の設定をインポート", - "importFromClaude": "Claude から互換プロバイダーをインポート", + "importFromClaude": "Claude Code の既存プロバイダーをインポート", "importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート", "currentlyUsing": "現在使用中", "enable": "有効化", @@ -174,6 +174,7 @@ } }, "claudeDesktop": { + "officialNotice": "Claude Desktop の公式プロバイダーは内蔵の 1P サインインを使用します。API キーやエンドポイントの設定は不要です。", "mode": "モデル処理方式", "modeDirect": "直結", "modeProxy": "ルーティング必要", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 416c4144c..ffe8cb5c9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -104,7 +104,7 @@ "noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中", "noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享", "importCurrent": "导入当前配置", - "importFromClaude": "从 Claude 导入兼容供应商", + "importFromClaude": "将 Claude Code 中已有的供应商导入", "importCurrentDescription": "将当前正在使用的配置导入为默认供应商", "currentlyUsing": "当前使用", "enable": "启用", @@ -174,6 +174,7 @@ } }, "claudeDesktop": { + "officialNotice": "Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。", "mode": "模型处理方式", "modeDirect": "直连", "modeProxy": "需要路由",