修复AGC客户端未跟随服务端默认模型
Project CI / Native shell tests (pull_request) Failing after 14m52s
Project CI / Repository checks (pull_request) Successful in 5m51s
Project CI / Frontend tests (pull_request) Successful in 6m17s
Project CI / Backend tests (pull_request) Successful in 23m19s

- 客户端配置新增 selectedModelIsDefault,记录当前选择是否来自平台默认项
- 服务端默认项变化时,跟随默认项的选择自动切换并提示,手动选择不受影响
- 所选模型失效回退默认项时标记为默认项选择
- 补充配置读写与客户端定向测试,同步技术方案文档
This commit is contained in:
2026-09-08 16:38:37 +08:00
parent d60c4ce6ec
commit 4fc9451492
8 changed files with 145 additions and 15 deletions
@@ -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<GameCreatorAppConfigView, String> {
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)
}
@@ -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(())
}
@@ -1022,6 +1022,8 @@ struct GameCreatorAppConfigFile {
planning: Option<GameCreatorPlanningConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option<bool>,
}
#[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,
}
}
}
@@ -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
@@ -719,6 +719,7 @@ export interface GameCreatorAppConfig {
apiKey: string;
};
selectedModelId?: string;
selectedModelIsDefault?: boolean;
planning?: {
capabilityEnabled: boolean;
};
@@ -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<GameCreatorAppConfigView>(
'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<GameCreatorAppConfigView>(
'select_game_creator_model',
{ modelId: id },
{ modelId: id, isDefault: false },
);
if (result.config.selectedModelId !== id)
throw new Error('Selection was not saved');
@@ -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<ConversationModelSelectHandle>();
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(<ConversationModelSelect disabled={false} onReady={onReady} />);
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(<ConversationModelSelect disabled={false} onReady={onReady} />);
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);
});
@@ -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` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
## 验收