diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index 87e7bcc..1bfa837 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -388,6 +388,8 @@ fn prepare_catalog_with_sources( alias: optional_map_string(&entry.value, "display_name").filter(|display| { !display.eq_ignore_ascii_case(&string_value(&entry.value, "slug")) }), + is_alias: false, + context_window: positive_u64_value(entry.value.get("context_window")), }) .collect::>(); let values = entries diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2ee63e0..b3a2ecf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -88,6 +88,7 @@ const CLAUDE_DESKTOP_PROFILE_ID: &str = "00000000-0000-4000-8000-000000831700"; const CLAUDE_DESKTOP_OPUS_MODEL_ID: &str = "claude-opus-5"; const CLAUDE_DESKTOP_SONNET_MODEL_ID: &str = "claude-sonnet-4-6"; const CLAUDE_DESKTOP_HAIKU_MODEL_ID: &str = "claude-haiku-4-5"; +const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000; const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "codex-api-key", "openai-compatibility", @@ -651,6 +652,10 @@ struct CodexModelCatalogUpdateResult { struct AgentModelOption { name: String, alias: Option, + #[serde(default)] + is_alias: bool, + #[serde(default)] + context_window: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -2544,14 +2549,23 @@ fn parse_agent_model_options(payload: &serde_json::Value) -> Result, name: &str, alias: Option, + is_alias: bool, + context_window: Option, ) { let name = name.trim(); if name.is_empty() @@ -2578,6 +2594,8 @@ fn append_agent_model_option( models.push(AgentModelOption { name: name.to_string(), alias, + is_alias, + context_window, }); } @@ -4452,6 +4470,7 @@ fn build_agent_updates_with_oauth( &root_base, api_key, model, + models, claude_desktop_model_mappings, ) .or_else(|_| { @@ -4460,6 +4479,7 @@ fn build_agent_updates_with_oauth( &root_base, api_key, model, + models, claude_desktop_model_mappings, ) })?, @@ -4658,6 +4678,8 @@ fn ordered_agent_models( ordered.push(AgentModelOption { name: selected_model.to_string(), alias: None, + is_alias: false, + context_window: None, }); } for model in models { @@ -4683,6 +4705,7 @@ fn build_claude_desktop_profile( base_url: &str, api_key: &str, model: &str, + models: &[AgentModelOption], mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 网关配置")?; @@ -4710,13 +4733,13 @@ fn build_claude_desktop_profile( "inferenceProvider".to_string(), serde_json::json!("gateway"), ); - let _mappings = mappings + let mappings = mappings .cloned() .unwrap_or_else(|| ClaudeDesktopModelMappings::all(model)); let inference_models = vec![ - serde_json::json!(CLAUDE_DESKTOP_OPUS_MODEL_ID), - serde_json::json!(CLAUDE_DESKTOP_SONNET_MODEL_ID), - serde_json::json!(CLAUDE_DESKTOP_HAIKU_MODEL_ID), + claude_desktop_inference_model(CLAUDE_DESKTOP_OPUS_MODEL_ID, &mappings.opus, models), + claude_desktop_inference_model(CLAUDE_DESKTOP_SONNET_MODEL_ID, &mappings.sonnet, models), + claude_desktop_inference_model(CLAUDE_DESKTOP_HAIKU_MODEL_ID, &mappings.haiku, models), ]; root.insert( "inferenceModels".to_string(), @@ -4725,6 +4748,24 @@ fn build_claude_desktop_profile( render_agent_json(root, "Claude Desktop 网关配置") } +fn claude_desktop_inference_model( + route_model: &str, + source_model: &str, + models: &[AgentModelOption], +) -> serde_json::Value { + let context_window = models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(source_model)) + .and_then(|model| model.context_window); + let mut entry = serde_json::Map::new(); + entry.insert("name".to_string(), serde_json::json!(route_model)); + if context_window.is_some_and(|window| window >= CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW) { + entry.insert("supports1m".to_string(), serde_json::json!(true)); + entry.insert("prefer1m".to_string(), serde_json::json!(true)); + } + serde_json::Value::Object(entry) +} + fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop 配置索引")?; let entries = ensure_json_array_entry(&mut root, "entries"); @@ -6890,6 +6931,8 @@ fn fresh_agent_contents( let models = [AgentModelOption { name: model.to_string(), alias: None, + is_alias: false, + context_window: None, }]; fresh_agent_contents_with_oauth(client, port, api_key, model, &models, false, None) } @@ -6917,6 +6960,7 @@ fn fresh_agent_contents_with_oauth( &root_base, api_key, model, + models, claude_desktop_model_mappings, )?, build_claude_desktop_meta(None)?, @@ -7727,6 +7771,8 @@ fn reset_agent_configuration_to_default( models: &[AgentModelOption { name: model.to_string(), alias: None, + is_alias: false, + context_window: None, }], codex_catalog, oauth_configuration: false, @@ -16715,6 +16761,8 @@ mod tests { .map(|name| AgentModelOption { name: (*name).to_string(), alias: None, + is_alias: false, + context_window: None, }) .collect() } @@ -16883,6 +16931,7 @@ mod tests { "http://127.0.0.1:8317", DEFAULT_API_KEY, "gpt-test", + &[], None, ) .unwrap(); @@ -18195,6 +18244,7 @@ model: "http://127.0.0.1:8317", DEFAULT_API_KEY, "claude-sonnet-test", + &[], None, ) .unwrap(); @@ -18213,9 +18263,9 @@ model: assert_eq!( profile["inferenceModels"], serde_json::json!([ - CLAUDE_DESKTOP_OPUS_MODEL_ID, - CLAUDE_DESKTOP_SONNET_MODEL_ID, - CLAUDE_DESKTOP_HAIKU_MODEL_ID + { "name": CLAUDE_DESKTOP_OPUS_MODEL_ID }, + { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID }, + { "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID } ]) ); assert_eq!(meta["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); @@ -18236,11 +18286,32 @@ model: sonnet: "gpt-5.6".to_string(), haiku: "gpt-5.6-mini".to_string(), }; + let models = vec![ + AgentModelOption { + name: mappings.opus.clone(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + AgentModelOption { + name: mappings.sonnet.clone(), + alias: None, + is_alias: false, + context_window: Some(272_000), + }, + AgentModelOption { + name: mappings.haiku.clone(), + alias: None, + is_alias: false, + context_window: Some(1_000_000), + }, + ]; let profile = build_claude_desktop_profile( None, "http://127.0.0.1:8317", DEFAULT_API_KEY, "gpt-5.6-sol", + &models, Some(&mappings), ) .unwrap(); @@ -18249,9 +18320,17 @@ model: assert_eq!( profile["inferenceModels"], serde_json::json!([ - CLAUDE_DESKTOP_OPUS_MODEL_ID, - CLAUDE_DESKTOP_SONNET_MODEL_ID, - CLAUDE_DESKTOP_HAIKU_MODEL_ID + { + "name": CLAUDE_DESKTOP_OPUS_MODEL_ID, + "supports1m": true, + "prefer1m": true + }, + { "name": CLAUDE_DESKTOP_SONNET_MODEL_ID }, + { + "name": CLAUDE_DESKTOP_HAIKU_MODEL_ID, + "supports1m": true, + "prefer1m": true + } ]) ); assert!(!profile.to_string().contains("gpt-")); @@ -18429,9 +18508,9 @@ model: let models = parse_agent_model_options(&serde_json::json!({ "object": "list", "data": [ - {"id": "gpt-5", "display_name": "GPT 5"}, - {"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true}, - {"name": "hidden-original", "alias": "visible-alias"}, + {"id": "gpt-5", "display_name": "GPT 5", "context_length": 272000}, + {"name": "claude-sonnet", "alias": "claude-sonnet-xhigh", "fork": true, "contextLength": "1000000"}, + {"name": "hidden-original", "alias": "visible-alias", "context_window": 128000}, "deepseek-chat", {"id": "GPT-5"}, {"id": ""} @@ -18445,22 +18524,32 @@ model: AgentModelOption { name: "gpt-5".to_string(), alias: Some("GPT 5".to_string()), + is_alias: false, + context_window: Some(272_000), }, AgentModelOption { name: "claude-sonnet".to_string(), alias: None, + is_alias: false, + context_window: Some(1_000_000), }, AgentModelOption { name: "claude-sonnet-xhigh".to_string(), alias: Some("claude-sonnet".to_string()), + is_alias: true, + context_window: Some(1_000_000), }, AgentModelOption { name: "visible-alias".to_string(), alias: Some("hidden-original".to_string()), + is_alias: true, + context_window: Some(128_000), }, AgentModelOption { name: "deepseek-chat".to_string(), alias: None, + is_alias: false, + context_window: None, }, ] ); @@ -18566,6 +18655,8 @@ model: let models = vec![AgentModelOption { name: "gpt-5.4".to_string(), alias: Some("GPT 5.4".to_string()), + is_alias: false, + context_window: None, }]; assert_eq!( diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 65d2b12..636a40c 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -550,6 +550,8 @@ export const jaOverrides = { 'agents.modify.oauthConfiguration': 'OAuth 設定方式', 'agents.claudeDesktopMapping.title': 'Claude Desktop モデル → 上流モデル', 'agents.claudeDesktopMapping.description': '各 Claude Desktop モデルが実際に呼び出す上流モデルを選択します。', + 'agents.claudeDesktopMapping.customMapping': 'カスタムマッピング', + 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 のマッピング先', 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 のマッピング先', 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 のマッピング先', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 89c177a..30234e9 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -548,6 +548,8 @@ export const zhCN = { 'agents.pending': '尚未应用', 'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型', 'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。', + 'agents.claudeDesktopMapping.customMapping': '自定义映射', + 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到', 'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到', 'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到', @@ -1438,6 +1440,8 @@ export const en: Record = { 'agents.pending': 'Not Applied', 'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model', 'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.', + 'agents.claudeDesktopMapping.customMapping': 'Custom Mapping', + 'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.', 'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to', 'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to', 'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index b962c27..8a81e42 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -34,6 +34,7 @@ import opencodeIcon from '../assets/icons/opencode.svg'; import { agentModelAlias, filterAgentModels, + filterAgentModelsByAlias, findAgentModel, resolveAgentModelSelection, } from '../services/agentModelPicker'; @@ -298,7 +299,9 @@ function AgentModelPicker({ () => visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })), [visibleModels], ); - const selectedAlias = agentModelAlias(models, value); + const selectedModel = findAgentModel(models, value); + const selectedName = selectedModel?.name ?? ''; + const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; const updateDropdownLayout = useCallback(() => { const root = rootRef.current; @@ -411,8 +414,8 @@ function AgentModelPicker({ }} > - - {value || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))} + + {selectedName || (loading ? t('agents.model.loading') : error ? t('agents.model.loadFailed') : models.length ? t('agents.model.select') : t('agents.model.none'))} {selectedAlias ? {selectedAlias} : null} @@ -513,6 +516,7 @@ export function AgentsPage() { const [claudeDesktopModelMappingsDraft, setClaudeDesktopModelMappingsDraft] = useState( createClaudeDesktopModelMappings(''), ); + const [claudeDesktopCustomMapping, setClaudeDesktopCustomMapping] = useState(false); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< @@ -643,6 +647,10 @@ export function AgentsPage() { const savedSelectedModel = modelByClient[selected] ?? ''; const selectedModelOption = findAgentModel(models, savedSelectedModel); const selectedModel = selectedModelOption?.name ?? ''; + const claudeDesktopModels = useMemo( + () => filterAgentModelsByAlias(models, claudeDesktopCustomMapping), + [claudeDesktopCustomMapping, models], + ); useEffect(() => { if (selected !== 'claude-desktop' || !selectedModel) return; @@ -1105,14 +1113,31 @@ export function AgentsPage() { {t('agents.claudeDesktopMapping.title')} {t('agents.claudeDesktopMapping.description')} - {claudeDesktopMappingDraftChanged ? {t('agents.pending')} : null} +
+ + {claudeDesktopMappingDraftChanged ? {t('agents.pending')} : null} +
{claudeDesktopMappingRoles.map((role) => (
{t(role.labelKey)} item.model); } +export function filterAgentModelsByAlias( + models: ModelOption[], + aliasesOnly: boolean, +): ModelOption[] { + return models.filter((model) => Boolean(model.isAlias) === aliasesOnly); +} + export function hasExactAgentModel(models: ModelOption[], value: string): boolean { const query = normalized(value); if (!query) return false; diff --git a/src/services/modelService.ts b/src/services/modelService.ts index 079f2b4..e7c829b 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -6,6 +6,8 @@ const modelText = (key: Parameters[1]) => translate(getCurrent export type ModelOption = { name: string; alias?: string; + isAlias?: boolean; + contextWindow?: number; thinking?: Record; }; export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai'; diff --git a/src/styles.css b/src/styles.css index 0e5f4f3..f1660cb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5166,6 +5166,39 @@ textarea:focus-visible { font-size: var(--font-size-label); } +.agent-section-heading-actions { + display: flex !important; + flex: none; + align-items: center; + justify-content: flex-end; + gap: 8px !important; +} + +.agent-claude-desktop-mapping-filter { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--theme-e3e1db); + border-radius: 7px; + background: var(--theme-fff); + color: var(--theme-5f5a53); + cursor: pointer; + font-size: var(--font-size-label); + font-weight: 700; +} + +.agent-claude-desktop-mapping-filter:has(input:checked) { + border-color: var(--theme-8069ad); + background: var(--theme-f1ecfb); + color: var(--theme-554075); +} + +.agent-claude-desktop-mapping-filter:has(input:disabled) { + cursor: not-allowed; + opacity: 0.62; +} + .agent-section-heading { display: flex; align-items: center; @@ -6950,6 +6983,15 @@ textarea:focus-visible { } @container agent-panel (max-width: 640px) { + .agent-claude-desktop-mapping > .agent-section-heading { + align-items: stretch; + flex-direction: column; + } + + .agent-claude-desktop-mapping .agent-section-heading-actions { + justify-content: space-between; + } + .agent-claude-desktop-mapping-grid { grid-template-columns: 1fr; } diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index a1f4665..8fd282f 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { agentModelAlias, filterAgentModels, + filterAgentModelsByAlias, findAgentModel, hasExactAgentModel, resolveAgentModelSelection, @@ -49,4 +50,17 @@ describe('智能体模型选择器', () => { expect(findAgentModel(models, 'codex')?.name).toBeUndefined(); expect(findAgentModel(models, 'gpt-5.2-codex')?.name).toBe('gpt-5.2-codex'); }); + + test('Claude Desktop 自定义映射只显示对应类型的模型', () => { + const mixedModels = [ + { name: 'gpt-original', alias: 'GPT Original', isAlias: false }, + { name: 'gpt-high', alias: 'gpt-original', isAlias: true }, + { name: 'claude-original' }, + ]; + + expect(filterAgentModelsByAlias(mixedModels, false).map((model) => model.name)) + .toEqual(['gpt-original', 'claude-original']); + expect(filterAgentModelsByAlias(mixedModels, true).map((model) => model.name)) + .toEqual(['gpt-high']); + }); });