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..d6e81735a 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,5 +1,5 @@ import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView } from '../../app/types'; @@ -9,21 +9,25 @@ import { } from '../../services/clientApi'; export function ConversationModelSelect({ + className, disabled, onReady, }: { + className?: string; disabled: boolean; - onReady: (ready: boolean) => void; + onReady?: (ready: boolean) => void; }) { const [models, setModels] = useState([]); const [selected, setSelected] = useState(''); + const [defaultModelId, setDefaultModelId] = useState(''); const [busy, setBusy] = useState(true); const [error, setError] = useState(''); const [open, setOpen] = useState(false); + const containerRef = useRef(null); const refresh = useCallback(async () => { setBusy(true); setError(''); - onReady(false); + onReady?.(false); try { const invoke = resolveTauriInvoke(); if (!invoke) throw new Error('Native host unavailable'); @@ -32,18 +36,28 @@ export function ConversationModelSelect({ invoke('read_game_creator_app_config'), ]); setModels(catalog.models); - const id = config.config.selectedModelId || catalog.defaultModelId; + 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 && !config.config.selectedModelId) { + if (available && savedSelection !== id) { const saved = await invoke( 'select_game_creator_model', { modelId: id }, ); if (saved.config.selectedModelId !== id) - throw new Error('Default selection was not saved'); + throw new Error('Model selection was not saved'); } - onReady(available); + onReady?.(available); if (!available) setError('请选择可用模型'); } catch { setModels([]); @@ -57,8 +71,29 @@ export function ConversationModelSelect({ void refresh(); }, [refresh]); + useEffect(() => { + if (!open) return; + function handleOutsidePointerDown(event: MouseEvent) { + const target = event.target as Node | null; + if (containerRef.current && !containerRef.current.contains(target)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + document.addEventListener('mousedown', handleOutsidePointerDown); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + async function select(id: string) { - onReady(false); + onReady?.(false); setBusy(true); setError(''); try { @@ -71,7 +106,7 @@ export function ConversationModelSelect({ if (result.config.selectedModelId !== id) throw new Error('Selection was not saved'); setSelected(id); - onReady(true); + onReady?.(true); } catch { setError('模型选择保存失败'); } finally { @@ -80,7 +115,14 @@ export function ConversationModelSelect({ } return ( -
+
{error ? {error} : null} +
+ +
+
+ + +
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index ad1c1bb67..5feeec759 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -62,6 +62,46 @@ function ApprovedGddStartHarness() { } export function registerClientHomeTests() { + it('adds a model selector to the home composer and shows the default model', async () => { + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + // 挂载即加载目录,触发按钮直接落在默认模型上,不出现「选择模型」空态。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + const modelTrigger = await screen.findByRole('button', { + name: '对话模型', + }); + await waitFor(() => expect(modelTrigger.textContent).toContain('高质量')); + const createButton = screen.getByRole('button', { name: '开启创作' }); + expect(createButton).toHaveProperty('disabled', false); + + fireEvent.click(modelTrigger); + await waitFor(() => + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(), + ); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'fast', + }), + ); + expect(modelTrigger.textContent).toContain('快速'); + expect(createButton).toHaveProperty('disabled', false); + }); + it('anchors the empty home input placeholder to the editor while the page scrolls', () => { renderLauncherAt('/?launcher'); @@ -1220,7 +1260,17 @@ export function registerHomeProjectCreationTests() { }); it('keeps only open and create project actions without exposing a Linux fallback', () => { - const invoke = vi.fn(); + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'select_game_creator_model') { + return { + config: { selectedModelId: (args as { modelId: string }).modelId }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); window.__TAURI__ = { core: { invoke } }; renderLauncherProjectsAt('/?launcher'); @@ -1242,7 +1292,12 @@ export function registerHomeProjectCreationTests() { screen.queryByRole('button', { name: '在文件管理器中显示' }), ).toBeNull(); expect(screen.queryByRole('button', { name: /Godot 项目/ })).toBeNull(); - expect(invoke).not.toHaveBeenCalled(); + // 首页模型选择器会在挂载时读取模型目录(read_game_creator_app_config), + // 这里只校验没有打开工作区窗口或其它项目操作被触发。 + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); }); it('opens the directory selected by the native picker', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 79815398b..64373c01d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4695,6 +4695,50 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('allows selecting the model on the first direct-project entry', async () => { + const projectPath = '/tmp/first-entry-model-select'; + const manifest = createGameCreationAppManifest( + 'first-entry-model-select', + '首次进入模型选择', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + + render( + React.createElement(App, { + initialProjectPath: projectPath, + initialProjectManifest: manifest, + projectSupervisorOnly: true, + }), + ); + + const surface = await screen.findByLabelText('陶泥儿项目对话'); + const trigger = within(surface).getByRole('button', { name: '对话模型' }); + await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false)); + fireEvent.click(trigger); + await waitFor(() => + expect( + within(surface).getByRole('option', { name: '快速' }), + ).not.toBeNull(), + ); + fireEvent.click(within(surface).getByRole('option', { name: '快速' })); + await waitFor(() => expect(trigger.textContent).toContain('快速')); + }); + it('runs a top workbench play request without a second confirmation', async () => { const projectPath = '/tmp/top-play-request'; const supervisorHarness = createProjectSupervisorRuntimeHarness({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 90bdc35b3..3ed93a19a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -692,9 +692,16 @@ export function registerRuntimeSettingsTests() { }) => void) | undefined; let readCount = 0; + let modelReadResolved = false; const invoke = vi.fn((command: string) => { if (command === 'read_game_creator_app_config') { readCount += 1; + // 首页模型选择器会在挂载时读取一次配置;让首次读取立即完成, + // 以免一直处于 pending 干扰运行时配置对话框的读取计数。 + if (!modelReadResolved) { + modelReadResolved = true; + return Promise.resolve({ config: { selectedModelId: 'quality' } }); + } return new Promise((resolve) => { resolveRead = resolve as typeof resolveRead; }); @@ -723,10 +730,13 @@ export function registerRuntimeSettingsTests() { true, ); + // 首页模型选择器挂载时会读取一次配置(上面已让首次读取立即完成), + // 这里只校验:对话框处于「正在读取」时点击读取/保存不会新增读取请求。 + const readsWhileReading = readCount; fireEvent.click(screen.getByRole('button', { name: '读取' })); fireEvent.click(screen.getByRole('button', { name: '保存' })); - expect(readCount).toBe(1); + expect(readCount).toBe(readsWhileReading); await act(async () => { resolveRead?.({ path: '/home/test/AppData/game-creator.config.json', diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index fd7e49b36..1f160b63b 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -5,6 +5,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; @@ -53,15 +54,25 @@ test('only displays aliases and persists selection through the native command', ).toContain('快速'); }); -test('does not mark a removed selection ready or expose the old identifier', async () => { - invoke.mockResolvedValue({ - config: { selectedModelId: 'private-old-model' }, - }); +test('falls back to the default model when the saved selection was removed', async () => { + invoke.mockImplementation(async (command, input) => ({ + config: { + selectedModelId: + command === 'select_game_creator_model' + ? (input as { modelId: string }).modelId + : 'private-old-model', + }, + })); const onReady = vi.fn(); render(); - await screen.findByText('请选择可用模型'); - expect(onReady).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('private-old-model')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'quality', + }); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('高质量'); }); test('failed catalog can be refreshed without enabling submission', async () => { @@ -88,3 +99,80 @@ test('a failed save keeps submission unavailable', async () => { await screen.findByText('模型选择保存失败'); expect(onReady).toHaveBeenLastCalledWith(false); }); + +test('closes the menu when clicking outside', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.mouseDown(document.body); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('closes the menu on Escape', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(), + ); +}); + +test('marks the default model in the menu', async () => { + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const qualityOption = screen.getByRole('option', { name: /高质量/ }); + expect(within(qualityOption).getByText('默认')).not.toBeNull(); + const fastOption = screen.getByRole('option', { name: '快速' }); + expect(within(fastOption).queryByText('默认')).toBeNull(); +}); + +test('keeps model options disabled while a selection save is in flight', async () => { + let resolveSave: ((value: unknown) => void) | undefined; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + return { config: { selectedModelId: 'quality' } }; + }); + const onReady = vi.fn(); + render(); + await screen.findByRole('button', { name: '对话模型' }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await screen.findByRole('option', { name: '快速' }); + fireEvent.click(screen.getByRole('option', { name: '快速' })); + + // 保存期间重新打开菜单:可以查看,但选项应禁用,避免并发选择。 + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('option', { name: /高质量/ })).toHaveProperty( + 'disabled', + true, + ); + expect(onReady).toHaveBeenLastCalledWith(false); + + resolveSave?.({ config: { selectedModelId: 'fast' } }); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + expect(screen.getByRole('option', { name: '快速' })).toHaveProperty( + 'disabled', + false, + ); +}); diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index d70a5d096..43b17d928 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -8,6 +8,8 @@ - `GET /api/llm/models` 返回启用项的 `id/displayName` 和 `defaultModelId`,不返回实际模型名、Router 目录、凭据或能力原始数据。 - AGC Responses 请求的 `model` 是稳定目录标识。服务端按当前目录映射实际模型名;未知、停用项拒绝,不回退其它模型。旧客户端无 AGC 标记时使用后台默认项。 - 输入框右下角选择模型,只显示别名;选择保存到客户端配置 `selectedModelId`,从下一次请求生效。加载失败或选项停用时禁用提交并允许刷新,不显示实际 ID 作为兜底文案。 +- 首页聊天框架的右下角同样提供模型选择入口(与项目对话右侧一致)。首页入口按需加载模型目录(首次展开才请求),选择仅影响后续创建/发送的轮次,不阻塞「开启创作」,因此模型目录不可用时仍可创建项目并使用后台默认项。 +- 项目右侧对话的模型选择器在对话进行中保持可交互:切换模型只写回客户端配置并作用于下一轮,当前回合不受影响;发送按钮仍由 `controlBusy` / `modelReady` 把关。 - 设置页恢复到布局改版前的官方代理版本,不包含模型管理或模型选择,保留配置安全清理和官方代理锁定。 ## 验收