From 4fc945149265d340d89e231dc760ca7b9b04cfae Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 8 Sep 2026 16:38:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=9C=AA=E8=B7=9F=E9=9A=8F=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端配置新增 selectedModelIsDefault,记录当前选择是否来自平台默认项 - 服务端默认项变化时,跟随默认项的选择自动切换并提示,手动选择不受影响 - 所选模型失效回退默认项时标记为默认项选择 - 补充配置读写与客户端定向测试,同步技术方案文档 --- .../src-tauri/src/commands.rs | 6 +- .../src-tauri/src/config.rs | 3 + .../src-tauri/src/main.rs | 5 ++ .../src-tauri/src/tests/configuration.rs | 26 ++++++ apps/ai-game-creator-shell/src/app/types.ts | 1 + .../ConversationModelSelect.tsx | 27 ++++-- .../tests/conversationModelSelect.test.tsx | 89 +++++++++++++++++-- ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 3 +- 8 files changed, 145 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 2f564e863..10ac70a7f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2006,13 +2006,16 @@ pub(crate) fn write_game_creator_app_config( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - config.selected_model_id = load_game_creator_app_config()?.selected_model_id; + let stored = load_game_creator_app_config()?; + config.selected_model_id = stored.selected_model_id; + config.selected_model_is_default = stored.selected_model_is_default; persist_game_creator_app_config(config) } #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, + is_default: bool, ) -> Result { let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() @@ -2027,6 +2030,7 @@ pub(crate) fn select_game_creator_model( } let mut config = load_game_creator_app_config()?; config.selected_model_id = model_id; + config.selected_model_is_default = is_default; persist_game_creator_app_config(config) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 8fbfff37b..4904b59b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3623,6 +3623,9 @@ pub(crate) fn merge_game_creator_config_file( if let Some(selected_model_id) = file_config.selected_model_id { config.selected_model_id = selected_model_id; } + if let Some(selected_model_is_default) = file_config.selected_model_is_default { + config.selected_model_is_default = selected_model_is_default; + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 1e7cd504d..9de89f01a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1022,6 +1022,8 @@ struct GameCreatorAppConfigFile { planning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] selected_model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_model_is_default: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -1084,6 +1086,8 @@ struct GameCreatorAppConfig { planning: GameCreatorPlanningConfig, #[serde(default)] selected_model_id: String, + #[serde(default)] + selected_model_is_default: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1609,6 +1613,7 @@ impl Default for GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig::default(), planning: GameCreatorPlanningConfig::default(), selected_model_id: String::new(), + selected_model_is_default: false, } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 72fb58321..07d7b9d55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -738,6 +738,7 @@ fn app_config_commands_write_runtime_config_file() { agent_llm, planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }) .expect("write runtime config"); @@ -806,6 +807,28 @@ fn app_config_commands_write_runtime_config_file() { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } +#[test] +fn model_selection_default_flag_round_trips() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let saved = + select_game_creator_model("quality".to_string(), true).expect("select default model"); + assert_eq!(saved.config.selected_model_id, "quality"); + assert!(saved.config.selected_model_is_default); + + let saved = + select_game_creator_model("fast".to_string(), false).expect("select explicit model"); + assert_eq!(saved.config.selected_model_id, "fast"); + assert!(!saved.config.selected_model_is_default); + + let reloaded = read_game_creator_app_config().expect("read runtime config"); + assert_eq!(reloaded.config.selected_model_id, "fast"); + assert!(!reloaded.config.selected_model_is_default); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); +} + #[test] fn app_config_write_rejects_invalid_api_kind() { let root = unique_project_path(); @@ -824,6 +847,7 @@ fn app_config_write_rejects_invalid_api_kind() { agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result @@ -850,6 +874,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result @@ -876,6 +901,7 @@ fn app_config_write_rejects_too_small_request_timeout() { agent_llm: BTreeMap::new(), planning: GameCreatorPlanningConfig::default(), selected_model_id: "default".to_string(), + selected_model_is_default: false, }); assert!(result diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 493fdb748..3df18afcb 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -719,6 +719,7 @@ export interface GameCreatorAppConfig { apiKey: string; }; selectedModelId?: string; + selectedModelIsDefault?: boolean; planning?: { capabilityEnabled: boolean; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 025e0854d..d69ff1376 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -94,18 +94,35 @@ export function ConversationModelSelect({ return Boolean(selectedRef.current); } const saved = config.config.selectedModelId; + const followsDefault = config.config.selectedModelIsDefault === true; const enabled = (id: string) => catalog.models.some((model) => model.id === id); - let next = saved && enabled(saved) ? saved : ''; + const defaultEnabled = enabled(catalog.defaultModelId); + let next = ''; + let nextIsDefault = false; let nextNotice = ''; - if (!next && enabled(catalog.defaultModelId)) { + if (followsDefault && defaultEnabled) { next = catalog.defaultModelId; + nextIsDefault = true; + if (saved && saved !== next) + nextNotice = '默认模型已更新,已切换为新的默认模型'; + } else if (!followsDefault && saved && enabled(saved)) { + next = saved; + } + if (!next && defaultEnabled) { + next = catalog.defaultModelId; + nextIsDefault = true; if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + } + if (next && (next !== saved || nextIsDefault !== followsDefault)) { const persisted = await invoke( 'select_game_creator_model', - { modelId: next }, + { modelId: next, isDefault: nextIsDefault }, ); - if (persisted.config.selectedModelId !== next) + if ( + persisted.config.selectedModelId !== next || + persisted.config.selectedModelIsDefault !== nextIsDefault + ) throw new Error('Default selection was not saved'); } const ready = Boolean(next); @@ -183,7 +200,7 @@ export function ConversationModelSelect({ if (!invoke) throw new Error('Native host unavailable'); const result = await invoke( 'select_game_creator_model', - { modelId: id }, + { modelId: id, isDefault: false }, ); if (result.config.selectedModelId !== id) throw new Error('Selection was not saved'); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 250e3ab95..2ce57324c 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -21,6 +21,8 @@ import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalo vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); vi.mock('../src/services/clientApi', () => ({ loadClientLlmModels: vi.fn() })); const invoke = vi.fn(); +let savedModelId = 'quality'; +let savedModelIsDefault = true; beforeEach(() => { vi.clearAllMocks(); @@ -34,12 +36,20 @@ beforeEach(() => { ], revision: 1, }); - invoke.mockImplementation(async (command, input) => ({ - config: { - selectedModelId: - command === 'select_game_creator_model' ? input.modelId : 'quality', - }, - })); + savedModelId = 'quality'; + savedModelIsDefault = true; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = String(input.modelId); + savedModelIsDefault = Boolean(input.isDefault); + } + return { + config: { + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); }); afterEach(cleanup); @@ -53,6 +63,7 @@ test('only displays aliases and persists selection through the native command', await waitFor(() => expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'fast', + isDefault: false, }), ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); @@ -68,6 +79,10 @@ test('falls back to the default model when the saved selection was removed', asy command === 'select_game_creator_model' ? input.modelId : 'private-old-model', + selectedModelIsDefault: + command === 'select_game_creator_model' + ? Boolean(input.isDefault) + : false, }, })); const onReady = vi.fn(); @@ -76,6 +91,7 @@ test('falls back to the default model when the saved selection was removed', asy await waitFor(() => expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { modelId: 'quality', + isDefault: true, }), ); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); @@ -115,10 +131,18 @@ test('applies a new catalog revision on focus', async () => { test('pre-send validation falls back when the selected model is disabled', async () => { let savedModelId = 'quality'; + let savedModelIsDefault = false; invoke.mockImplementation(async (command, input) => { - if (command === 'select_game_creator_model') + if (command === 'select_game_creator_model') { savedModelId = String(input.modelId); - return { config: { selectedModelId: savedModelId } }; + savedModelIsDefault = Boolean(input.isDefault); + } + return { + config: { + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; }); const ref = createRef(); const onReady = vi.fn(); @@ -165,3 +189,52 @@ test('a failed save keeps submission unavailable', async () => { await screen.findByText('模型选择保存失败'); expect(onReady).toHaveBeenLastCalledWith(false); }); + +test('follows the new server default when the saved selection was the default', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'fast', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 2, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => expect(savedModelId).toBe('fast')); + expect(savedModelIsDefault).toBe(true); + expect( + screen.getByText('默认模型已更新,已切换为新的默认模型'), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'); +}); + +test('keeps an explicit selection when the server default changes', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => expect(savedModelId).toBe('fast')); + expect(savedModelIsDefault).toBe(false); + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量' }, + { id: 'fast', displayName: '快速' }, + ], + revision: 2, + }); + fireEvent(window, new Event('focus')); + await waitFor(() => + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'), + ); + expect(savedModelId).toBe('fast'); + expect(savedModelIsDefault).toBe(false); +}); diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index 318253e67..1651f34d8 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -8,7 +8,8 @@ - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。 -- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 +- 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId` 与 `selectedModelIsDefault`(当前选择是否来自平台默认项),从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 +- `selectedModelIsDefault` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。 - 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。 ## 验收