From fe3c1389ae6b000ac1a9ffd85ea2d365d4eb208d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 18:11:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=A1=A3=EF=BC=9A=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=94=B9=E9=85=8D=E7=BD=AE=E5=90=8E=E9=87=8D=E8=AF=BB?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E6=96=B0=E7=A7=8D=E3=80=8C=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E5=80=BC=E3=80=8D=EF=BC=8C=E9=87=8D=E9=80=89?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=A1=A3=E4=BB=8D=E7=84=B6=E8=90=BD=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 问题(评审 D5 边界):`persistedEffortRef` 只在挂载回读与自身落盘时更新,而配置还有别的写入者——`RuntimeConfigDialog` 保存后会 `notifyLlmConfigChanged()`。外部把 `llm.reasoningEffort` 改成「最高」后,常驻挂载的滑块手上仍是旧口径:用户改选「高」时 `next !== persisted` 本来成立,但若外部改成了用户要选的**同一档**(或先外部改档再重选原档),去重会把这次点击吞掉,界面显示 A、磁盘是 B - 改法:把「读一次配置」抽成 `readEffortRef`,挂载时读一次,另外在 `LLM_CONFIG_CHANGED_EVENT` 与窗口 `focus` 时**重读并重新种 `persistedEffortRef`**(只读不写盘;`silent` 时不重复报「推理档读取失败」);写盘在途或预览未提交时不回显,避免回读旧值把界面顶回去 - 用例:`chat-composer.suite.ts`「an external config change is re-read so re-selecting that grade still commits」——挂载读到「默认」→ 外部改档 + 派发 `LLM_CONFIG_CHANGED_EVENT` → 断言回显成「最高」→ 用户改选「高」→ **必须写一次**(改前该用例红:writes 里没有 'high',界面对着旧口径把这次点击去重掉) - 滑块 pointercancel 的用例也在这一条里(handler 随上一条提交落地):预览后 `pointercancel` → 恰好写一次 Refs #600, PR #607 --- .../ComposerControls.tsx | 37 +++++-- .../tests/appSurface/chat-composer.suite.ts | 96 +++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) 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: () => {