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 a627e22d2..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 @@ -24,6 +24,9 @@ export type ConversationModelSelectHandle = { ensureUsable: () => Promise; }; +/** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ +class ModelSelectionConfigError extends Error {} + export function ConversationModelSelect({ className, disabled, @@ -55,7 +58,8 @@ export function ConversationModelSelect({ ); const selectedRef = useRef(''); const selectionEpochRef = useRef(0); - const saveInFlightRef = useRef(false); + const busyTokenRef = useRef(0); + const configWriteChainRef = useRef>(Promise.resolve()); const onReadyRef = useRef(onReady); const mountedRef = useRef(true); @@ -74,31 +78,51 @@ export function ConversationModelSelect({ 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, - configPromise: Promise, ) => { if ( mountedRef.current && - appliedRevisionRef.current !== catalog.revision + (appliedRevisionRef.current !== catalog.revision || + !Number.isFinite(catalog.revision)) ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); - const config = await configPromise; - if (!invoke || !config) throw new Error('Native host unavailable'); - if ( - saveInFlightRef.current || - selectionEpochRef.current !== epochAtRequest - ) { - if (mountedRef.current && showBusy) setBusy(false); + 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) => @@ -123,31 +147,43 @@ export function ConversationModelSelect({ if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; } if (next && (next !== saved || nextIsDefault !== followsDefault)) { - const persisted = await invoke( - 'select_game_creator_model', - { modelId: next, isDefault: nextIsDefault }, - ); + 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 Error('Model selection was not saved'); + 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 ? '' : '请选择可用模型'); - if (showBusy) setBusy(false); markReady(ready); return ready; }, - [markReady], + [markReady, queueConfigWrite, waitForConfigWrite], ); const syncCatalog = useCallback( async (showBusy: boolean) => { + const busyToken = showBusy + ? ++busyTokenRef.current + : busyTokenRef.current; if (showBusy) { if (mountedRef.current) { setBusy(true); @@ -156,38 +192,44 @@ export function ConversationModelSelect({ markReady(false); } const epochAtRequest = selectionEpochRef.current; - const invoke = resolveTauriInvoke(); - const configPromise: Promise = invoke - ? invoke( - 'read_game_creator_app_config', - ).catch(() => null) - : Promise.resolve(null); try { - const catalog = await refreshLlmModelCatalog(); - return await applyCatalog( - catalog, - showBusy, - epochAtRequest, - configPromise, - ); - } catch { - const cached = cachedLlmModelCatalog(); - if (cached) { - const ready = await applyCatalog( - cached, - showBusy, - epochAtRequest, - configPromise, - ).catch(() => false); - if (mountedRef.current) setError('模型列表加载失败'); - return ready; + 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; } - if (mountedRef.current) { + const ready = await applyCatalog(catalog, showBusy, epochAtRequest); + if (usingCachedCatalog && mountedRef.current) setError('模型列表加载失败'); - if (showBusy) setBusy(false); + 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], @@ -232,16 +274,17 @@ export function ConversationModelSelect({ async function select(id: string) { selectionEpochRef.current += 1; - saveInFlightRef.current = true; 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, isDefault: false }, + 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'); @@ -255,7 +298,6 @@ export function ConversationModelSelect({ if (mountedRef.current) setError('模型选择保存失败'); markReady(false); } finally { - saveInFlightRef.current = false; if (mountedRef.current) setBusy(false); } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 4db663c37..98737a746 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -149,7 +149,9 @@ export function ProjectSupervisorView({ : '发送'; const submitting = runtimePanelProps.controlBusy && !needsUserInput; const [modelReady, setModelReady] = useState(false); + const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); + const modelValidateInFlightRef = useRef(false); return (
{ - const ready = modelSelectRef.current - ? await modelSelectRef.current.ensureUsable() - : modelReady; - if (ready) onSubmit(event); + try { + const ready = modelSelectRef.current + ? await modelSelectRef.current.ensureUsable() + : modelReady; + if (ready) onSubmit(event); + } finally { + modelValidateInFlightRef.current = false; + setModelValidating(false); + } }; void validateModel(); }} >