diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index bc1ea595a..215766274 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -32,6 +32,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; +import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { resolveSpeechRecognitionCtor, @@ -383,28 +384,52 @@ export function ComposerReasoningEffortSelect({ }; }, []); - useEffect(() => { - let cancelled = false; + /* + 读一次配置:种「最近落盘值」并在空闲时回显。 + —— 配置有**别的写入者**(运行配置对话框改 `llm.reasoningEffort` 后 `notifyLlmConfigChanged()`, + 也可能在窗口失焦期间被改)。只在挂载时读一次会让「最近落盘值」过期:用户重选**同一档**会被 + 去重跳过,界面显示 A、磁盘却是 B。所以挂载、`LLM_CONFIG_CHANGED_EVENT`、窗口重新获得焦点 + 时都重读一次(**只读不写盘**),重新种 `persistedEffortRef`。 + —— 写盘在途或拖动预览未提交时不回显(回读拿到的还是旧值,回显会让界面闪一下)。 + */ + const writeInFlightRef = useRef(0); + const readEffortRef = useRef<(silent?: boolean) => void>(() => undefined); + readEffortRef.current = (silent = false) => { const invoke = resolveTauriInvoke(); if (!invoke) { - return undefined; + return; } void invoke('read_game_creator_app_config') .then((view) => { - if (cancelled || !mountedRef.current) return; + if (!mountedRef.current) return; const read = normalizeComposerReasoningEffort( view?.config?.llm?.reasoningEffort, ); // 回读值就是"磁盘上的档位":把它登记成最近落盘值,之后提交同一档位不再写盘。 persistedEffortRef.current = read; + if (writeInFlightRef.current > 0 || previewEffortRef.current !== null) { + return; + } setEffort(read); }) .catch(() => { - if (cancelled || !mountedRef.current) return; + if (!mountedRef.current || silent) return; setNotice('推理档读取失败'); }); + }; + + useEffect(() => { + readEffortRef.current(); + }, []); + + useEffect(() => { + // 外部写入者改档 / 窗口重新获得焦点:重读一次让「最近落盘值」跟上,避免去重吞掉真实改档。 + const reread = () => readEffortRef.current(true); + window.addEventListener(LLM_CONFIG_CHANGED_EVENT, reread); + window.addEventListener('focus', reread); return () => { - cancelled = true; + window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, reread); + window.removeEventListener('focus', reread); }; }, []); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index fff8566a4..dc4e6440a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,6 +1,7 @@ import { ConversationModelSelect } from '../../src/features/project-workspace/ConversationModelSelect'; import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { + LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, resetLlmModelCatalogCacheForTest, } from '../../src/services/llmModelCatalog'; @@ -1279,6 +1280,101 @@ export function registerChatComposerControlTests() { }); }); + it('strength slider: an external config change is re-read so re-selecting that grade still commits', async () => { + // 配置还有别的写入者(`RuntimeConfigDialog` 保存后 `notifyLlmConfigChanged()`)。组件必须重读 + // 配置并重新种「最近落盘值」,否则外部把档位改成「最高」后用户再选「高」会被去重跳过: + // 界面显示高、磁盘仍是最高(旧实现在这条用例里 writes 不含 'high')。 + let stored = 'default'; + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView(stored); + } + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + stored = String(args?.effort ?? ''); + return gameCreatorConfigView(stored); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('默认'), + ); + + // 外部改档(另一处写完配置会派发这个事件):组件重读并回显成「最高」。 + stored = 'max'; + await act(async () => { + window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT)); + await Promise.resolve(); + }); + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('最高'), + ); + + // 用户改选「高」:仍然要真落盘一次(档位枚举 0=默认 1=低 2=中 3=高 4=最高)。 + fireEvent.input(slider, { target: { value: '3' } }); + fireEvent.keyUp(slider); + await waitFor(() => expect(writes).toEqual(['high'])); + expect(stored).toBe('high'); + }); + + it('strength slider: pointercancel commits the pending preview once', async () => { + // 触屏取消(pointercancel)与「松手即提交」同一口径:预览值不能滞留到下一次提交或卸载 flush。 + // jsdom 没有 PointerEvent,这里派发同名原生事件(React 的合成 handler 按事件名分发)。 + const writes: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'select_game_creator_reasoning_effort') { + writes.push(String(args?.effort ?? '')); + return gameCreatorConfigView(String(args?.effort ?? '')); + } + if (command === 'read_game_creator_app_config') { + return gameCreatorConfigView('default'); + } + return null; + }, + ); + installTauriRuntime({ + core: { invoke }, + event: { listen: async () => () => undefined }, + }); + render( + React.createElement(ComposerReasoningEffortSelect, { + disabled: false, + presentation: 'section', + }), + ); + const slider = (await screen.findByRole('slider', { + name: '思维强度', + })) as HTMLInputElement; + await waitFor(() => + expect(slider.getAttribute('aria-valuetext')).toBe('默认'), + ); + + fireEvent.input(slider, { target: { value: '2' } }); + expect(writes, '拖动过程中不落盘').toEqual([]); + await act(async () => { + slider.dispatchEvent(new Event('pointercancel', { bubbles: true })); + await Promise.resolve(); + }); + await waitFor(() => expect(writes).toEqual(['medium'])); + }); + it('shows model service unavailability as a toast and leaves no persistent line', async () => { const { surface } = await openDirectCodexSurface({ load_game_creator_llm_models: () => {