fix: track agent config changes and health-check selected models only

This commit is contained in:
lzt404 committed 2026-08-04 18:07:34 +08:00
1 parent 4d32f58ee2
commit 02c6fac72a
8 files changed
+180 -59

No files matched your search

+1
View File
@@ -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': '設定を消去',
+25 -28
View File
@@ -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() {
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
<AgentModelPicker
models={models}
@@ -1357,7 +1359,6 @@ export function AgentsPage() {
<span className="switch-track" />
</span>
</label>
{claudeMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
</div>
<div className="agent-claude-desktop-mapping-grid">
@@ -1454,24 +1455,20 @@ export function AgentsPage() {
<button
type="button"
className="primary-button"
onClick={() => void (configurationUpdateRequired
? applyConfigurationChanges()
: activeStatus?.modificationState === 'applied'
? closeConfigurationChanges()
: applyConfigurationChanges())}
onClick={() => void (configurationAction === 'close'
? closeConfigurationChanges()
: applyConfigurationChanges())}
disabled={
busy
|| (activeStatus?.modificationState === 'applied' && !configurationUpdateRequired
? false
: !canEnable)
|| (configurationAction === 'close' ? false : !canEnable)
}
>
{busyAction === 'apply' || busyAction === 'close-config'
? <LoaderCircle size={16} className="spin" />
: null}
{configurationUpdateRequired
{configurationAction === 'update'
? t('agents.modify.update')
: activeStatus?.modificationState === 'applied'
: configurationAction === 'close'
? t('agents.modify.close')
: t('agents.modify.apply')}
</button>
+4 -5
View File
@@ -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<ModelOption[]>(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();
+68
View File
@@ -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';
}
+10 -9
View File
@@ -65,20 +65,21 @@ export function primaryProviderHealthCredential(apiKeys: string[]): string {
export function mergeProviderHealthModels(
discoveredModels: ModelOption[],
configuredModels: ModelOption[],
testModel = '',
): ModelOption[] {
const models = new Map<string, ModelOption>();
[...discoveredModels, ...configuredModels].forEach((model) => {
const configured = new Map<string, ModelOption>();
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' }),
);
}
-11
View File
@@ -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;
+58
View File
@@ -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<AgentConfigurationClientId>([
'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');
});
});
+14 -6
View File
@@ -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('');