fix: track agent config changes and health-check selected models only
This commit is contained in:
1 parent
4d32f58ee2
commit
02c6fac72a
8 files changed
+180
-59
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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' }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
|
||||
Reference in new issue
Block a user