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 87ada105d..45918c9e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2007,13 +2007,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() @@ -2028,6 +2031,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 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 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 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}