feat(claude-code): refine model mapping UI and defaults

- Add independent Fable model mapping
- Rename startup model to default model
- Default to the Opus mapping and preserve Subagent inheritance
- Replace 1M checkboxes with standard toggle switches
- Align form fields and simplify explanatory text
- Remove bulk role synchronization
- Update translations and regression tests
This commit is contained in:
lzt404 committed 2026-10-08 18:36:58 +08:00
1 parent b90445143c
commit 97ba1051a2
17 files changed
+206 -118

No files matched your search

+5 -1
View File
@@ -1038,6 +1038,8 @@ pub(crate) fn resolve_claude_desktop_model_mappings(
opus_1m: requested.opus_1m,
sonnet_1m: requested.sonnet_1m,
haiku_1m: requested.haiku_1m,
fable: resolve(if requested.fable.trim().is_empty() { &requested.sonnet } else { &requested.fable })?,
fable_1m: requested.fable_1m,
max_context_tokens: requested.max_context_tokens,
auto_compact_pct: requested.auto_compact_pct,
disable_auto_compact: requested.disable_auto_compact,
@@ -1067,7 +1069,7 @@ pub(crate) fn resolve_claude_code_model_mappings(
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 {
let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m || requested.fable_1m || explicit_1m {
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
} else {
requested.max_context_tokens
@@ -1087,6 +1089,8 @@ pub(crate) fn resolve_claude_code_model_mappings(
opus_1m: requested.opus_1m,
sonnet_1m: requested.sonnet_1m,
haiku_1m: requested.haiku_1m,
fable: resolve(if requested.fable.trim().is_empty() { &requested.sonnet } else { &requested.fable })?,
fable_1m: requested.fable_1m,
max_context_tokens,
auto_compact_pct: requested.auto_compact_pct,
disable_auto_compact: requested.disable_auto_compact,
+2 -1
View File
@@ -609,7 +609,7 @@ pub(crate) fn build_claude_agent_config(
("ANTHROPIC_DEFAULT_OPUS_MODEL", model_settings.opus.as_str()),
(
"ANTHROPIC_DEFAULT_FABLE_MODEL",
model_settings.sonnet.as_str(),
model_settings.fable.as_str(),
),
] {
env.insert(
@@ -684,6 +684,7 @@ pub(crate) fn build_claude_agent_config(
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.fable, if mappings.fable_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
(&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 }),
+6 -2
View File
@@ -2982,6 +2982,8 @@ pub(crate) fn inspect_claude_code_model_mappings(
opus_1m: opus_had_1m,
sonnet_1m: sonnet_had_1m,
haiku_1m: haiku_had_1m,
fable: read_model("ANTHROPIC_DEFAULT_FABLE_MODEL").map(|entry| entry.0).unwrap_or_default(),
fable_1m: read_model("ANTHROPIC_DEFAULT_FABLE_MODEL").is_some_and(|entry| entry.1),
max_context_tokens,
auto_compact_pct,
disable_auto_compact,
@@ -3129,6 +3131,8 @@ pub(crate) fn claude_code_model_settings(
opus_1m: mappings.opus_1m,
sonnet_1m: mappings.sonnet_1m,
haiku_1m: mappings.haiku_1m,
fable: claude_code_model_setting(if mappings.fable.is_empty() { &mappings.sonnet } else { &mappings.fable }, if mappings.fable.is_empty() { mappings.sonnet_1m } else { mappings.fable_1m }),
fable_1m: mappings.fable_1m,
max_context_tokens: mappings.max_context_tokens,
auto_compact_pct: mappings.auto_compact_pct,
disable_auto_compact: mappings.disable_auto_compact,
@@ -3196,8 +3200,8 @@ pub(crate) fn claude_code_model_presentation_environment(
);
let fable = claude_code_model_presentation(
models,
&mappings.sonnet,
mappings.sonnet_1m,
if mappings.fable.is_empty() { &mappings.sonnet } else { &mappings.fable },
mappings.fable_1m,
Some(mappings.max_context_tokens),
Some("Fable mapping"),
);
+6
View File
@@ -1144,6 +1144,10 @@ struct ClaudeDesktopModelMappings {
#[serde(default)]
haiku: String,
#[serde(default)]
fable: String,
#[serde(default)]
fable_1m: bool,
#[serde(default)]
opus_1m: bool,
#[serde(default)]
sonnet_1m: bool,
@@ -1225,6 +1229,8 @@ impl ClaudeDesktopModelMappings {
opus: model.to_string(),
sonnet: model.to_string(),
haiku: model.to_string(),
fable: model.to_string(),
fable_1m: false,
opus_1m: false,
sonnet_1m: false,
haiku_1m: false,
@@ -222,6 +222,8 @@ fn claude_code_role_mappings_drive_settings() {
disable_auto_compact: false,
manage_default_model: true,
manage_subagent_model: true,
fable: String::new(),
fable_1m: false,
startup_model: None,
subagent_model: None,
};
@@ -342,6 +344,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
disable_auto_compact: true,
manage_default_model: true,
manage_subagent_model: true,
fable: String::new(),
fable_1m: false,
startup_model: None,
subagent_model: None,
};
@@ -2692,3 +2696,24 @@ fn windows_batch_agent_commands_use_call_without_embedded_quotes() {
);
assert_eq!(native.get_args().count(), 0);
}
#[test]
fn claude_code_fable_mapping_is_independent_and_round_trips() {
let directory = agent_test_home("fable-mapping");
let path = directory.join("settings.json");
let models = test_agent_models(&["route-sonnet", "route-fable"]);
let mut mappings = ClaudeDesktopModelMappings::all("route-sonnet");
mappings.fable = "route-fable".into();
mappings.fable_1m = true;
mappings.startup_model = Some("fable".into());
let resolved = resolve_claude_code_model_mappings(AgentClient::ClaudeCode, &models, "route-sonnet", Some(mappings)).unwrap().unwrap();
let rendered = build_claude_agent_config(None, "http://127.0.0.1:8317", "test-key", "route-sonnet", &models, Some(&resolved)).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_FABLE_MODEL"], "route-fable[1m]");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "route-sonnet");
assert_eq!(value["model"], "fable");
fs::write(&path, rendered).unwrap();
let inspected = inspect_claude_code_model_mappings(&path).unwrap().unwrap();
assert_eq!(inspected.fable, "route-fable");
assert!(inspected.fable_1m);
}
@@ -6,7 +6,7 @@ type Props = {
startupModel: string;
subagentModel: string;
disabled: boolean;
onRoleChange: (field: 'opus1m' | 'sonnet1m' | 'haiku1m', enabled: boolean) => void;
onRoleChange: (field: 'opus1m' | 'sonnet1m' | 'haiku1m' | 'fable1m', enabled: boolean) => void;
onModelChange: (field: 'startupModel' | 'subagentModel', value: string) => void;
};
@@ -1,53 +1,113 @@
.claude-code-model-inputs {
grid-column: 1 / -1;
.claude-code-model-inputs,
.claude-role-table {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
gap: 12px;
}
.claude-code-model-inputs { grid-column: 1 / -1; }
.claude-code-model-field {
display: grid;
align-content: start;
gap: 8px;
grid-template-columns: 112px minmax(0, 1fr);
align-items: center;
gap: 6px 12px;
min-width: 0;
}
.claude-model-with-context {
display: grid;
grid-template-columns: minmax(0, 1fr) 76px;
align-items: center;
gap: 12px;
min-width: 0;
}
.claude-role-table {
padding-top: 16px;
border-top: 1px solid var(--theme-e3e1db);
}
.claude-role-table > strong {
color: var(--theme-817b72);
font-size: var(--font-size-caption);
font-weight: 500;
}
.claude-role-row {
display: grid;
grid-template-columns: 112px minmax(0, 1fr) 76px;
align-items: center;
gap: 12px;
min-width: 0;
}
.claude-code-model-field > strong,
.claude-role-row > strong {
font-size: var(--font-size-label);
font-weight: 500;
color: var(--theme-5f5a53);
}
.claude-code-model-inputs .agent-model-trigger,
.claude-role-table .agent-model-trigger {
min-height: 40px;
height: 40px;
padding: 7px 11px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 6px;
background: var(--theme-fff);
font-weight: 400;
}
.claude-role-table .agent-model-trigger strong { font-weight: 400; }
.claude-code-model-field .agent-model-trigger input { min-width: 0; width: 100%; }
.claude-code-model-field > small {
grid-column: 2;
color: var(--theme-817b72);
font-size: var(--font-size-caption);
line-height: 1.4;
overflow-wrap: anywhere;
}
@container agent-panel (max-width: 560px) {
.claude-code-model-inputs {
grid-template-columns: 1fr;
}
.claude-inline-context {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: var(--font-size-caption);
color: var(--theme-5f5a53);
}
.claude-inline-context .switch-control input:focus-visible + .switch-track {
outline: 2px solid var(--theme-2d2a26);
outline-offset: 2px;
}
.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-context-settings {
margin-top: 4px;
border-top: 1px solid var(--theme-e3e1db);
padding-top: 12px;
}
.claude-code-context-settings summary {
cursor: pointer;
font-weight: 500;
font-size: var(--font-size-label);
padding: 4px 0;
}
.claude-code-model-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--theme-e3e1db);
border-radius: 6px;
}
.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; }
@container agent-panel (max-width: 460px) {
.claude-code-model-field { grid-template-columns: minmax(0, 1fr); }
.claude-code-model-field > small { grid-column: 1; }
.claude-role-row { grid-template-columns: minmax(0, 1fr) 76px; gap: 6px 12px; }
.claude-role-row > strong { grid-column: 1 / -1; }
}
@@ -26,7 +26,7 @@ export function ClaudeCodeModelInputs({ startupModel, subagentModel, models, rou
return (
<div className="claude-code-model-inputs">
{(['startupModel', 'subagentModel'] as const).map((field) => {
const value = field === 'startupModel' ? startupModel : subagentModel;
const value = field === 'startupModel' ? startupModel || 'opus' : subagentModel;
const preview = claudeCodeModelPreview(value, routes);
return (
<div className="claude-code-model-field" key={field}>
@@ -35,27 +35,22 @@ export function ClaudeCodeModelInputs({ startupModel, subagentModel, models, rou
<AgentModelPicker
models={options}
value={value}
onChange={(value) => onChange(field, value)}
onChange={(value) => onChange(field, field === 'startupModel' ? value.trim() || 'opus' : value)}
disabled={disabled}
loading={loading}
allowCustomValue
emptyOption={t(`agents.claudeCodeRuntime.${field}Placeholder`)}
emptyOption={field === 'subagentModel' ? t('agents.claudeCodeRuntime.subagentModelPlaceholder') : undefined}
editable={{ label: t(`agents.claudeCodeRuntime.${field}`),
placeholder: t(`agents.claudeCodeRuntime.${field}Placeholder`), maxLength: 240 }}
/>
<label className="claude-inline-context">
<input type="checkbox" aria-label={`${t(`agents.claudeCodeRuntime.${field}`)} 1M`}
<span className="switch-control"><input type="checkbox" role="switch" aria-label={`${t(`agents.claudeCodeRuntime.${field}`)} 1M`}
checked={preview.context1m} disabled={disabled || !value.trim() || Boolean(preview.role)}
onChange={event => onChange(field, claudeCodeModelBase(value) + (event.currentTarget.checked ? '[1m]' : ''))} />
1M
<span className="switch-track" aria-hidden="true" /></span><span>1M</span>
</label>
</div>
<small className="claude-code-model-summary" aria-live="polite">
{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`)}
</small>
{preview.role && <small className="claude-code-model-summary" aria-live="polite">{preview.role} → {preview.model}</small>}
{!loading && !isKnownClaudeCodeModel(value, models) ? (
<small role="status">{t('agents.claudeCodeRuntime.unverifiedModel')}</small>
) : null}
@@ -14,26 +14,20 @@ type Props = {
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) {
export function ClaudeCodeRoleRows({ routes, models, loading, error, disabled, onModelChange, onContextChange, onRefresh }: Props) {
const { t } = useI18n();
return <section className="claude-role-table" aria-label={t('agents.claudeCodeRuntime.roleMappings')}>
<div className="claude-role-toolbar">
<strong>{t('agents.claudeCodeRuntime.roleMappings')}</strong>
<button type="button" className="secondary-button" disabled={disabled || !routes.sonnet}
onClick={onApplyAll}>{t('agents.claudeCodeRuntime.applySonnetToRoles')}</button>
</div>
<p>{t('agents.claudeCodeRuntime.roleRowsHint')}</p>
{(['sonnet', 'opus', 'haiku'] as const).map(role => <div className="claude-role-row" key={role} data-role={role}>
return <section className="claude-role-table" aria-label={t('agents.claudeCodeRuntime.modelMappings')}>
<strong>{t('agents.claudeCodeRuntime.modelMappings')}</strong>
{(['sonnet', 'opus', 'fable', 'haiku'] as const).map(role => <div className="claude-role-row" key={role} data-role={role}>
<strong>{role[0].toUpperCase() + role.slice(1)}</strong>
<AgentModelPicker preserveValue models={models} value={routes[role]} loading={loading} error={error}
disabled={disabled} onChange={value => onModelChange(role, value)} onRefresh={onRefresh} />
<label className="claude-inline-context">
<input type="checkbox" checked={routes[`${role}1m`]} disabled={disabled}
<span className="switch-control"><input type="checkbox" role="switch" checked={routes[`${role}1m`]} disabled={disabled}
aria-label={`${role} 1M`} onChange={event => onContextChange(`${role}1m`, event.currentTarget.checked)} />
1M
<span className="switch-track" aria-hidden="true" /></span><span>1M</span>
</label>
</div>)}
</section>;
+6 -5
View File
@@ -1474,23 +1474,24 @@ export const jaOverrides = {
'agents.claudeCodeRuntime.unverifiedModel': '現在の CPA 一覧にないモデルです。ID またはエイリアスが利用可能か確認してください。',
'agents.claudeCodeRuntime.selectionHint': 'ロール選択はロールの 1M 設定に従います。直接指定では個別に 1M を設定します。上流モデルの対応が必要です。',
'agents.claudeCodeRuntime.advancedContext': 'コンテキストと自動圧縮(詳細)',
'agents.claudeCodeRuntime.applySonnetToRoles': 'Sonnet を全ロールに適用',
'agents.claudeCodeRuntime.roleMappings': 'モデルマッピング',
'agents.claudeCodeRuntime.modelMappings': 'モデルマッピング',
'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.startupModel': 'デフォルトモデル',
'agents.claudeCodeRuntime.startupModelPlaceholder': 'Opus のマッピングに従う',
'agents.claudeCodeRuntime.startupModelHint': '既定では Opus のマッピングに従います。別のモデルも選択できます。',
'agents.claudeCodeRuntime.subagentModel': 'Subagent モデル',
'agents.claudeCodeRuntime.subagentModelPlaceholder': '既定の継承動作を使用',
'agents.claudeCodeRuntime.subagentModelHint': 'CPA のモデルを選択するか ID を入力します。空欄ではサブエージェントの既定動作を維持します。',
'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用',
'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。',
'agents.claudeDesktopMapping.fable': 'Fable',
'agents.claudeDesktopMapping.opus': 'Opus',
'agents.claudeDesktopMapping.sonnet': 'Sonnet',
'agents.claudeDesktopMapping.haiku': 'Haiku',
+6 -5
View File
@@ -1440,23 +1440,24 @@ export const en: Record<MessageKey, string> = {
'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.roleMappings': 'Model mappings',
'agents.claudeCodeRuntime.modelMappings': 'Model mappings',
'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.startupModel': 'Default model',
'agents.claudeCodeRuntime.startupModelPlaceholder': 'Follow Opus mapping',
'agents.claudeCodeRuntime.startupModelHint': 'Follows the Opus mapping by default; you can select another model.',
'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.fable': 'Fable',
'agents.claudeDesktopMapping.opus': 'Opus',
'agents.claudeDesktopMapping.sonnet': 'Sonnet',
'agents.claudeDesktopMapping.haiku': 'Haiku',
+6 -5
View File
@@ -1439,23 +1439,24 @@ export const zhCN = {
'agents.claudeCodeRuntime.unverifiedModel': '当前 CPA 列表中未找到此模型,请确认 ID 或别名可用。',
'agents.claudeCodeRuntime.selectionHint': '跟随角色时使用该角色的 1M 设置;直接指定模型时单独设置 1M。上游须支持对应窗口。',
'agents.claudeCodeRuntime.advancedContext': '上下文与自动压缩(高级)',
'agents.claudeCodeRuntime.applySonnetToRoles': '将 Sonnet 应用到全部角色',
'agents.claudeCodeRuntime.roleMappings': '模型映射',
'agents.claudeCodeRuntime.modelMappings': '模型映射',
'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.startupModel': '默认模型',
'agents.claudeCodeRuntime.startupModelPlaceholder': '跟随 Opus 映射',
'agents.claudeCodeRuntime.startupModelHint': '默认跟随 Opus 映射,也可选择其他模型。',
'agents.claudeCodeRuntime.subagentModel': 'Subagent 模型',
'agents.claudeCodeRuntime.subagentModelPlaceholder': '使用默认继承行为',
'agents.claudeCodeRuntime.subagentModelHint': '选择 CPA 模型或输入模型 ID;留空不覆盖子代理的默认模型。',
'agents.claudeDesktopMapping.customMapping': '使用模型别名',
'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。',
'agents.claudeDesktopMapping.fable': 'Fable',
'agents.claudeDesktopMapping.opus': 'Opus',
'agents.claudeDesktopMapping.sonnet': 'Sonnet',
'agents.claudeDesktopMapping.haiku': 'Haiku',
+19 -19
View File
@@ -153,6 +153,8 @@ type ClaudeModelMappings = {
opus: string;
sonnet: string;
haiku: string;
fable: string;
fable1m: boolean;
opus1m: boolean;
sonnet1m: boolean;
haiku1m: boolean;
@@ -187,6 +189,8 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
opus: model,
sonnet: model,
haiku: model,
fable: model,
fable1m: false,
opus1m: false,
sonnet1m: false,
haiku1m: false,
@@ -194,9 +198,9 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
autoCompactPct: DEFAULT_CLAUDE_AUTO_COMPACT_PCT,
disableAutoCompact: false,
manageDefaultModel: true,
manageSubagentModel: true,
startupModel: model,
subagentModel: model,
manageSubagentModel: false,
startupModel: 'opus',
subagentModel: '',
});
const createClaudeModelMappingsByClient = (): Record<
@@ -219,6 +223,7 @@ let codexOauthConfigurationDraftCache: boolean | null = null;
let agentFormEditBaselineCache: Partial<Record<AgentClientId, AgentFormValues>> = {};
const claudeMappingRoles = [
{ key: 'fable', contextKey: 'fable1m', labelKey: 'agents.claudeDesktopMapping.fable' },
{
key: 'opus',
contextKey: 'opus1m',
@@ -903,13 +908,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
opus1m: Boolean(source.opus1m),
sonnet1m: Boolean(source.sonnet1m),
haiku1m: Boolean(source.haiku1m),
fable: source.fable || source.sonnet || selectedModel,
fable1m: Boolean(source.fable1m),
maxContextTokens: source.maxContextTokens ?? DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS,
autoCompactPct: source.autoCompactPct ?? DEFAULT_CLAUDE_AUTO_COMPACT_PCT,
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 || ''),
startupModel: source.startupModel?.trim() || 'opus',
subagentModel: source.subagentModel ?? '',
};
return sameAgentModelMappings(currentClientDraft, next)
? current
@@ -1079,7 +1086,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const selectClaudeModelMapping = (
role: 'opus' | 'sonnet' | 'haiku',
role: 'opus' | 'sonnet' | 'haiku' | 'fable',
value: string,
) => {
const model = findAgentModel(models, value);
@@ -1089,14 +1096,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
};
const changeClaude1mPreference = (
preference: 'opus1m' | 'sonnet1m' | 'haiku1m',
preference: 'opus1m' | 'sonnet1m' | 'haiku1m' | 'fable1m',
enabled: boolean,
) => {
if (!isClaudeModelMappingClient) return;
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.fable1m
|| [next.startupModel, next.subagentModel].some(value => /\[1m\]$/i.test(value.trim()));
next.maxContextTokens = any1mEnabled
? 1_000_000
@@ -1179,7 +1186,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
resolved.maxContextTokens = claudeModelMappingsDraft.maxContextTokens;
resolved.autoCompactPct = claudeModelMappingsDraft.autoCompactPct;
resolved.disableAutoCompact = claudeModelMappingsDraft.disableAutoCompact;
resolved.startupModel = claudeModelMappingsDraft.startupModel.trim();
resolved.startupModel = claudeModelMappingsDraft.startupModel.trim() || 'opus';
resolved.subagentModel = claudeModelMappingsDraft.subagentModel.trim();
resolved.manageDefaultModel = resolved.startupModel.length > 0;
resolved.manageSubagentModel = resolved.subagentModel.length > 0;
@@ -2036,9 +2043,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<strong>{t(selected === 'claude-code'
? 'agents.claudeCodeMapping.title'
: 'agents.claudeDesktopMapping.title')}</strong>
<span>{t(selected === 'claude-code'
? 'agents.claudeCodeMapping.description'
: 'agents.claudeDesktopMapping.description')}</span>
</div>
<div className="agent-section-heading-actions">
<label
@@ -2070,7 +2075,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
[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
&& !current.opus1m && !current.sonnet1m && !current.haiku1m && !current.fable1m
&& !/\[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,
@@ -2088,14 +2093,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
loading={modelLoading} error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onModelChange={selectClaudeModelMapping} onContextChange={changeClaude1mPreference}
onRefresh={refreshModels}
onApplyAll={() => editClaudeModelMappings(current => ({ ...current,
opus: current.sonnet, haiku: current.sonnet,
opus1m: current.sonnet1m, haiku1m: current.sonnet1m,
}))} />
onRefresh={refreshModels} />
<details className="claude-code-context-settings">
<summary>{t('agents.claudeCodeRuntime.advancedContext')}</summary>
<p>{t('agents.claudeCodeRuntime.sharedContextHint')}</p>
<div className="agent-claude-code-runtime-settings">
<div className="agent-claude-code-number-field">
<span>{t('agents.claudeCodeRuntime.maxContextTokens')}</span>
+4
View File
@@ -23,6 +23,8 @@ export type AgentModelMappings = {
opus: string;
sonnet: string;
haiku: string;
fable?: string;
fable1m?: boolean;
opus1m?: boolean;
sonnet1m?: boolean;
haiku1m?: boolean;
@@ -69,6 +71,8 @@ export const sameAgentModelMappings = (
&& sameAgentModel(left.haiku, right.haiku)
&& Boolean(left.opus1m) === Boolean(right.opus1m)
&& Boolean(left.sonnet1m) === Boolean(right.sonnet1m)
&& (left.fable ?? left.sonnet) === (right.fable ?? right.sonnet)
&& Boolean(left.fable1m) === Boolean(right.fable1m)
&& Boolean(left.haiku1m) === Boolean(right.haiku1m)
&& (left.maxContextTokens ?? 200_000) === (right.maxContextTokens ?? 200_000)
&& (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90)
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ModelOption } from './modelService';
export const claudeCodeRoles = ['opus', 'sonnet', 'haiku'] as const;
export const claudeCodeRoles = ['opus', 'sonnet', 'haiku', 'fable'] as const;
export type ClaudeCodeRole = typeof claudeCodeRoles[number];
export type ClaudeCodeRoutes = Record<ClaudeCodeRole, string> & {
opus1m: boolean; sonnet1m: boolean; haiku1m: boolean;
opus1m: boolean; sonnet1m: boolean; haiku1m: boolean; fable1m: boolean;
};
export function claudeCodeModelBase(value: string): string {
+8 -16
View File
@@ -8,19 +8,19 @@ const os = require('node:os');
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 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();
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 });
const context = page.getByRole('switch', { 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');
@@ -28,7 +28,7 @@ const os = require('node:os');
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);
assert.equal(await page.locator('.claude-role-row input[type=checkbox]').count(), 4);
await subagent.fill('custom-worker');
await subagent.press('Enter');
assert.equal(await subagent.inputValue(), 'custom-worker');
@@ -49,22 +49,14 @@ const os = require('node:os');
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 page.getByRole('option', { name: /^opus / }).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.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.startupModel === 'opus' && c.args?.claudeCodeModelMappings?.subagentModel === ''));
await page.getByRole('switch', { name: 'fable 1M', exact: true }).check();
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;
}));
await page.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.fable1m === true));
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 });
+1 -1
View File
@@ -1,7 +1,7 @@
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 };
const routes = { fable: 'model-f', fable1m: false, 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]');