From d60c4ce6eca88a1da37858b68b43f1276c058f84 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 8 Sep 2026 16:11:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96AGC=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=90=8C=E6=AD=A5LLM=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/llm/models 增加目录 revision,客户端据此做条件刷新 - AGC 客户端在项目切换、对话表面挂载、下拉展开、窗口聚焦时按 revision 条件刷新 - 模型目录请求同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择 - 发起对话前校验所选模型,已停用或删除时回退默认模型并提示 - 新增模型目录缓存模块与定向测试,同步技术方案与后端架构文档 --- .../ConversationModelSelect.tsx | 216 ++++++++++++++---- .../ProjectSupervisorView.tsx | 23 +- .../src/services/clientApi.ts | 8 +- .../src/services/llmModelCatalog.ts | 32 +++ apps/ai-game-creator-shell/src/styles.css | 9 + .../tests/appSurface/harness.ts | 3 + .../tests/conversationModelSelect.test.tsx | 91 +++++++- ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- server-rs/crates/api-server/src/llm/mod.rs | 3 + server-rs/crates/shared-contracts/src/llm.rs | 2 + 11 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/llmModelCatalog.ts 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 92d10d2a2..025e0854d 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,64 +1,181 @@ import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, 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, + type ClientLlmModelCatalog, } from '../../services/clientApi'; +import { + cachedLlmModelCatalog, + refreshLlmModelCatalog, +} from '../../services/llmModelCatalog'; + +export type ConversationModelSelectHandle = { + /** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */ + ensureUsable: () => Promise; +}; export function ConversationModelSelect({ disabled, onReady, + projectPath, + ref, }: { 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 [busy, setBusy] = useState(true); + const [busy, setBusy] = useState(!initialCatalog); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [open, setOpen] = useState(false); - 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); - const id = config.config.selectedModelId || catalog.defaultModelId; - setSelected(id); - const available = catalog.models.some((model) => model.id === id); - if (available && !config.config.selectedModelId) { - const saved = await invoke( - 'select_game_creator_model', - { modelId: id }, - ); - if (saved.config.selectedModelId !== id) - throw new Error('Default 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 saveInFlightRef = useRef(false); + 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 applyCatalog = useCallback( + async ( + catalog: ClientLlmModelCatalog, + showBusy: boolean, + epochAtRequest: number, + ) => { + if ( + mountedRef.current && + appliedRevisionRef.current !== catalog.revision + ) { + appliedRevisionRef.current = catalog.revision; + setModels(catalog.models); + } + const invoke = resolveTauriInvoke(); + if (!invoke) throw new Error('Native host unavailable'); + const config = await invoke( + 'read_game_creator_app_config', + ); + if ( + saveInFlightRef.current || + selectionEpochRef.current !== epochAtRequest + ) { + if (mountedRef.current && showBusy) setBusy(false); + return Boolean(selectedRef.current); + } + const saved = config.config.selectedModelId; + const enabled = (id: string) => + catalog.models.some((model) => model.id === id); + let next = saved && enabled(saved) ? saved : ''; + let nextNotice = ''; + if (!next && enabled(catalog.defaultModelId)) { + next = catalog.defaultModelId; + if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + const persisted = await invoke( + 'select_game_creator_model', + { modelId: next }, + ); + if (persisted.config.selectedModelId !== next) + throw new Error('Default selection was not saved'); + } + 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], + ); + + const syncCatalog = useCallback( + async (showBusy: boolean) => { + if (showBusy) { + if (mountedRef.current) { + setBusy(true); + setError(''); + } + markReady(false); + } + const epochAtRequest = selectionEpochRef.current; + try { + const catalog = await refreshLlmModelCatalog(); + return await applyCatalog(catalog, showBusy, epochAtRequest); + } catch { + const cached = cachedLlmModelCatalog(); + if (cached) { + const ready = await applyCatalog( + cached, + showBusy, + epochAtRequest, + ).catch(() => false); + if (mountedRef.current) setError('模型列表加载失败'); + return ready; + } + if (mountedRef.current) { + setError('模型列表加载失败'); + if (showBusy) setBusy(false); + } + markReady(false); + return 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]); + + const ensureUsable = useCallback(() => syncCatalog(true), [syncCatalog]); + + useImperativeHandle(ref, () => ({ ensureUsable }), [ensureUsable]); async function select(id: string) { - onReady(false); + selectionEpochRef.current += 1; + saveInFlightRef.current = true; + markReady(false); setBusy(true); setError(''); try { @@ -70,18 +187,25 @@ export function ConversationModelSelect({ ); 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); + saveInFlightRef.current = false; + if (mountedRef.current) setBusy(false); } } return (
{error ? {error} : null} + {notice ? {notice} : null}