From 07fc26953af73cfd0e9b9d128a4936248027d5c4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 12:00:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E5=90=8C=E6=AD=A5=E7=9A=84=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E4=BF=9D=E5=AD=98=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConversationModelSelect:目录同步与配置读取/写回失败时统一在 finally 收起加载态,避免选择器永久卡在 busy、无法切换或刷新 - ConversationModelSelect:配置读取失败单独提示「读取客户端配置失败」,不再误报为模型目录加载失败 - ConversationModelSelect:配置写回串行化,发送前校验等待在途保存并读取最新配置,避免保存中放行旧选择、或用旧快照覆盖刚完成的选择 - ConversationModelSelect:服务端未返回 revision 时按目录已变化处理,避免界面停止刷新 - ProjectSupervisorView:提交前模型校验期间禁用输入框与发送按钮,避免重复提交与校验窗口内编辑丢失 - 测试:补充配置读取失败恢复、保存中发送前校验等待、目录请求去重用例;修正依赖配置读取时序的 appSurface 用例 - 文档:修正首页入口「按需加载」与实现不符的描述 --- .../ConversationModelSelect.tsx | 140 ++++++++++++------ .../ProjectSupervisorView.tsx | 24 ++- .../appSurface/runtime-settings.suite.ts | 2 + .../tests/conversationModelSelect.test.tsx | 79 ++++++++++ ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 2 +- 5 files changed, 191 insertions(+), 56 deletions(-) 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(); }} >