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 4ca6d448a..a1b2406df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2009,12 +2009,14 @@ pub(crate) fn write_game_creator_app_config( .map_err(|_| "配置写入锁不可用")?; let (current, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = current.selected_model_id; + config.selected_model_is_default = current.selected_model_is_default; persist_game_creator_app_config(config, overlays, false) } #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, + is_default: bool, ) -> Result { let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() @@ -2029,6 +2031,7 @@ pub(crate) fn select_game_creator_model( } let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; + config.selected_model_is_default = is_default; persist_game_creator_app_config(config, overlays, true) } @@ -2047,7 +2050,9 @@ fn persist_game_creator_app_config( let previous = overlay.clone(); if let Some(fields) = overlay.as_object_mut() { for (key, value) in fields.iter_mut() { - if (key == "selectedModelId") == model_only { + if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") + == model_only + { if let Some(saved_value) = saved.get(key) { // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 if key == "agentLlm" { 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 c03fba4e4..586faa107 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3662,6 +3662,9 @@ fn merge_game_creator_config_content( 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 e163cad21..923491cde 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 e1e91b5fd..429fa96e2 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"); @@ -848,19 +849,23 @@ fn app_config_save_updates_conflicting_local_overlay() { let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); fs::write( &overlay_path, - r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","custom":{"keep":true}}"#, + r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, ) .expect("write overlay"); let mut config = load_game_creator_app_config().expect("load config"); config.llm.reasoning_effort = "high".to_string(); + config.selected_model_is_default = false; let saved = write_game_creator_app_config(config).expect("save config"); let effective = load_game_creator_app_config().expect("effective config"); assert_eq!(saved.config.llm.reasoning_effort, "high"); assert_eq!(effective.llm.reasoning_effort, "high"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); let overlay: serde_json::Value = serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) .expect("parse overlay"); assert_eq!(overlay["selectedModelId"], "existing"); + assert_eq!(overlay["selectedModelIsDefault"], true); assert_eq!( overlay["llm"], serde_json::json!({"reasoningEffort": "high"}) @@ -877,13 +882,15 @@ fn app_config_model_selection_only_updates_model_overlay() { let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); fs::write( &overlay_path, - r#"{"selectedModelId":"old","llm":{"reasoningEffort":"low"}}"#, + r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, ) .expect("write overlay"); - let saved = select_game_creator_model("new-model".to_string()).expect("select model"); + let saved = select_game_creator_model("new-model".to_string(), true).expect("select model"); let effective = load_game_creator_app_config().expect("effective config"); assert_eq!(saved.config.selected_model_id, "new-model"); assert_eq!(effective.selected_model_id, "new-model"); + assert!(saved.config.selected_model_is_default); + assert!(effective.selected_model_is_default); let overlay: serde_json::Value = serde_json::from_str(&fs::read_to_string(&overlay_path).expect("read overlay")) .expect("parse overlay"); @@ -894,6 +901,28 @@ fn app_config_model_selection_only_updates_model_overlay() { fs::remove_dir_all(root).expect("cleanup 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(); @@ -912,6 +941,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 @@ -938,6 +968,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 @@ -964,6 +995,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 d6e81735a..1bc4c9ea4 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 @@ -1,75 +1,251 @@ import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Ref } from 'react'; +import { + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView } from '../../app/types'; -import { - type ClientLlmModel, - loadClientLlmModels, +import type { + ClientLlmModel, + ClientLlmModelCatalog, } from '../../services/clientApi'; +import { + cachedLlmModelCatalog, + refreshLlmModelCatalog, +} from '../../services/llmModelCatalog'; + +export type ConversationModelSelectHandle = { + /** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */ + ensureUsable: () => Promise; +}; + +/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ +class ModelSelectionConfigError extends Error {} export function ConversationModelSelect({ className, disabled, onReady, + projectPath, + ref, }: { className?: string; disabled: boolean; onReady?: (ready: boolean) => void; + projectPath?: string; + ref?: Ref; }) { - const [models, setModels] = useState([]); + const initialCatalog = cachedLlmModelCatalog(); + const [models, setModels] = useState( + initialCatalog?.models ?? [], + ); const [selected, setSelected] = useState(''); - const [defaultModelId, setDefaultModelId] = useState(''); - const [busy, setBusy] = useState(true); + const [defaultModelId, setDefaultModelId] = useState( + initialCatalog?.defaultModelId ?? '', + ); + const [busy, setBusy] = useState(!initialCatalog); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [open, setOpen] = useState(false); const containerRef = useRef(null); - const refresh = useCallback(async () => { - setBusy(true); - setError(''); - onReady?.(false); - try { - const invoke = resolveTauriInvoke(); - if (!invoke) throw new Error('Native host unavailable'); - const [catalog, config] = await Promise.all([ - loadClientLlmModels(), - invoke('read_game_creator_app_config'), - ]); - setModels(catalog.models); - setDefaultModelId(catalog.defaultModelId); - const savedSelection = config.config.selectedModelId; - // 优先沿用用户已选且仍可用的模型;已选模型被停用/移除时回退到默认模型, - // 避免下拉出现「请选择模型」的空态。默认模型由后台强制配置,缺失时才走错误提示。 - const savedAvailable = savedSelection - ? catalog.models.some((model) => model.id === savedSelection) - : false; - const id = - savedAvailable && savedSelection - ? savedSelection - : catalog.defaultModelId; - setSelected(id); - const available = catalog.models.some((model) => model.id === id); - if (available && savedSelection !== id) { - const saved = await invoke( - 'select_game_creator_model', - { modelId: id }, - ); - if (saved.config.selectedModelId !== id) - throw new Error('Model selection was not saved'); - } - onReady?.(available); - if (!available) setError('请选择可用模型'); - } catch { - setModels([]); - setError('模型列表加载失败'); - } finally { - setBusy(false); - } + const appliedRevisionRef = useRef( + initialCatalog?.revision ?? null, + ); + const selectedRef = useRef(''); + const selectionEpochRef = useRef(0); + const busyTokenRef = useRef(0); + const configWriteChainRef = useRef>(Promise.resolve()); + const onReadyRef = useRef(onReady); + const mountedRef = useRef(true); + + useEffect(() => { + onReadyRef.current = onReady; }, [onReady]); useEffect(() => { - void refresh(); - }, [refresh]); + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const markReady = useCallback((ready: boolean) => { + onReadyRef.current?.(ready); + }, []); + + // 配置写回串行化:目录同步的写回与用户选择按入队顺序落盘,用户选择最后写入。 + const queueConfigWrite = useCallback( + (write: () => Promise) => { + const run = configWriteChainRef.current.then(write, write); + configWriteChainRef.current = run.then( + () => undefined, + () => undefined, + ); + return run; + }, + [], + ); + + const waitForConfigWrite = useCallback( + () => configWriteChainRef.current.then(() => undefined), + [], + ); + + const applyCatalog = useCallback( + async ( + catalog: ClientLlmModelCatalog, + showBusy: boolean, + epochAtRequest: number, + ) => { + if ( + mountedRef.current && + (appliedRevisionRef.current !== catalog.revision || + !Number.isFinite(catalog.revision)) + ) { + appliedRevisionRef.current = catalog.revision; + setModels(catalog.models); + setDefaultModelId(catalog.defaultModelId); + } + const invoke = resolveTauriInvoke(); + if (!invoke) throw new ModelSelectionConfigError('读取客户端配置失败'); + // 有在途写回时先等它结束,避免读到旧配置、也避免用旧快照覆盖新选择。 + await waitForConfigWrite(); + if (selectionEpochRef.current !== epochAtRequest) + return Boolean(selectedRef.current); + const config = await invoke( + 'read_game_creator_app_config', + ).catch(() => null); + if (!config) throw new ModelSelectionConfigError('读取客户端配置失败'); + if (selectionEpochRef.current !== epochAtRequest) + 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); + const defaultEnabled = enabled(catalog.defaultModelId); + let next = ''; + let nextIsDefault = false; + let nextNotice = ''; + // 跟随默认项的选择会随后台默认模型变化;用户手动选择后不再被覆盖。 + 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)) { + let persisted: GameCreatorAppConfigView; + try { + persisted = await queueConfigWrite(() => + invoke('select_game_creator_model', { + modelId: next, + isDefault: nextIsDefault, + }), + ); + } catch { + throw new ModelSelectionConfigError('模型选择保存失败'); + } + if ( + persisted.config.selectedModelId !== next || + persisted.config.selectedModelIsDefault !== nextIsDefault + ) + throw new ModelSelectionConfigError('模型选择保存失败'); + } + // 写回期间用户又做了新选择:保留新选择,不用本次快照覆盖界面。 + if (selectionEpochRef.current !== epochAtRequest) + return Boolean(selectedRef.current); + const ready = Boolean(next); + if (!mountedRef.current) return ready; + selectedRef.current = next; + setSelected(next); + setNotice(nextNotice); + setError(ready ? '' : '请选择可用模型'); + markReady(ready); + return ready; + }, + [markReady, queueConfigWrite, waitForConfigWrite], + ); + + const syncCatalog = useCallback( + async (showBusy: boolean) => { + const busyToken = showBusy + ? ++busyTokenRef.current + : busyTokenRef.current; + if (showBusy) { + if (mountedRef.current) { + setBusy(true); + setError(''); + } + markReady(false); + } + const epochAtRequest = selectionEpochRef.current; + try { + let catalog: ClientLlmModelCatalog; + let usingCachedCatalog = false; + try { + catalog = await refreshLlmModelCatalog(); + } catch { + const cached = cachedLlmModelCatalog(); + if (!cached) { + if (mountedRef.current) setError('模型列表加载失败'); + markReady(false); + return false; + } + catalog = cached; + usingCachedCatalog = true; + } + const ready = await applyCatalog(catalog, showBusy, epochAtRequest); + if (usingCachedCatalog && mountedRef.current) + setError('模型列表加载失败'); + return ready; + } catch (error) { + if (mountedRef.current) { + setError( + error instanceof ModelSelectionConfigError + ? error.message + : '模型列表加载失败', + ); + } + markReady(false); + return false; + } finally { + // 只有最新的 showBusy 同步负责收起加载态,失败路径也必须恢复可交互。 + if ( + showBusy && + mountedRef.current && + busyTokenRef.current === busyToken + ) { + setBusy(false); + } + } + }, + [applyCatalog, markReady], + ); + + useEffect(() => { + void syncCatalog(true); + }, [projectPath, syncCatalog]); + + useEffect(() => { + function handleWindowFocus() { + void syncCatalog(false); + } + window.addEventListener('focus', handleWindowFocus); + return () => window.removeEventListener('focus', handleWindowFocus); + }, [syncCatalog]); useEffect(() => { if (!open) return; @@ -92,25 +268,37 @@ export function ConversationModelSelect({ }; }, [open]); + const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]); + + useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]); + async function select(id: string) { - onReady?.(false); + selectionEpochRef.current += 1; + markReady(false); setBusy(true); setError(''); try { const invoke = resolveTauriInvoke(); if (!invoke) throw new Error('Native host unavailable'); - const result = await invoke( - 'select_game_creator_model', - { modelId: id }, + const result = await queueConfigWrite(() => + invoke('select_game_creator_model', { + modelId: id, + isDefault: false, + }), ); if (result.config.selectedModelId !== id) throw new Error('Selection was not saved'); - setSelected(id); - onReady?.(true); + if (mountedRef.current) { + selectedRef.current = id; + setSelected(id); + setNotice(''); + } + markReady(true); } catch { - setError('模型选择保存失败'); + if (mountedRef.current) setError('模型选择保存失败'); + markReady(false); } finally { - setBusy(false); + if (mountedRef.current) setBusy(false); } } @@ -124,6 +312,7 @@ export function ConversationModelSelect({ } > {error ? {error} : null} + {notice ? {notice} : null}