diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 9ceee71..e765763 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -599,6 +599,7 @@ export const jaOverrides = { 'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。', 'agents.oauthLoginRequired.confirm': '了解しました', 'agents.modify.apply': '設定変更を適用', + 'agents.modify.update': '設定を更新', 'agents.modify.close': '設定変更を終了', 'agents.modify.default': 'デフォルト設定', 'agents.modify.clear': '設定を消去', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index a930831..d4b67ab 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -41,6 +41,11 @@ import { resolveAgentModelForAliasMode, resolveAgentModelSelection, } from '../services/agentModelPicker'; +import { + resolveAgentConfigurationAction, + sameAgentModel, + sameAgentModelMappings, +} from '../services/agentConfigurationDraft'; import type { ModelOption } from '../services/modelService'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionAutoRestoreCard } from './CodexSessionAutoRestoreCard'; @@ -119,17 +124,6 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ haiku: model, }); -const sameAgentModel = (left: string, right: string) => ( - left.trim().toLocaleLowerCase() === right.trim().toLocaleLowerCase() -); - -const sameClaudeModelMappings = ( - left: ClaudeModelMappings, - right: ClaudeModelMappings, -) => sameAgentModel(left.opus, right.opus) - && sameAgentModel(left.sonnet, right.sonnet) - && sameAgentModel(left.haiku, right.haiku); - const claudeMappingRoles = [ { key: 'opus', @@ -682,6 +676,7 @@ export function AgentsPage() { setCloseAppNotice(''); setCloseAppConfirmOpen(false); setOauthLoginRequiredAction(null); + setOauthConfigurationDraft(null); setClaudeCodeLaunchDialogOpen(false); setClaudeCodeDirectoryError(''); claudeModelMappingsDirtyRef.current = false; @@ -725,7 +720,7 @@ export function AgentsPage() { sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel, haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel, }; - return sameClaudeModelMappings(current, next) ? current : next; + return sameAgentModelMappings(current, next) ? current : next; }); }, [ activeStatus?.claudeCodeModelMappings, @@ -756,15 +751,23 @@ export function AgentsPage() { ); const claudeMappingDraftChanged = isClaudeModelMappingClient && activeStatus?.modificationState === 'applied' - && !sameClaudeModelMappings( + && !sameAgentModelMappings( claudeModelMappingsDraft, appliedClaudeModelMappings, ); const oauthConfigurationChanged = selected === 'codex' && oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration); const draftChanged = modelDraftChanged || claudeMappingDraftChanged || oauthConfigurationChanged; - const configurationUpdateRequired = activeStatus?.modificationState === 'applied' - && draftChanged; + const configurationAction = resolveAgentConfigurationAction({ + client: selected, + modificationState: activeStatus?.modificationState ?? 'unconfigured', + selectedModel, + appliedModel, + oauthConfiguration, + appliedOauthConfiguration: Boolean(activeStatus?.oauthConfiguration), + modelMappings: claudeModelMappingsDraft, + appliedModelMappings: appliedClaudeModelMappings, + }); const canEnable = Boolean( activeStatus?.supportedPlatform && activeStatus.installed @@ -837,7 +840,7 @@ export function AgentsPage() { sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled), haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled), }; - if (!sameClaudeModelMappings(current, next)) { + if (!sameAgentModelMappings(current, next)) { claudeModelMappingsDirtyRef.current = true; } return next; @@ -1307,7 +1310,6 @@ export function AgentsPage() {
{t('agents.useModel')}
- {modelDraftChanged ? {t('agents.pending')} : null}
- {claudeMappingDraftChanged ? {t('agents.pending')} : null}
@@ -1454,24 +1455,20 @@ export function AgentsPage() { diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 0904a01..1bb3f7a 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1116,10 +1116,9 @@ type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResu function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { const { t } = useI18n(); - const testModel = readString(row.record, 'test-model', 'testModel'); const configuredModels = useMemo( - () => mergeProviderHealthModels([], row.models, testModel), - [row.models, testModel], + () => mergeProviderHealthModels([], row.models), + [row.models], ); const [models, setModels] = useState(configuredModels); const [modelLoading, setModelLoading] = useState(true); @@ -1149,7 +1148,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { healthOptions.customHeaders, healthOptions.timeoutMs, ).then((discovered) => { - if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models, testModel)); + if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models)); }).catch((requestError) => { if (!disposed) setModelError(String(requestError).replace(/^Error:\s*/i, '')); }).finally(() => { @@ -1158,7 +1157,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { return () => { disposed = true; }; - }, [healthOptions, row.apiKeys, row.models, testModel]); + }, [healthOptions, row.apiKeys, row.models]); const visibleModels = useMemo(() => { const query = search.trim().toLowerCase(); diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts new file mode 100644 index 0000000..6946b88 --- /dev/null +++ b/src/services/agentConfigurationDraft.ts @@ -0,0 +1,68 @@ +export type AgentConfigurationClientId = + | 'claude-code' + | 'claude-desktop' + | 'codex' + | 'opencode' + | 'openclaw' + | 'hermes' + | 'pi'; + +export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | 'invalid'; + +export type AgentConfigurationAction = 'apply' | 'update' | 'close'; + +export type AgentModelMappings = { + opus: string; + sonnet: string; + haiku: string; +}; + +type ResolveAgentConfigurationActionOptions = { + client: AgentConfigurationClientId; + modificationState: AgentConfigurationModificationState; + selectedModel: string; + appliedModel: string; + oauthConfiguration: boolean; + appliedOauthConfiguration: boolean; + modelMappings: AgentModelMappings; + appliedModelMappings: AgentModelMappings; +}; + +const normalizedModel = (value: string) => value.trim().toLocaleLowerCase(); + +export const sameAgentModel = (left: string, right: string) => ( + normalizedModel(left) === normalizedModel(right) +); + +export const sameAgentModelMappings = ( + left: AgentModelMappings, + right: AgentModelMappings, +) => sameAgentModel(left.opus, right.opus) + && sameAgentModel(left.sonnet, right.sonnet) + && sameAgentModel(left.haiku, right.haiku); + +export function resolveAgentConfigurationAction({ + client, + modificationState, + selectedModel, + appliedModel, + oauthConfiguration, + appliedOauthConfiguration, + modelMappings, + appliedModelMappings, +}: ResolveAgentConfigurationActionOptions): AgentConfigurationAction { + if (modificationState !== 'applied') return 'apply'; + if (client === 'pi') return 'close'; + + const modelChanged = client === 'claude-code' || client === 'claude-desktop' + ? !sameAgentModelMappings(modelMappings, appliedModelMappings) + : Boolean( + selectedModel.trim() + && appliedModel.trim() + && !sameAgentModel(selectedModel, appliedModel), + ); + const oauthChanged = client === 'codex' + && oauthConfiguration !== appliedOauthConfiguration; + + return modelChanged || oauthChanged ? 'update' : 'close'; +} diff --git a/src/services/providerHealthCheck.ts b/src/services/providerHealthCheck.ts index 341db67..7b88e27 100644 --- a/src/services/providerHealthCheck.ts +++ b/src/services/providerHealthCheck.ts @@ -65,20 +65,21 @@ export function primaryProviderHealthCredential(apiKeys: string[]): string { export function mergeProviderHealthModels( discoveredModels: ModelOption[], configuredModels: ModelOption[], - testModel = '', ): ModelOption[] { - const models = new Map(); - [...discoveredModels, ...configuredModels].forEach((model) => { + const configured = new Map(); + configuredModels.forEach((model) => { const name = model.name.trim(); if (!name) return; const key = name.toLowerCase(); - models.set(key, { ...models.get(key), ...model, name }); + configured.set(key, { ...configured.get(key), ...model, name }); }); - const normalizedTestModel = testModel.trim(); - if (normalizedTestModel && !models.has(normalizedTestModel.toLowerCase())) { - models.set(normalizedTestModel.toLowerCase(), { name: normalizedTestModel }); - } - return Array.from(models.values()).sort((left, right) => + discoveredModels.forEach((model) => { + const key = model.name.trim().toLowerCase(); + const selected = configured.get(key); + if (!selected) return; + configured.set(key, { ...model, ...selected }); + }); + return Array.from(configured.values()).sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }), ); } diff --git a/src/styles.css b/src/styles.css index 29a3c4e..fa42ecf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5589,17 +5589,6 @@ textarea:focus-visible { overflow-wrap: anywhere; } -.agent-pending-badge { - flex: none; - padding: 4px 8px; - border: 1px solid var(--theme-e4d0af); - border-radius: 999px; - background: var(--theme-fff8eb); - color: var(--theme-805a24); - font-size: var(--font-size-meta); - font-weight: 700; -} - .agent-model-picker { position: relative; min-width: 0; diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 889aa7c..da36f6d 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -8,6 +8,10 @@ import { resolveAgentModelForAliasMode, resolveAgentModelSelection, } from '../src/services/agentModelPicker'; +import { + resolveAgentConfigurationAction, + type AgentConfigurationClientId, +} from '../src/services/agentConfigurationDraft'; const models = [ { name: 'claude-sonnet-4-5', alias: 'Sonnet' }, @@ -69,3 +73,57 @@ describe('智能体模型选择器', () => { expect(resolveAgentModelForAliasMode([], 'gpt-original', true)).toBe(''); }); }); + +const appliedConfiguration = ( + client: AgentConfigurationClientId, + selectedModel: string, + appliedModel = 'model-a', +) => resolveAgentConfigurationAction({ + client, + modificationState: 'applied', + selectedModel, + appliedModel, + oauthConfiguration: false, + appliedOauthConfiguration: false, + modelMappings: { + opus: selectedModel, + sonnet: selectedModel, + haiku: selectedModel, + }, + appliedModelMappings: { + opus: appliedModel, + sonnet: appliedModel, + haiku: appliedModel, + }, +}); + +describe('agent configuration update action', () => { + test.each([ + 'claude-code', + 'claude-desktop', + 'codex', + 'opencode', + 'openclaw', + 'hermes', + ])('%s updates only while its model draft differs', (client) => { + expect(appliedConfiguration(client, 'model-b')).toBe('update'); + expect(appliedConfiguration(client, ' MODEL-A ')).toBe('close'); + }); + + test('Codex also updates when only OAuth configuration changes', () => { + expect(resolveAgentConfigurationAction({ + client: 'codex', + modificationState: 'applied', + selectedModel: 'model-a', + appliedModel: 'model-a', + oauthConfiguration: true, + appliedOauthConfiguration: false, + modelMappings: { opus: '', sonnet: '', haiku: '' }, + appliedModelMappings: { opus: '', sonnet: '', haiku: '' }, + })).toBe('update'); + }); + + test('Pi never exposes the model update action', () => { + expect(appliedConfiguration('pi', 'model-b')).toBe('close'); + }); +}); diff --git a/tests/providerHealthCheck.test.ts b/tests/providerHealthCheck.test.ts index 0ebf0d0..42650a9 100644 --- a/tests/providerHealthCheck.test.ts +++ b/tests/providerHealthCheck.test.ts @@ -7,21 +7,29 @@ import { } from '../src/services/providerHealthCheck'; describe('API 接入健康检测', () => { - it('合并发现模型、已配置模型和 test-model,并按名称去重', () => { + it('健康检测只保留已勾选模型,并使用发现结果补充模型信息', () => { const models = mergeProviderHealthModels( - [{ name: 'model-b' }, { name: 'model-a' }], + [ + { name: 'model-b' }, + { name: 'model-a', contextWindow: 200_000 }, + { name: 'model-c' }, + ], [{ name: 'MODEL-A', alias: 'model-a-alias' }, { name: 'model-c' }], - 'health-model', ); expect(models).toEqual([ - { name: 'health-model' }, - { name: 'MODEL-A', alias: 'model-a-alias' }, - { name: 'model-b' }, + { name: 'MODEL-A', alias: 'model-a-alias', contextWindow: 200_000 }, { name: 'model-c' }, ]); }); + it('没有勾选模型时不允许发现列表自动加入健康检测', () => { + expect(mergeProviderHealthModels( + [{ name: 'model-a' }, { name: 'model-b' }], + [], + )).toEqual([]); + }); + it('逐模型检测只使用当前接入的首个有效密钥', () => { expect(primaryProviderHealthCredential(['', ' first-key ', 'second-key'])).toBe('first-key'); expect(primaryProviderHealthCredential([])).toBe('');