diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index 06d42c5..daaf1eb 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -1043,6 +1043,8 @@ pub(crate) fn resolve_claude_desktop_model_mappings( disable_auto_compact: requested.disable_auto_compact, manage_default_model: requested.manage_default_model, manage_subagent_model: requested.manage_subagent_model, + startup_model: requested.startup_model.map(|value| value.trim().to_string()), + subagent_model: requested.subagent_model.map(|value| value.trim().to_string()), })) } @@ -1062,13 +1064,21 @@ pub(crate) fn resolve_claude_code_model_mappings( if !(1..=100).contains(&requested.auto_compact_pct) { return Err("Claude Code compaction trigger percentage must be between 1 and 100".to_string()); } - let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m { + let explicit_1m = [&requested.startup_model, &requested.subagent_model] + .iter().any(|value| value.as_deref().is_some_and(|model| + model.trim().to_ascii_lowercase().ends_with("[1m]"))); + let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m || explicit_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { requested.max_context_tokens }; let resolve = |model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?); + let normalize_override = |value: Option| -> Result, String> { + value.map(|value| { + if value.trim().is_empty() { Ok(String::new()) } else { validate_agent_model(&value) } + }).transpose() + }; Ok(Some(ClaudeDesktopModelMappings { desktop_models: None, opus: resolve(&requested.opus)?, @@ -1082,6 +1092,8 @@ pub(crate) fn resolve_claude_code_model_mappings( disable_auto_compact: requested.disable_auto_compact, manage_default_model: requested.manage_default_model, manage_subagent_model: requested.manage_subagent_model, + startup_model: normalize_override(requested.startup_model)?, + subagent_model: normalize_override(requested.subagent_model)?, })) } diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 5ec3ebf..7cbe071 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -584,14 +584,17 @@ pub(crate) fn build_claude_agent_config( let max_context_tokens = mappings.max_context_tokens; let model_settings = claude_code_model_settings(&mappings); let subagent_model = model_settings.haiku.clone(); - let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?; + // Explicit/default session selections may switch roles, while Subagents may use + // another model. A global Sonnet-derived effort override is wrong for both. + let effort_level = if mappings.startup_model.is_none() && mappings.subagent_model.is_none() { + claude_code_model_effort_level(models, &mappings.sonnet)? + } else { + None + }; let env = ensure_json_object_entry(root, "env"); env.remove("ANTHROPIC_API_KEY"); env.remove("CLAUDE_CODE_EFFORT_LEVEL"); - env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV); env.remove(CLAUDE_CODE_AUTO_MODE_SERVER_ENV); - env.remove(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV); - env.remove(DISABLE_AUTO_COMPACT_ENV); for (key, value) in [ ("ANTHROPIC_BASE_URL", base_url), ("ANTHROPIC_AUTH_TOKEN", api_key), @@ -616,13 +619,19 @@ pub(crate) fn build_claude_agent_config( } env.insert( "EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL".to_string(), - serde_json::Value::String(if mappings.manage_default_model { "1" } else { "0" }.to_string()), + serde_json::Value::String(if mappings.startup_model.as_ref().map_or(mappings.manage_default_model, |value| !value.trim().is_empty()) { "1" } else { "0" }.to_string()), ); env.insert( "EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL".to_string(), - serde_json::Value::String(if mappings.manage_subagent_model { "1" } else { "0" }.to_string()), + serde_json::Value::String(if mappings.subagent_model.as_ref().map_or(mappings.manage_subagent_model, |value| !value.trim().is_empty()) { "1" } else { "0" }.to_string()), ); - if mappings.manage_default_model { + let explicit_startup_model = mappings.startup_model.as_ref().map(|model| model.trim().to_string()); + if mappings.startup_model.is_some() { + // Claude Code's official persistent startup-model setting is the root + // `model` key. Keep ANTHROPIC_MODEL out of the generated file so the + // saved choice can still be changed from Claude Code's /model picker. + env.remove("ANTHROPIC_MODEL"); + } else if mappings.manage_default_model { env.insert( "ANTHROPIC_MODEL".to_string(), serde_json::Value::String(model_settings.sonnet.clone()), @@ -634,7 +643,13 @@ pub(crate) fn build_claude_agent_config( { env.remove("ANTHROPIC_MODEL"); } - if mappings.manage_subagent_model { + if let Some(model) = &mappings.subagent_model { + if model.trim().is_empty() { + env.remove("CLAUDE_CODE_SUBAGENT_MODEL"); + } else { + env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), serde_json::Value::String(model.trim().into())); + } + } else if mappings.manage_subagent_model { env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string()) .or_insert_with(|| serde_json::Value::String(subagent_model)); } else if env @@ -644,10 +659,6 @@ pub(crate) fn build_claude_agent_config( { env.remove("CLAUDE_CODE_SUBAGENT_MODEL"); } - env.insert( - CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(), - serde_json::Value::String(max_context_tokens.to_string()), - ); env.insert( CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV.to_string(), serde_json::Value::String("1".to_string()), @@ -656,16 +667,6 @@ pub(crate) fn build_claude_agent_config( CLAUDE_CODE_AUTO_MODE_SERVER_ENV.to_string(), serde_json::Value::String("0".to_string()), ); - env.insert( - CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV.to_string(), - serde_json::Value::String(mappings.auto_compact_pct.to_string()), - ); - if mappings.disable_auto_compact { - env.insert( - DISABLE_AUTO_COMPACT_ENV.to_string(), - serde_json::Value::String("1".to_string()), - ); - } for (key, value) in claude_code_model_presentation_environment(&mappings, models)? { env.insert(key, serde_json::Value::String(value)); } @@ -675,12 +676,49 @@ pub(crate) fn build_claude_agent_config( serde_json::Value::String(effort_level), ); } - if mappings.manage_default_model { + // Claude Code 2.1.288+ supports a per-model auto-compaction window in + // settings.json. Keep the existing environment override for compatibility + // with older Claude Code versions and custom gateway IDs, but also emit the + // official settings so newer versions can apply the value per model. + root.insert("autoCompactWindow".into(), serde_json::Value::Number(max_context_tokens.into())); + root.insert("autoCompactEnabled".into(), serde_json::Value::Bool(!mappings.disable_auto_compact)); + let model_settings_json = ensure_json_object_entry(root, "modelSettings"); + for (model_id, window) in [ + (&mappings.opus, if mappings.opus_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }), + (&mappings.sonnet, if mappings.sonnet_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }), + (&mappings.haiku, if mappings.haiku_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }), + ] { + let model_id = strip_claude_code_context_suffix(model_id).trim(); + if model_id.is_empty() { continue; } + let entry = ensure_json_object_entry(model_settings_json, model_id); + let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0); + entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into())); + } + for model in [&mappings.startup_model, &mappings.subagent_model] { + let Some(model) = model.as_deref().map(str::trim).filter(|value| !value.is_empty()) else { continue; }; + let model_id = strip_claude_code_context_suffix(model).trim(); + let window = if model.to_ascii_lowercase().ends_with("[1m]") { + CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW + } else { + mappings.max_context_tokens + }; + let entry = ensure_json_object_entry(model_settings_json, model_id); + let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0); + entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into())); + } + if let Some(model) = explicit_startup_model { + if model.is_empty() { + root.remove("model"); + } else { + root.insert("model".into(), serde_json::Value::String(model)); + } + } + if mappings.startup_model.is_none() && mappings.manage_default_model { root.insert( "model".to_string(), serde_json::Value::String(model_settings.sonnet), ); - } else if root + } else if mappings.startup_model.is_none() && root .get("model") .and_then(serde_json::Value::as_str) .is_some_and(|value| value == model_settings.opus || value == model_settings.sonnet || value == model_settings.haiku) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 9815097..1883dab 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -2925,7 +2925,8 @@ pub(crate) fn inspect_claude_code_model_mappings( .get("ANTHROPIC_MODEL") .and_then(serde_json::Value::as_str) .map(str::trim) - .filter(|value| !value.is_empty()); + .filter(|value| !value.is_empty()) + .or_else(|| root.get("model").and_then(serde_json::Value::as_str).map(str::trim).filter(|value| !value.is_empty())); let read_model = |key: &str| { let value = env .get(key) @@ -2946,10 +2947,13 @@ pub(crate) fn inspect_claude_code_model_mappings( return Ok(None); }; let legacy_1m = opus_had_1m || sonnet_had_1m || haiku_had_1m; - let max_context_tokens = env - .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) - .and_then(serde_json::Value::as_str) - .and_then(|value| value.trim().parse::().ok()) + let max_context_tokens = root + .get("autoCompactWindow") + .and_then(serde_json::Value::as_u64) + .or_else(|| env + .get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV) + .and_then(serde_json::Value::as_str) + .and_then(|value| value.trim().parse::().ok())) .unwrap_or_else(|| { if legacy_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW @@ -2962,10 +2966,14 @@ pub(crate) fn inspect_claude_code_model_mappings( .and_then(serde_json::Value::as_str) .and_then(|value| value.trim().parse::().ok()) .unwrap_or_else(default_claude_auto_compact_pct); - let disable_auto_compact = env - .get(DISABLE_AUTO_COMPACT_ENV) - .and_then(serde_json::Value::as_str) - .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + let disable_auto_compact = root + .get("autoCompactEnabled") + .and_then(serde_json::Value::as_bool) + .map(|enabled| !enabled) + .unwrap_or_else(|| env + .get(DISABLE_AUTO_COMPACT_ENV) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))); Ok(Some(ClaudeDesktopModelMappings { desktop_models: None, opus, @@ -2987,6 +2995,17 @@ pub(crate) fn inspect_claude_code_model_mappings( .and_then(serde_json::Value::as_str) .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) .unwrap_or(true), + startup_model: Some(env + .get("ANTHROPIC_MODEL") + .and_then(serde_json::Value::as_str) + .or_else(|| root.get("model").and_then(serde_json::Value::as_str)) + .unwrap_or_default() + .to_string()), + subagent_model: Some(env + .get("CLAUDE_CODE_SUBAGENT_MODEL") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string()), })) } @@ -3115,6 +3134,8 @@ pub(crate) fn claude_code_model_settings( disable_auto_compact: mappings.disable_auto_compact, manage_default_model: mappings.manage_default_model, manage_subagent_model: mappings.manage_subagent_model, + startup_model: mappings.startup_model.clone(), + subagent_model: mappings.subagent_model.clone(), } } diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index a3d18a5..cb3b997 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -840,6 +840,15 @@ fn validate_unmanaged_preserved( } } if client == "claude-code" { + if let Some(root) = value.as_object_mut() { + // These are CPA-managed Claude Code settings. Exclude them + // when comparing user-owned configuration so a model update + // does not get rejected as an unrelated custom edit. + root.remove("model"); + root.remove("modelSettings"); + root.remove("autoCompactWindow"); + root.remove("autoCompactEnabled"); + } if let Some(env) = value.get_mut("env").and_then(Value::as_object_mut) { env.retain(|key, _| { !matches!( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9d59902..5225e6a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1163,6 +1163,12 @@ struct ClaudeDesktopModelMappings { /// Defaults to true for backwards compatibility with existing profiles. #[serde(default = "default_true")] manage_subagent_model: bool, + /// Optional explicit model written to ANTHROPIC_MODEL. + #[serde(default)] + startup_model: Option, + /// Optional explicit model written to CLAUDE_CODE_SUBAGENT_MODEL. + #[serde(default)] + subagent_model: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -1227,6 +1233,8 @@ impl ClaudeDesktopModelMappings { disable_auto_compact: false, manage_default_model: true, manage_subagent_model: true, + startup_model: None, + subagent_model: None, } } } diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 5d0b9d4..491f971 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1,6 +1,73 @@ use super::support::*; use super::*; +#[test] +fn claude_code_explicit_model_inputs_replace_clear_and_round_trip() { + let directory = agent_test_home("claude-code-explicit-models"); + let path = directory.join("settings.json"); + let models = test_agent_models(&["route-model"]); + let mut mappings = ClaudeDesktopModelMappings::all("route-model"); + mappings.startup_model = Some(" custom-startup[1m] ".into()); + mappings.subagent_model = Some("custom-worker".into()); + let resolved = resolve_claude_code_model_mappings( + AgentClient::ClaudeCode, &models, "route-model", Some(mappings), + ).unwrap().unwrap(); + let rendered = build_claude_agent_config( + Some(r#"{"model":"user-choice","env":{"KEEP":"yes","ANTHROPIC_MODEL":"old-startup","CLAUDE_CODE_SUBAGENT_MODEL":"old-worker"}}"#), + "http://127.0.0.1:8317", "test-key", "route-model", &models, Some(&resolved), + ).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["model"], "custom-startup[1m]"); + assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "custom-worker"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "route-model"); + assert_eq!(value["modelSettings"]["route-model"]["autoCompactWindow"], 1_000_000); + assert_eq!(value["model"], "custom-startup[1m]"); + fs::write(&path, &rendered).unwrap(); + let mut inspected = inspect_claude_code_model_mappings(&path).unwrap().unwrap(); + assert_eq!(inspected.startup_model.as_deref(), Some("custom-startup[1m]")); + assert_eq!(inspected.subagent_model.as_deref(), Some("custom-worker")); + inspected.startup_model = Some(String::new()); + inspected.subagent_model = Some(String::new()); + let cleared = build_claude_agent_config( + Some(&rendered), "http://127.0.0.1:8317", "test-key", "route-model", &models, Some(&inspected), + ).unwrap(); + let value: serde_json::Value = serde_json::from_str(&cleared).unwrap(); + assert!(value["env"].get("ANTHROPIC_MODEL").is_none()); + assert!(value["env"].get("CLAUDE_CODE_SUBAGENT_MODEL").is_none()); + assert!(value.get("model").is_none()); + assert_eq!(value["env"]["KEEP"], "yes"); +} + +#[test] +fn claude_code_role_selection_survives_mapping_changes_without_global_effort_override() { + let models = test_agent_models(&["route-a", "route-b"]); + let mut mappings = ClaudeDesktopModelMappings::all("route-a"); + mappings.startup_model = Some("sonnet".into()); + mappings.subagent_model = Some("haiku".into()); + for route in ["route-a", "route-b"] { + mappings.sonnet = route.into(); + mappings.sonnet_1m = true; + let rendered = build_claude_agent_config( + Some(r#"{"model":"user-choice","env":{"CLAUDE_CODE_EFFORT_LEVEL":"max"}}"#), + "http://127.0.0.1:8317", "test-key", route, &models, Some(&mappings), + ).unwrap(); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(value["model"], "sonnet"); + assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "haiku"); + assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], format!("{route}[1m]")); + assert_eq!(value["modelSettings"][route]["autoCompactWindow"], 1_000_000); + assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); + assert_eq!(value["model"], "sonnet"); + } + mappings.sonnet_1m = false; + mappings.startup_model = Some("route-a[1m]".into()); + let resolved = resolve_claude_code_model_mappings( + AgentClient::ClaudeCode, &models, "route-a", Some(mappings), + ).unwrap().unwrap(); + assert_eq!(resolved.max_context_tokens, 1_000_000); + assert_eq!(resolved.startup_model.as_deref(), Some("route-a[1m]")); +} + #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( @@ -27,9 +94,8 @@ fn claude_agent_config_preserves_existing_fields() { "1" ); assert_eq!(value["env"][CLAUDE_CODE_AUTO_MODE_SERVER_ENV], "0"); - assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000"); - assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "90"); - assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none()); + assert_eq!(value["autoCompactWindow"], 200_000); + assert_eq!(value["autoCompactEnabled"], true); assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test"); assert_eq!( @@ -156,6 +222,8 @@ fn claude_code_role_mappings_drive_settings() { disable_auto_compact: false, manage_default_model: true, manage_subagent_model: true, + startup_model: None, + subagent_model: None, }; let models = vec![ AgentModelOption { @@ -205,7 +273,7 @@ fn claude_code_role_mappings_drive_settings() { assert_eq!(value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "gpt-opus"); assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet"); assert_eq!(value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "gpt-haiku"); - assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "272000"); + assert_eq!(value["autoCompactWindow"], 272_000); assert_eq!( value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"], "gpt-sonnet (272K context)" @@ -274,6 +342,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { disable_auto_compact: true, manage_default_model: true, manage_subagent_model: true, + startup_model: None, + subagent_model: None, }; let models = vec![ AgentModelOption { @@ -304,9 +374,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() { .unwrap(); let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); - assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "1000000"); - assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "75"); - assert_eq!(value["env"][DISABLE_AUTO_COMPACT_ENV], "1"); + assert_eq!(value["autoCompactWindow"], 1_000_000); + assert_eq!(value["autoCompactEnabled"], false); assert_eq!(value["env"]["ANTHROPIC_MODEL"], "custom-pro[1m]"); assert_eq!( value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], diff --git a/src/components/AgentModelPicker.tsx b/src/components/AgentModelPicker.tsx index 3ff8af3..066fa6c 100644 --- a/src/components/AgentModelPicker.tsx +++ b/src/components/AgentModelPicker.tsx @@ -28,6 +28,8 @@ export type AgentModelPickerProps = { allowCustomValue?: boolean; editable?: { label: string; placeholder: string; maxLength: number }; menuClassName?: string; + emptyOption?: string; + preserveValue?: boolean; }; type AgentModelDropdownLayout = { @@ -48,6 +50,8 @@ export function AgentModelPicker({ allowCustomValue = false, editable, menuClassName = '', + emptyOption, + preserveValue = false, }: AgentModelPickerProps) { const { t } = useI18n(); const [open, setOpen] = useState(false); @@ -66,13 +70,16 @@ export function AgentModelPicker({ ); const choices = useMemo(() => { const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })); + if (emptyOption && !search.trim()) options.unshift({ name: '', alias: emptyOption }); if (allowCustomValue && search.trim() && !findAgentModel(models, search)) { options.push({ name: search.trim(), alias: t('agents.model.useCustom') }); } return options; - }, [visibleModels, allowCustomValue, search, models, t]); + }, [visibleModels, allowCustomValue, search, models, t, emptyOption]); const selectedModel = findAgentModel(models, value); - const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : ''); + const selectedName = (selectedModel?.name + ?? (allowCustomValue || editable || preserveValue ? value.trim() : '')) + || (emptyOption && !value.trim() ? emptyOption : ''); const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; const updateDropdownLayout = useCallback(() => { @@ -158,7 +165,7 @@ export function AgentModelPicker({ useEffect(() => { if (!open) return; if (!editable) setSearch(''); - const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex( + const selectedIndex = choices.findIndex( (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), ); setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); @@ -322,8 +329,8 @@ export function AgentModelPicker({ onClick={() => choose(choice.name)} > - {choice.name} - {choice.alias || t('agents.model.available')} + {choice.name || emptyOption} + {choice.name ? {choice.alias || t('agents.model.available')} : null} {selected ? : null} diff --git a/src/components/AuthFileQuotaPanel.css b/src/components/AuthFileQuotaPanel.css index 93cdbf7..baeca7a 100644 --- a/src/components/AuthFileQuotaPanel.css +++ b/src/components/AuthFileQuotaPanel.css @@ -98,14 +98,13 @@ .credential-quota-dense .credential-quota-label span { min-width: 0; - padding: 2px 5px; overflow: hidden; padding: 2px 7px; border: 0; border-radius: 0; background: transparent; - font-size: 10px; - font-weight: 550; + font-size: 12px; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } @@ -137,21 +136,22 @@ .credential-quota-dense .credential-quota-empty { margin: 0; color: var(--text-tertiary); - font-size: 11px; + font-size: 12px; } .credential-quota-dense .credential-quota-label strong { - font-size: 12px; + font-size: 13px; line-height: 1; + font-weight: 750; } .credential-quota-dense .credential-quota-track { - height: 5px; + height: 7px; } .credential-quota-dense .credential-quota-row small { color: var(--text-tertiary); - font-size: 10px; + font-size: 12px; line-height: 1.4; } @@ -167,11 +167,11 @@ } .credential-quota-dense .credential-quota-heading .credential-quota-reset { - min-height: 21px; - padding: 2px 6px; + min-height: 26px; + padding: 3px 8px; margin: 0; - border-radius: 4px; - font-size: 10px; + border-radius: 6px; + font-size: 12px; line-height: 1.3; } diff --git a/src/components/claude-code/ClaudeCodeContextOptions.tsx b/src/components/claude-code/ClaudeCodeContextOptions.tsx new file mode 100644 index 0000000..d68a7d3 --- /dev/null +++ b/src/components/claude-code/ClaudeCodeContextOptions.tsx @@ -0,0 +1,39 @@ +import { useI18n } from '../../i18n'; +import { claudeCodeRoles, claudeCodeRole, claudeCodeModelBase, type ClaudeCodeRoutes } from '../../services/claudeCodeModels'; + +type Props = { + routes: ClaudeCodeRoutes; + startupModel: string; + subagentModel: string; + disabled: boolean; + onRoleChange: (field: 'opus1m' | 'sonnet1m' | 'haiku1m', enabled: boolean) => void; + onModelChange: (field: 'startupModel' | 'subagentModel', value: string) => void; +}; + +export function ClaudeCodeContextOptions({ routes, startupModel, subagentModel, disabled, onRoleChange, onModelChange }: Props) { + const { t } = useI18n(); + return ( +
+ {t('agents.claudeCodeRuntime.extendedContext')} +

{t('agents.claudeCodeRuntime.selectionHint')}

+
+ {claudeCodeRoles.map(role => ( + + ))} + {(['startupModel', 'subagentModel'] as const).map(field => { + const value = field === 'startupModel' ? startupModel : subagentModel; + if (!value.trim() || claudeCodeRole(value)) return null; + return ; + })} +
+
+ ); +} diff --git a/src/components/claude-code/ClaudeCodeModelInputs.css b/src/components/claude-code/ClaudeCodeModelInputs.css new file mode 100644 index 0000000..0d01f06 --- /dev/null +++ b/src/components/claude-code/ClaudeCodeModelInputs.css @@ -0,0 +1,53 @@ +.claude-code-model-inputs { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.claude-code-model-field { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; + overflow-wrap: anywhere; +} + +@container agent-panel (max-width: 560px) { + .claude-code-model-inputs { + grid-template-columns: 1fr; + } +} + +.claude-code-model-field small { color: var(--theme-817b72); line-height: 1.5; } +.claude-code-model-field .agent-model-trigger { width: 100%; border: 1px solid var(--theme-d3d0c9); border-radius: 7px; background: var(--theme-fff); } +.claude-code-model-field .agent-model-trigger input { min-width: 0; width: 100%; } +.claude-code-model-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--theme-d3d0c9); border-radius: 8px; } +.claude-code-model-error p { margin: 4px 0 0; } + +.claude-code-direct-context { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-caption); } +.claude-code-selection-hint { grid-column: 1 / -1; color: var(--theme-817b72); line-height: 1.5; } +.claude-code-model-inputs .claude-code-model-field .agent-model-trigger { + border: 1px solid var(--theme-d3d0c9, #d3d0c9); + padding: 6px 10px; + border-radius: 7px; +} + +.claude-code-context-settings { border-top: 1px solid var(--theme-d3d0c9, #d3d0c9); padding-top: 14px; } +.claude-code-context-settings summary { cursor: pointer; font-weight: 600; padding: 6px 0; } +.claude-code-context-settings > p, .claude-code-context-options p { font-size: var(--font-size-caption); line-height: 1.5; color: var(--theme-817b72); } +.claude-code-context-options { margin: 14px 0 0; padding: 12px; border: 1px solid var(--theme-d3d0c9, #d3d0c9); border-radius: 8px; } +.claude-code-context-options > div { display: flex; flex-wrap: wrap; gap: 14px 24px; } +.claude-code-context-options label { display: flex; gap: 8px; align-items: center; } +.claude-code-group-heading { margin-top: 4px; font-size: var(--font-size-label); } +.claude-role-table { display: grid; gap: 10px; min-width: 0; } +.claude-role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.claude-role-table > p { margin: 0; font-size: var(--font-size-caption); color: var(--theme-817b72); line-height: 1.5; } +.claude-role-row { display: grid; grid-template-columns: 80px minmax(0, 1fr) 65px; align-items: center; gap: 12px; padding: 10px; border: 1px solid var(--theme-d3d0c9); border-radius: 8px; } +.claude-role-row .agent-model-trigger { width: 100%; } +.claude-model-with-context { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; } +.claude-inline-context { display: flex; align-items: center; gap: 6px; white-space: nowrap; } +@container agent-panel (max-width: 420px) { + .claude-role-row { grid-template-columns: minmax(0, 1fr) auto; } + .claude-role-row > strong { grid-column: 1 / -1; } +} diff --git a/src/components/claude-code/ClaudeCodeModelInputs.tsx b/src/components/claude-code/ClaudeCodeModelInputs.tsx new file mode 100644 index 0000000..47ca50e --- /dev/null +++ b/src/components/claude-code/ClaudeCodeModelInputs.tsx @@ -0,0 +1,66 @@ +import { claudeCodeModelBase, claudeCodeRoles, claudeCodeModelPreview, isKnownClaudeCodeModel, type ClaudeCodeRoutes } from '../../services/claudeCodeModels'; +import { useI18n } from '../../i18n'; +import { AgentModelPicker } from '../AgentModelPicker'; +import type { ModelOption } from '../../services/modelService'; +import './ClaudeCodeModelInputs.css'; + +type Props = { + startupModel: string; + subagentModel: string; + models: ModelOption[]; + routes: ClaudeCodeRoutes; + loading: boolean; + disabled: boolean; + onChange: (field: 'startupModel' | 'subagentModel', value: string) => void; +}; + +export function ClaudeCodeModelInputs({ startupModel, subagentModel, models, routes, loading, disabled, onChange }: Props) { + const { t } = useI18n(); + const options = [ + ...claudeCodeRoles.map(role => ({ name: role, alias: t('agents.claudeCodeRuntime.followRole', { + role, model: claudeCodeModelPreview(role, routes).model || '—', + }) })), + ...models.filter(model => !claudeCodeRoles.some(role => role === model.name.toLowerCase())) + .map(model => ({ ...model, alias: t('agents.claudeCodeRuntime.directOption') })), + ]; + return ( +
+ {(['startupModel', 'subagentModel'] as const).map((field) => { + const value = field === 'startupModel' ? startupModel : subagentModel; + const preview = claudeCodeModelPreview(value, routes); + return ( +
+ {t(`agents.claudeCodeRuntime.${field}`)} +
+ onChange(field, value)} + disabled={disabled} + loading={loading} + allowCustomValue + emptyOption={t(`agents.claudeCodeRuntime.${field}Placeholder`)} + editable={{ label: t(`agents.claudeCodeRuntime.${field}`), + placeholder: t(`agents.claudeCodeRuntime.${field}Placeholder`), maxLength: 240 }} + /> + +
+ + {value.trim() ? preview.role + ? t('agents.claudeCodeRuntime.followRole', { role: preview.role, model: preview.model || '—' }) + : t('agents.claudeCodeRuntime.directModel', { model: preview.model }) + : t(`agents.claudeCodeRuntime.${field}Hint`)} + + {!loading && !isKnownClaudeCodeModel(value, models) ? ( + {t('agents.claudeCodeRuntime.unverifiedModel')} + ) : null} +
+ ); })} +
+ ); +} diff --git a/src/components/claude-code/ClaudeCodeRoleRows.tsx b/src/components/claude-code/ClaudeCodeRoleRows.tsx new file mode 100644 index 0000000..8a9c0fb --- /dev/null +++ b/src/components/claude-code/ClaudeCodeRoleRows.tsx @@ -0,0 +1,40 @@ +import { useI18n } from '../../i18n'; +import { AgentModelPicker } from '../AgentModelPicker'; +import type { ModelOption } from '../../services/modelService'; +import { claudeCodeRoles, type ClaudeCodeRoutes } from '../../services/claudeCodeModels'; +import './ClaudeCodeModelInputs.css'; + +type Role = typeof claudeCodeRoles[number]; +type Props = { + routes: ClaudeCodeRoutes; + models: ModelOption[]; + loading: boolean; + error: string; + disabled: boolean; + onModelChange: (role: Role, value: string) => void; + onContextChange: (field: `${Role}1m`, value: boolean) => void; + onRefresh: () => void; + onApplyAll: () => void; +}; + +export function ClaudeCodeRoleRows({ routes, models, loading, error, disabled, onModelChange, onContextChange, onRefresh, onApplyAll }: Props) { + const { t } = useI18n(); + return
+
+ {t('agents.claudeCodeRuntime.roleMappings')} + +
+

{t('agents.claudeCodeRuntime.roleRowsHint')}

+ {(['sonnet', 'opus', 'haiku'] as const).map(role =>
+ {role[0].toUpperCase() + role.slice(1)} + onModelChange(role, value)} onRefresh={onRefresh} /> + +
)} +
; +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index bb8010d..85fe17b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1455,7 +1455,7 @@ export const jaOverrides = { 'agents.claudeDesktopMapping.error.duplicate': 'エイリアスまたは元モデル ID が重複しています。各行に異なる ID を指定してください。', 'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル', 'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルを選び、共通のコンテキストと自動圧縮ポリシーを設定します。', - 'agents.claudeCodeRuntime.maxContextTokens': '最大コンテキスト', + 'agents.claudeCodeRuntime.maxContextTokens': '全体のコンテキスト上書き', 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code の既定値は 200000 です。上流モデルの上限を超えないでください。', 'agents.claudeCodeRuntime.context1mHint': 'このマッピングで [1m] モデルを使用し、最大コンテキストを 1000000 にします。3 つすべてを無効にすると 200000 に戻ります。', 'agents.claudeCodeRuntime.autoCompactPct': '自動圧縮の開始率', @@ -1466,6 +1466,29 @@ export const jaOverrides = { 'agents.claudeCodeRuntime.manageDefaultModelHint': '無効にすると ANTHROPIC_MODEL と model を上書きせず、/model で既定モデルを保存できます。', 'agents.claudeCodeRuntime.manageSubagentModel': 'Subagent モデルを CPA が固定', 'agents.claudeCodeRuntime.manageSubagentModelHint': '無効にすると CLAUDE_CODE_SUBAGENT_MODEL を設定せず、未指定の Subagent はメインモデルを継承します。', + 'agents.claudeCodeRuntime.connectionHint': 'CPA のモデル一覧を更新できません。カーネルと接続を確認して再試行してください。保存済みモデルと編集内容は保持されます。', + 'agents.claudeCodeRuntime.followRole': '{role} に追従 → {model}', + 'agents.claudeCodeRuntime.directOption': 'CPA モデルを直接指定', + 'agents.claudeCodeRuntime.directModel': '{model} に固定し、ロールの変更には追従しません。', + 'agents.claudeCodeRuntime.direct1m': 'このモデルで 1M コンテキストを使用', + 'agents.claudeCodeRuntime.unverifiedModel': '現在の CPA 一覧にないモデルです。ID またはエイリアスが利用可能か確認してください。', + 'agents.claudeCodeRuntime.selectionHint': 'ロール選択はロールの 1M 設定に従います。直接指定では個別に 1M を設定します。上流モデルの対応が必要です。', + 'agents.claudeCodeRuntime.advancedContext': 'コンテキストと自動圧縮(詳細)', + 'agents.claudeCodeRuntime.applySonnetToRoles': 'Sonnet を全ロールに適用', + 'agents.claudeCodeRuntime.roleRowsHint': '各行でリクエストモデルと 1M を設定します。起動・サブエージェントでロールを選ぶと、その行の設定に従います。', + 'agents.claudeCodeRuntime.roleMappings': 'ロールのマッピング', + 'agents.claudeCodeRuntime.sharedRoleModel': '共通ロールモデル', + 'agents.claudeCodeRuntime.sharedRoleModelHint': '通常は Opus、Sonnet、Haiku に同じ CPA モデルを使用します。個別ルートが必要な場合だけ展開してください。', + 'agents.claudeCodeRuntime.showRoleMappings': 'ロールを個別設定', + 'agents.claudeCodeRuntime.hideRoleMappings': '個別設定を閉じる', + 'agents.claudeCodeRuntime.extendedContext': '拡張コンテキスト指定(1M)', + 'agents.claudeCodeRuntime.sharedContextHint': 'ウィンドウ値は全体の上書きで、モデル別の制限ではありません。1M は拡張コンテキストの指定であり、上流モデルの容量を増やしません。', + 'agents.claudeCodeRuntime.startupModel': '起動モデル', + 'agents.claudeCodeRuntime.startupModelPlaceholder': 'Claude Code の既定モデルを使用', + 'agents.claudeCodeRuntime.startupModelHint': 'CPA のモデルを選択するか ID を入力します。空欄では Claude Code の既定の選択を維持します。', + 'agents.claudeCodeRuntime.subagentModel': 'Subagent モデル', + 'agents.claudeCodeRuntime.subagentModelPlaceholder': '既定の継承動作を使用', + 'agents.claudeCodeRuntime.subagentModelHint': 'CPA のモデルを選択するか ID を入力します。空欄ではサブエージェントの既定動作を維持します。', 'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用', 'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。', 'agents.claudeDesktopMapping.opus': 'Opus', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 53b3c37..db7d615 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1421,7 +1421,7 @@ export const en: Record = { 'agents.claudeDesktopMapping.error.duplicate': 'Duplicate alias or original model ID. Use a different ID for each row.', 'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model', 'agents.claudeCodeMapping.description': 'Choose the upstream model for each task role and configure one shared context and auto-compact policy.', - 'agents.claudeCodeRuntime.maxContextTokens': 'Maximum context', + 'agents.claudeCodeRuntime.maxContextTokens': 'Global context override', 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code defaults to 200000. Do not exceed the upstream model limit.', 'agents.claudeCodeRuntime.context1mHint': 'Uses the [1m] model for this mapping and sets the maximum context to 1000000. It returns to 200000 after all three mappings are off.', 'agents.claudeCodeRuntime.autoCompactPct': 'Auto-compact trigger', @@ -1432,6 +1432,29 @@ export const en: Record = { 'agents.claudeCodeRuntime.manageDefaultModelHint': 'When off, CPA leaves ANTHROPIC_MODEL and the root model untouched so /model can save the default.', 'agents.claudeCodeRuntime.manageSubagentModel': 'Let CPA pin the Subagent model', 'agents.claudeCodeRuntime.manageSubagentModelHint': 'When off, CPA leaves CLAUDE_CODE_SUBAGENT_MODEL unset so unspecified Subagents inherit the main model.', + 'agents.claudeCodeRuntime.connectionHint': 'Could not refresh CPA models. Check the kernel and connection, then retry. Saved models and edits are preserved.', + 'agents.claudeCodeRuntime.followRole': 'Follow {role} → {model}', + 'agents.claudeCodeRuntime.directOption': 'Direct CPA model', + 'agents.claudeCodeRuntime.directModel': 'Pinned to {model}; does not follow role mapping changes.', + 'agents.claudeCodeRuntime.direct1m': 'Use 1M context for this model', + 'agents.claudeCodeRuntime.unverifiedModel': 'This model is not in the current CPA list. Check that the ID or alias is available.', + 'agents.claudeCodeRuntime.selectionHint': 'Role selections follow their role’s 1M setting. Direct models have a separate 1M setting. The upstream must support the context size.', + 'agents.claudeCodeRuntime.advancedContext': 'Context and auto-compaction (advanced)', + 'agents.claudeCodeRuntime.applySonnetToRoles': 'Apply Sonnet to all roles', + 'agents.claudeCodeRuntime.roleRowsHint': 'Set each role’s request model and 1M flag together. Session and subagent role selections follow the corresponding row.', + 'agents.claudeCodeRuntime.roleMappings': 'Role mappings', + 'agents.claudeCodeRuntime.sharedRoleModel': 'Shared role model', + 'agents.claudeCodeRuntime.sharedRoleModelHint': 'Use one CPA model for Opus, Sonnet, and Haiku by default. Expand this only when you need separate routes.', + 'agents.claudeCodeRuntime.showRoleMappings': 'Configure roles separately', + 'agents.claudeCodeRuntime.hideRoleMappings': 'Hide separate role settings', + 'agents.claudeCodeRuntime.extendedContext': 'Extended context flag (1M)', + 'agents.claudeCodeRuntime.sharedContextHint': 'The window value is a global override, not a per-model limit. The 1M options send an extended-context flag; they do not increase upstream capacity.', + 'agents.claudeCodeRuntime.startupModel': 'Startup model', + 'agents.claudeCodeRuntime.startupModelPlaceholder': 'Use Claude Code default', + 'agents.claudeCodeRuntime.startupModelHint': 'Select a CPA model or enter a model ID. Leave empty to keep the Claude Code default.', + 'agents.claudeCodeRuntime.subagentModel': 'Subagent model', + 'agents.claudeCodeRuntime.subagentModelPlaceholder': 'Use default inheritance', + 'agents.claudeCodeRuntime.subagentModelHint': 'Select a CPA model or enter a model ID. Leave empty to keep the default subagent behavior.', 'agents.claudeDesktopMapping.customMapping': 'Use Model Aliases', 'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.', 'agents.claudeDesktopMapping.opus': 'Opus', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 0a5c770..a04307b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1420,7 +1420,7 @@ export const zhCN = { 'agents.claudeDesktopMapping.error.duplicate': '别名或原模型 ID 重复,请为每一行使用不同的 ID。', 'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型', 'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型,并统一配置上下文窗口与自动压缩。', - 'agents.claudeCodeRuntime.maxContextTokens': '最大窗口', + 'agents.claudeCodeRuntime.maxContextTokens': '全局窗口覆盖', 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code 默认 200000;请勿超过上游模型实际支持的窗口。', 'agents.claudeCodeRuntime.context1mHint': '为此映射使用 [1m] 模型并将最大窗口设为 1000000;三个映射均关闭后恢复为 200000。', 'agents.claudeCodeRuntime.autoCompactPct': '触发压缩百分比', @@ -1431,6 +1431,29 @@ export const zhCN = { 'agents.claudeCodeRuntime.manageDefaultModelHint': '关闭后不再写入 ANTHROPIC_MODEL 或根级 model,Claude Code 可通过 /model 保存默认模型。', 'agents.claudeCodeRuntime.manageSubagentModel': '由 CPA 固定 Subagent 模型', 'agents.claudeCodeRuntime.manageSubagentModelHint': '关闭后不再写入 CLAUDE_CODE_SUBAGENT_MODEL,未指定模型的 Subagent 继承主会话模型。', + 'agents.claudeCodeRuntime.connectionHint': '无法刷新 CPA 模型列表。请检查内核运行状态与连接后重试;已保存的模型和编辑内容会保留。', + 'agents.claudeCodeRuntime.followRole': '跟随 {role} → {model}', + 'agents.claudeCodeRuntime.directOption': '直接指定 CPA 模型', + 'agents.claudeCodeRuntime.directModel': '固定使用 {model},不随角色映射变化。', + 'agents.claudeCodeRuntime.direct1m': '此模型使用 1M 上下文', + 'agents.claudeCodeRuntime.unverifiedModel': '当前 CPA 列表中未找到此模型,请确认 ID 或别名可用。', + 'agents.claudeCodeRuntime.selectionHint': '跟随角色时使用该角色的 1M 设置;直接指定模型时单独设置 1M。上游须支持对应窗口。', + 'agents.claudeCodeRuntime.advancedContext': '上下文与自动压缩(高级)', + 'agents.claudeCodeRuntime.applySonnetToRoles': '将 Sonnet 应用到全部角色', + 'agents.claudeCodeRuntime.roleRowsHint': '每行设置一个角色的请求模型和 1M 标记;启动模型或子代理选择角色时,跟随对应行的设置。', + 'agents.claudeCodeRuntime.roleMappings': '角色映射', + 'agents.claudeCodeRuntime.sharedRoleModel': '统一角色模型', + 'agents.claudeCodeRuntime.sharedRoleModelHint': '默认让 Opus、Sonnet、Haiku 共用一个 CPA 模型;需要分流时再展开分别设置。', + 'agents.claudeCodeRuntime.showRoleMappings': '分别设置角色', + 'agents.claudeCodeRuntime.hideRoleMappings': '收起分别设置', + 'agents.claudeCodeRuntime.extendedContext': '扩展上下文标记(1M)', + 'agents.claudeCodeRuntime.sharedContextHint': '这里的窗口数值是全局覆盖,不是每个模型的独立窗口。1M 选项发送扩展上下文标记;它不会增加上游模型的实际容量。', + 'agents.claudeCodeRuntime.startupModel': '启动模型', + 'agents.claudeCodeRuntime.startupModelPlaceholder': '使用 Claude Code 默认模型', + 'agents.claudeCodeRuntime.startupModelHint': '选择 CPA 模型或输入模型 ID;留空保留 Claude Code 的默认选择。', + 'agents.claudeCodeRuntime.subagentModel': 'Subagent 模型', + 'agents.claudeCodeRuntime.subagentModelPlaceholder': '使用默认继承行为', + 'agents.claudeCodeRuntime.subagentModelHint': '选择 CPA 模型或输入模型 ID;留空不覆盖子代理的默认模型。', 'agents.claudeDesktopMapping.customMapping': '使用模型别名', 'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。', 'agents.claudeDesktopMapping.opus': 'Opus', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index e4bf1b4..529251d 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1,3 +1,5 @@ +import { ClaudeCodeRoleRows } from '../components/claude-code/ClaudeCodeRoleRows'; +import { ClaudeCodeModelInputs } from '../components/claude-code/ClaudeCodeModelInputs'; import { MessageNotice } from '../appNotice'; import { useCallback, @@ -159,6 +161,8 @@ type ClaudeModelMappings = { disableAutoCompact: boolean; manageDefaultModel: boolean; manageSubagentModel: boolean; + startupModel: string; + subagentModel: string; }; type AgentFormValues = { @@ -191,6 +195,8 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ disableAutoCompact: false, manageDefaultModel: true, manageSubagentModel: true, + startupModel: model, + subagentModel: model, }); const createClaudeModelMappingsByClient = (): Record< @@ -862,12 +868,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : activeStatus?.pluginVersion ?? undefined; useEffect(() => { - if (!isClaudeModelMappingClient || (selected !== 'claude-desktop' && !selectedModel)) return; + if (!isClaudeModelMappingClient) return; const appliedMappings = selected === 'claude-code' ? activeStatus?.claudeCodeModelMappings : activeStatus?.claudeDesktopModelMappings; const dirty = claudeModelMappingsDirtyRef.current[selected]; - if (!dirty && selected === 'claude-code') { + if (!dirty && selected === 'claude-code' && models.length > 0) { const appliedModels = appliedMappings ? claudeMappingRoles .map((role) => findAgentModel(models, appliedMappings[role.key])) @@ -891,9 +897,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const next: ClaudeModelMappings = { ...(selected === 'claude-desktop' ? { desktopModels: dirty && source.desktopModels ? source.desktopModels : appliedMappings ? desktopModelEntries(source) : createDefaultDesktopModels() } : {}), - opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), - sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), - haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel), + opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-code' ? source.opus || selectedModel : ''), + sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-code' ? source.sonnet || selectedModel : ''), + haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-code' ? source.haiku || selectedModel : ''), opus1m: Boolean(source.opus1m), sonnet1m: Boolean(source.sonnet1m), haiku1m: Boolean(source.haiku1m), @@ -902,6 +908,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP disableAutoCompact: Boolean(source.disableAutoCompact), manageDefaultModel: source.manageDefaultModel !== false, manageSubagentModel: source.manageSubagentModel !== false, + startupModel: source.startupModel ?? (source.manageDefaultModel === false ? '' : source.sonnet || ''), + subagentModel: source.subagentModel ?? (source.manageSubagentModel === false ? '' : source.haiku || ''), }; return sameAgentModelMappings(currentClientDraft, next) ? current @@ -1088,7 +1096,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP editClaudeModelMappings((current) => { const next = { ...current, [preference]: enabled }; if (selected === 'claude-code') { - const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m; + const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m + || [next.startupModel, next.subagentModel].some(value => /\[1m\]$/i.test(value.trim())); next.maxContextTokens = any1mEnabled ? 1_000_000 : DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS; @@ -1170,8 +1179,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP resolved.maxContextTokens = claudeModelMappingsDraft.maxContextTokens; resolved.autoCompactPct = claudeModelMappingsDraft.autoCompactPct; resolved.disableAutoCompact = claudeModelMappingsDraft.disableAutoCompact; - resolved.manageDefaultModel = claudeModelMappingsDraft.manageDefaultModel; - resolved.manageSubagentModel = claudeModelMappingsDraft.manageSubagentModel; + resolved.startupModel = claudeModelMappingsDraft.startupModel.trim(); + resolved.subagentModel = claudeModelMappingsDraft.subagentModel.trim(); + resolved.manageDefaultModel = resolved.startupModel.length > 0; + resolved.manageSubagentModel = resolved.subagentModel.length > 0; return resolved; }; @@ -1673,7 +1684,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP status={modelLoading || !activeStatus ? t('agents.modify.checking') : activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''} description={modificationDescription} />; - const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || modelError : ''); + const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || (selected === 'claude-code' ? '' : modelError) : ''); const editDesktopEntries = (entries: ClaudeDesktopModelMapping[]) => { setModelSelectionError(''); @@ -2047,7 +2058,44 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP - {selected === 'claude-code' ? ( + editClaudeModelMappings((current) => ({ + ...current, + [field]: value, + maxContextTokens: /\[1m\]$/i.test(value.trim()) ? 1_000_000 + : /\[1m\]$/i.test(current[field].trim()) && current.maxContextTokens === 1_000_000 + && !current.opus1m && !current.sonnet1m && !current.haiku1m + && !/\[1m\]$/i.test(current[field === 'startupModel' ? 'subagentModel' : 'startupModel'].trim()) + ? DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS : current.maxContextTokens, + [field === 'startupModel' ? 'manageDefaultModel' : 'manageSubagentModel']: value.trim().length > 0, + }))} + /> + {modelError ? ( +
+
{t('agents.model.loadFailed')}

{t('agents.claudeCodeRuntime.connectionHint')}

+ +
+ ) : null} + editClaudeModelMappings(current => ({ ...current, + opus: current.sonnet, haiku: current.sonnet, + opus1m: current.sonnet1m, haiku1m: current.sonnet1m, + }))} /> +
+ {t('agents.claudeCodeRuntime.advancedContext')} +

{t('agents.claudeCodeRuntime.sharedContextHint')}

{t('agents.claudeCodeRuntime.maxContextTokens')} @@ -2111,88 +2159,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP - -
- ) : null} -
- {claudeMappingRoles.map((role) => ( -
-
- {t(role.labelKey)} - -
- selectClaudeModelMapping(role.key, value)} - onRefresh={refreshModels} - /> -
- ))} -
+
) : null} diff --git a/src/pages/AuthFileManagementPage.css b/src/pages/AuthFileManagementPage.css index 7699699..da931a1 100644 --- a/src/pages/AuthFileManagementPage.css +++ b/src/pages/AuthFileManagementPage.css @@ -22,7 +22,7 @@ .auth-files-heading-actions > .auth-toolbar-quiet { border-color: transparent; background: transparent; color: var(--auth-muted); } .auth-files-heading-actions > .auth-toolbar-quiet:hover:not(:disabled) { background: var(--bg-hover); color: var(--auth-text); } .auth-files-page .auth-files-panel { - --auth-list-columns: minmax(160px, 1.15fr) 66px 88px 110px 152px minmax(220px, 3fr) 166px; + --auth-list-columns: minmax(168px, 1.1fr) 72px 92px 116px 156px minmax(240px, 2.4fr) 248px; padding: 0; min-height: 0; border: 0; @@ -41,36 +41,36 @@ .auth-files-page .auth-files-toolbar select:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; } .auth-file-table-scroll { min-width: 0; overflow-x: auto; padding: 0 0 2px; } .auth-file-list-head, .auth-credential-row { display: grid; grid-template-columns: var(--auth-list-columns); align-items: center; column-gap: 10px; } -.auth-file-list-head { min-width: 1148px; min-height: 36px; padding: 2px 13px 0; color: var(--auth-muted); font-size: 12px; font-weight: 700; } +.auth-file-list-head { min-width: 1280px; min-height: 38px; padding: 4px 14px 0; color: var(--auth-muted); font-size: 13px; font-weight: 700; } .auth-file-list-head > span { min-width: 0; line-height: 1.25; text-align: center; } .auth-file-list-head > :first-child, .auth-file-list-head > :nth-child(6) { text-align: left; } -.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1148px; gap: 8px; } -.auth-file-card { min-width: 0; padding: 0 12px; border: 1px solid var(--auth-border); border-radius: 8px; background: var(--bg-card); } +.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1280px; gap: 10px; } +.auth-file-card { min-width: 0; padding: 0 14px; border: 1px solid var(--auth-border); border-radius: 10px; background: var(--bg-card); } .auth-file-card:hover { border-color: color-mix(in srgb, var(--auth-accent) 24%, var(--auth-border)); } -.auth-credential-row { min-height: 102px; padding: 10px 0; } +.auth-credential-row { min-height: 108px; padding: 12px 0; } .auth-list-cell { min-width: 0; } .auth-card-header { display: flex; gap: 10px; align-items: center; min-width: 0; } .auth-card-header .provider-logo { width: 35px; height: 35px; object-fit: contain; flex-shrink: 0; } [data-theme="dark"] .auth-card-header .provider-logo[data-provider="xai"] { filter: invert(1); } .auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 5px; } -.auth-card-identity strong { color: var(--auth-text); font-size: 13px; font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.auth-card-filename, .auth-card-provider { color: var(--auth-muted); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auth-card-identity strong { color: var(--auth-text); font-size: 14px; font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auth-card-filename, .auth-card-provider { color: var(--auth-muted); font-size: 12px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .auth-list-plan, .auth-list-status { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; } -.auth-list-plan > span { max-width: 100%; color: var(--auth-text); font-size: 12px; font-weight: 600; text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.auth-list-plan small, .auth-list-status small { color: var(--auth-muted); font-size: 11px; line-height: 1.25; } +.auth-list-plan > span { max-width: 100%; color: var(--auth-text); font-size: 13px; font-weight: 600; text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auth-list-plan small, .auth-list-status small { color: var(--auth-muted); font-size: 12px; line-height: 1.3; } .auth-list-status small { font-weight: 600; } -.auth-status-badge { display: block; max-width: 100%; padding: 4px 8px; border-radius: 7px; background: var(--bg-secondary); color: var(--auth-muted); font-size: 12px; line-height: 1.4; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.auth-status-badge { display: block; max-width: 100%; padding: 4px 9px; border-radius: 7px; background: var(--bg-secondary); color: var(--auth-muted); font-size: 13px; line-height: 1.4; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .auth-status-badge.success { color: var(--success-color); background: color-mix(in srgb, var(--success-color) 8%, var(--bg-card)); } .auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 8%, var(--bg-card)); } .auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 7%, var(--bg-card)); } .auth-status-badge.info { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 8%, var(--bg-card)); } -.auth-list-muted { color: var(--auth-muted); font-size: 11px; } +.auth-list-muted { color: var(--auth-muted); font-size: 12px; } .auth-list-recent .auth-file-requests { gap: 9px; } -.auth-list-recent .auth-file-requests-latest { color: var(--auth-muted); font-size: 11px; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.auth-list-recent .auth-file-requests-latest { color: var(--auth-muted); font-size: 12px; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .auth-list-recent .auth-file-requests-blocks { gap: 2px; padding: 0; } .auth-list-recent .auth-file-request-block { height: 16px; border-radius: 2px; } -.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 10px; } -.auth-list-recent .auth-file-requests-unavailable { font-size: 10px; line-height: 1.4; text-align: center; color: var(--auth-muted); } +.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 11px; } +.auth-list-recent .auth-file-requests-unavailable { font-size: 12px; line-height: 1.4; text-align: center; color: var(--auth-muted); } .auth-list-usage .auth-file-usage { color: var(--auth-text); } .auth-list-usage .auth-file-usage-metrics { grid-template-columns: max-content max-content; grid-template-rows: repeat(2, 20px); gap: 0 18px; justify-content: center; align-content: center; } .auth-list-usage .auth-file-usage-metric { gap: 5px; } @@ -78,22 +78,22 @@ .auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; } .auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; border-radius: 4px; } .auth-list-usage .auth-file-usage-metric.total svg { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 9%, var(--bg-card)); } -.auth-list-usage .auth-file-usage-metric strong { font-size: 12.75px; line-height: 1.2; font-weight: 750; } +.auth-list-usage .auth-file-usage-metric strong { font-size: 13px; line-height: 1.2; font-weight: 750; } .auth-list-usage .auth-file-usage-scope { margin-top: 6px; color: var(--auth-muted); text-align: right; font-size: 10px; line-height: 1.2; } -.auth-card-actions { display: grid; grid-template-columns: 98px 54px; align-items: center; justify-content: center; gap: 12px; } -.auth-list-icon-actions { display: grid; grid-template-columns: repeat(3, 30px); gap: 4px; } -.auth-card-actions .auth-list-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 30px; min-width: 0; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 8px; color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 10%, var(--bg-card)); cursor: pointer; } -.auth-card-actions .auth-list-action.muted { color: var(--auth-muted); background: var(--bg-secondary); } -.auth-card-actions .auth-list-action.danger { color: #ef4444; background: color-mix(in srgb, #ef4444 9%, var(--bg-card)); } -.auth-card-actions .auth-list-action:disabled { opacity: .4; cursor: default; } -.auth-card-actions .auth-list-action:not(:disabled):hover { border-color: currentColor; } -.auth-list-main-actions { display: grid; justify-items: center; align-items: center; grid-template-rows: repeat(2, 30px); gap: 4px; border-left: 1px solid var(--auth-border); } -.auth-list-switch { display: flex; align-items: center; flex: none; width: 38px; height: 22px; padding: 2px; border: 1px solid color-mix(in srgb, var(--auth-muted) 18%, var(--bg-card)); border-radius: 20px; background: color-mix(in srgb, var(--auth-muted) 24%, var(--bg-card)); cursor: pointer; } -.auth-list-switch > span { display: block; width: 16px; height: 16px; flex-shrink: 0; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .15s; } +.auth-card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; } +.auth-list-icon-actions { display: flex; align-items: center; gap: 6px; } +.auth-card-actions .auth-list-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 32px; min-width: 32px; height: 32px; padding: 0; border: 1px solid color-mix(in srgb, var(--auth-accent) 16%, var(--auth-border)); border-radius: 8px; color: var(--auth-accent); background: var(--bg-card); cursor: pointer; } +.auth-card-actions .auth-list-action.muted { color: var(--auth-text); border-color: var(--auth-border); background: var(--bg-card); } +.auth-card-actions .auth-list-action.danger { color: #e11d48; border-color: color-mix(in srgb, #e11d48 22%, var(--auth-border)); background: var(--bg-card); } +.auth-card-actions .auth-list-action:disabled { opacity: .38; cursor: default; } +.auth-card-actions .auth-list-action:not(:disabled):hover { background: color-mix(in srgb, currentColor 8%, var(--bg-card)); border-color: currentColor; } +.auth-list-main-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-left: 10px; border-left: 1px solid var(--auth-border); } +.auth-list-switch { display: flex; align-items: center; flex: none; width: 40px; height: 24px; padding: 2px; border: 1px solid color-mix(in srgb, var(--auth-muted) 22%, var(--bg-card)); border-radius: 20px; background: color-mix(in srgb, var(--auth-muted) 28%, var(--bg-card)); cursor: pointer; } +.auth-list-switch > span { display: block; width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .15s; } .auth-list-switch[aria-checked="true"] { background: var(--auth-accent); border-color: var(--auth-accent); } .auth-list-switch[aria-checked="true"] > span { transform: translateX(16px); } .auth-list-switch:disabled { opacity: .5; cursor: default; } -.auth-list-details-button { width: 50px; min-height: 30px; padding: 4px; border: 0; border-radius: 8px; background: color-mix(in srgb, var(--auth-accent) 7%, var(--bg-card)); color: var(--auth-accent); font: inherit; font-size: 11px; font-weight: 650; cursor: pointer; } +.auth-list-details-button { width: auto; min-width: 52px; min-height: 32px; padding: 4px 10px; border: 1px solid color-mix(in srgb, var(--auth-accent) 18%, var(--auth-border)); border-radius: 8px; background: var(--bg-card); color: var(--auth-accent); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; white-space: nowrap; } .auth-list-details-button:hover { background: color-mix(in srgb, var(--auth-accent) 14%, var(--bg-card)); } .auth-list-action:focus-visible, .auth-list-switch:focus-visible, .auth-list-details-button:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 3px; } .auth-list-details { display: grid; gap: 12px; border-top: 1px solid var(--auth-border); padding: 12px 0; } @@ -105,7 +105,7 @@ .auth-card-note > span { flex-shrink: 0; color: var(--auth-muted); } .auth-card-note p { min-width: 0; margin: 0; color: var(--auth-text); white-space: pre-wrap; overflow-wrap: anywhere; } .auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; } -.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 0 0; color: var(--auth-muted); font-size: 11px; } +.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; width: 100%; max-width: 100%; margin-left: auto; padding: 12px 0 0; color: var(--auth-muted); font-size: 12px; position: sticky; left: 0; } .auth-list-pagination > span { margin-right: auto; } .auth-list-pagination label { display: flex; align-items: center; gap: 6px; } .auth-list-pagination select { min-height: 30px; border: 1px solid var(--auth-border); border-radius: 6px; background: var(--bg-card); color: var(--auth-muted); padding: 3px 6px; font: inherit; } @@ -153,14 +153,13 @@ @media (prefers-reduced-motion: reduce) { .credential-quota-track.indeterminate i { animation: none; width: 100%; opacity: .3; } } -@container credential-files (min-width: 701px) and (max-width: 1147px) { - .auth-file-list-head, .auth-credential-row { grid-template-columns: minmax(140px, 1fr) 60px 78px 96px 136px minmax(180px, 2fr) 142px; column-gap: 8px; } - .auth-file-card-grid, .auth-file-list-head { min-width: 906px; } - .auth-card-actions { grid-template-columns: 86px 46px; gap: 8px; } - .auth-list-icon-actions { grid-template-columns: repeat(3, 26px); } - .auth-card-actions .auth-list-action { width: 26px; height: 28px; border-radius: 6px; } - .auth-list-main-actions { grid-template-rows: repeat(2, 28px); } - .auth-list-details-button { width: 42px; min-height: 28px; } +@container credential-files (min-width: 701px) and (max-width: 1360px) { + .auth-file-list-head, .auth-credential-row { grid-template-columns: minmax(132px, 1fr) 58px 74px 92px 128px minmax(168px, 1.6fr) 214px;; column-gap: 8px; } + .auth-file-card-grid, .auth-file-list-head { min-width: 980px; } + .auth-card-actions { gap: 8px; } + .auth-list-icon-actions { gap: 4px; } + .auth-card-actions .auth-list-action { width: 30px; min-width: 30px; height: 30px; } + .auth-list-details-button { min-width: 48px; min-height: 30px; padding-inline: 8px; font-size: 12px; } .auth-list-usage .auth-file-usage-metric strong { font-size: 12px; } } @container credential-files (max-width: 700px) { diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index a820764..7e29417 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -31,6 +31,8 @@ export type AgentModelMappings = { disableAutoCompact?: boolean; manageDefaultModel?: boolean; manageSubagentModel?: boolean; + startupModel?: string; + subagentModel?: string; }; type ResolveAgentConfigurationActionOptions = { @@ -72,7 +74,9 @@ export const sameAgentModelMappings = ( && (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90) && Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact) && (left.manageDefaultModel ?? true) === (right.manageDefaultModel ?? true) - && (left.manageSubagentModel ?? true) === (right.manageSubagentModel ?? true); + && (left.manageSubagentModel ?? true) === (right.manageSubagentModel ?? true) + && (left.startupModel ?? '') === (right.startupModel ?? '') + && (left.subagentModel ?? '') === (right.subagentModel ?? ''); export const resolveAgentModelMappingsDraftSource = ( current: T, diff --git a/src/services/claudeCodeModels.ts b/src/services/claudeCodeModels.ts new file mode 100644 index 0000000..a6f8ce9 --- /dev/null +++ b/src/services/claudeCodeModels.ts @@ -0,0 +1,28 @@ +import type { ModelOption } from './modelService'; + +export const claudeCodeRoles = ['opus', 'sonnet', 'haiku'] as const; +export type ClaudeCodeRole = typeof claudeCodeRoles[number]; +export type ClaudeCodeRoutes = Record & { + opus1m: boolean; sonnet1m: boolean; haiku1m: boolean; +}; + +export function claudeCodeModelBase(value: string): string { + return value.trim().replace(/(?:\[1m\])+$/i, ''); +} + +export function claudeCodeRole(value: string): ClaudeCodeRole | null { + const base = claudeCodeModelBase(value).toLowerCase(); + return claudeCodeRoles.find(role => role === base) ?? null; +} + +export function claudeCodeModelPreview(value: string, routes: ClaudeCodeRoutes) { + const role = claudeCodeRole(value); + const base = role ? claudeCodeModelBase(routes[role]) : claudeCodeModelBase(value); + const context1m = /\[1m\]$/i.test(value.trim()) || Boolean(role && routes[`${role}1m`]); + return { role, model: base ? base + (context1m ? '[1m]' : '') : '', context1m }; +} + +export function isKnownClaudeCodeModel(value: string, models: ModelOption[]): boolean { + const base = claudeCodeModelBase(value).toLowerCase(); + return !base || Boolean(claudeCodeRole(value)) || models.some(model => model.name.toLowerCase() === base); +} diff --git a/src/styles.css b/src/styles.css index d058a54..769bfd8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7418,6 +7418,7 @@ textarea:focus-visible { white-space: nowrap; } + .agent-claude-context-toggle { display: flex; flex: none; diff --git a/tests/auth-file-list-ui.cjs b/tests/auth-file-list-ui.cjs index 93e3050..a725650 100644 --- a/tests/auth-file-list-ui.cjs +++ b/tests/auth-file-list-ui.cjs @@ -44,7 +44,7 @@ const fs = require('node:fs/promises'); className: node.className, width: node.clientWidth, scroll: node.scrollWidth, overflowX: getComputedStyle(node).overflowX, }))); assert.ok(dimensions.every(size => size.scroll <= size.width + 1 || ( - size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 906 && size.overflowX === 'auto' + size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 980 && size.overflowX === 'auto' )), `${label}: only intermediate widths may scroll inside the table; cards must fit ${JSON.stringify(dimensions)}`); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`); }; @@ -93,7 +93,7 @@ const fs = require('node:fs/promises'); const resetTitle = await resetTimes.first().getAttribute('title'); assert.ok(resetTitle.startsWith(await resetTimes.first().innerText()) && resetTitle.includes('小时后'), 'Compact timestamps retain their exact time and relative countdown on hover'); await next().click(); - await card(11).waitFor(); + await card(7).waitFor(); assert.equal(await cards().count(), 2); assert.equal(await next().isDisabled(), true); assert.ok((await pagination().innerText()).includes('2 / 2')); @@ -235,10 +235,10 @@ const fs = require('node:fs/promises'); await page.setViewportSize({ width: 1800, height: 1200 }); await open('theme=light&locale=zh-CN'); const tableBounds = await page.locator('.auth-file-table-scroll').boundingBox(); - const seventhBounds = await card(7).boundingBox(); + const lastBounds = await cards().last().boundingBox(); await page.screenshot({ path: path.join(screenshotDir, 'reference-dense-preview.png'), clip: { x: tableBounds.x, y: tableBounds.y, width: tableBounds.width, - height: seventhBounds.y + seventhBounds.height - tableBounds.y, + height: lastBounds.y + lastBounds.height - tableBounds.y, } }); await open('theme=light&locale=zh-CN&small=1'); await page.locator('.auth-file-table-scroll').screenshot({ path: path.join(screenshotDir, 'reference-small-idle-preview.png') }); diff --git a/tests/claude-code-model-inputs-ui.cjs b/tests/claude-code-model-inputs-ui.cjs new file mode 100644 index 0000000..fa2fc1c --- /dev/null +++ b/tests/claude-code-model-inputs-ui.cjs @@ -0,0 +1,77 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=claude-code&reset-selections'); + const startup = page.getByRole('combobox', { name: '启动模型', exact: true }); + const subagent = page.getByRole('combobox', { name: 'Subagent 模型', exact: true }); + await startup.click(); + await page.getByRole('option', { name: /^sonnet / }).click(); + assert.equal(await startup.inputValue(), 'sonnet'); + await page.locator('.claude-role-row .agent-model-trigger').first().click(); + await page.getByRole('option', { name: /gpt-two/ }).click(); + await page.getByText('跟随 sonnet → gpt-two', { exact: true }).waitFor(); + assert.equal(await startup.inputValue(), 'sonnet'); + await startup.click(); + await page.getByRole('option', { name: /^gpt-two / }).click(); + await page.locator('.claude-code-context-settings summary').click(); + const context = page.getByRole('checkbox', { name: '启动模型 1M', exact: true }); + await context.check(); + assert.equal(await startup.inputValue(), 'gpt-two[1m]'); + assert.equal(await page.getByRole('spinbutton', { name: '全局窗口覆盖', exact: true }).inputValue(), '1000000'); + await context.uncheck(); + assert.equal(await page.getByRole('spinbutton', { name: '全局窗口覆盖', exact: true }).inputValue(), '200000'); + assert.equal(await startup.inputValue(), 'gpt-two'); + await page.locator('.claude-code-context-settings summary').click(); + assert.equal(await page.locator('.claude-role-row input[type=checkbox]').count(), 3); + await subagent.fill('custom-worker'); + await subagent.press('Enter'); + assert.equal(await subagent.inputValue(), 'custom-worker'); + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.startupModel === 'gpt-two' && c.args?.claudeCodeModelMappings?.subagentModel === 'custom-worker')); + await subagent.fill('unsaved-worker'); + await subagent.press('Escape'); + await page.evaluate(() => { window.fixtureFailedCommands = ['get_agent_models']; }); + await page.locator('.claude-role-row .agent-model-trigger').first().click(); + await page.locator('.agent-model-dropdown:visible button').filter({ has: page.locator('svg.lucide-refresh-cw') }).click(); + await page.locator('.claude-code-model-error').waitFor(); + assert.equal(await startup.inputValue(), 'gpt-two'); + assert.equal(await subagent.inputValue(), 'unsaved-worker'); + assert.match(await page.locator('.claude-role-row .agent-model-trigger').first().innerText(), /gpt-two/); + await subagent.press('Escape'); + await page.evaluate(() => { window.fixtureFailedCommands = []; }); + await page.locator('.claude-code-model-error button').click(); + await page.locator('.claude-code-model-error').waitFor({ state: 'hidden' }); + assert.equal(await subagent.inputValue(), 'unsaved-worker'); + await startup.click(); + await page.getByRole('option', { name: '使用 Claude Code 默认模型', exact: true }).click(); + await subagent.click(); + await page.getByRole('option', { name: '使用默认继承行为', exact: true }).click(); + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.startupModel === '' && c.args?.claudeCodeModelMappings?.subagentModel === '')); + await page.getByRole('checkbox', { name: 'sonnet 1M', exact: true }).check(); + await page.getByRole('button', { name: '将 Sonnet 应用到全部角色', exact: true }).click(); + for (const role of ['sonnet', 'opus', 'haiku']) { + assert.equal(await page.getByRole('checkbox', { name: `${role} 1M`, exact: true }).isChecked(), true); + assert.match(await page.locator(`.claude-role-row[data-role="${role}"] .agent-model-trigger`).innerText(), /gpt-two/); + } + await page.getByRole('button', { name: '更新配置', exact: true }).click(); + await page.waitForFunction(() => window.fixtureCalls.some(c => { + const m = c.args?.claudeCodeModelMappings; + return m && m.opus === 'gpt-two' && m.haiku === 'gpt-two' && m.opus1m && m.haiku1m && m.sonnet1m; + })); + for (const width of [1280, 760]) { + await page.setViewportSize({ width, height: 1000 }); + await page.screenshot({ path: path.join(os.tmpdir(), `claude-code-models-${width}.png`), fullPage: true }); + for (const field of await page.locator('.claude-code-model-field').all()) { + assert(await field.evaluate(el => el.scrollWidth <= el.clientWidth + 1)); + } + } + console.log('PASS: select, custom input, defaults, connection failure/retry, draft retention and responsive layout'); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/claudeCodeModels.test.ts b/tests/claudeCodeModels.test.ts new file mode 100644 index 0000000..cbd1521 --- /dev/null +++ b/tests/claudeCodeModels.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test'; +import { claudeCodeModelPreview, isKnownClaudeCodeModel } from '../src/services/claudeCodeModels'; + +const routes = { opus: 'model-a', sonnet: 'model-b', haiku: 'model-c', opus1m: true, sonnet1m: false, haiku1m: false }; +test('role selections follow changed mappings and role context flags', () => { + expect(claudeCodeModelPreview('opus', routes).model).toBe('model-a[1m]'); + expect(claudeCodeModelPreview('sonnet', { ...routes, sonnet: 'model-d', sonnet1m: true }).model).toBe('model-d[1m]'); +}); +test('direct model stays pinned and has its own context flag', () => { + expect(claudeCodeModelPreview('model-a', routes)).toEqual({ role: null, model: 'model-a', context1m: false }); + expect(claudeCodeModelPreview('model-a[1m]', { ...routes, opus: 'other' }).model).toBe('model-a[1m]'); +}); +test('role aliases do not need to appear in CPA model list', () => { + expect(isKnownClaudeCodeModel('sonnet[1m]', [])).toBeTrue(); + expect(isKnownClaudeCodeModel('', [])).toBeTrue(); + expect(isKnownClaudeCodeModel('model-a[1m]', [{ name: 'model-a' }])).toBeTrue(); + expect(isKnownClaudeCodeModel('unknown', [{ name: 'model-a' }])).toBeFalse(); +});