修复AGC客户端未跟随服务端默认模型
- 客户端配置新增 selectedModelIsDefault,记录当前选择是否来自平台默认项 - 服务端默认项变化时,跟随默认项的选择自动切换并提示,手动选择不受影响 - 所选模型失效回退默认项时标记为默认项选择 - 补充配置读写与客户端定向测试,同步技术方案文档
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
+22
-5
@@ -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` 为真表示选择由平台默认项驱动(首次进入、默认项变化、所选模型失效回退),后台默认项变化时客户端跟随切换并提示;用户手动选择后置为假,不再被默认项变化覆盖。
|
||||
- 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。
|
||||
|
||||
## 验收
|
||||
|
||||
Reference in New Issue
Block a user