From f4ada5061246355dde75de658927e639e6efbc22 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:12:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=AE=A1=E5=8A=A0=E5=9B=BA=EF=BC=9A?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=8C=89=20ref=20=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E3=80=81=E6=8F=92=E5=85=A5=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E4=B8=8E=E5=A4=B1=E8=B4=A5=E7=95=99=E7=97=95?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActiveChatComposerHandle.insertReferences 改为返回 boolean:由句柄回答这一批有没有真的递到输入区,注册表不再用「有句柄」冒充「插进去了」 insertChatReferences 增加第三种 false(句柄报落空),且只有插入成功才 focus,失败不抢焦点 DirectProjectChatView 改为注册「按 ref 转发」的句柄,注册时不再读 composerHandleRef.current,去掉对父子 effect 顺序的隐式依赖(内层输入区重挂载也不会留下死句柄) DirectProjectComposer 与 PlanningChatView 的转发句柄同步返回 boolean App.tsx 的 dev 失败线索文案改为「没有可用的聊天输入区(未挂载或已卸载)」,覆盖句柄落空这一种 新增 tests/activeChatComposer.test.ts:钉住注册表合同(空批次 / 无输入区 / 句柄报落空不聚焦 / 注销身份校验);反向证伪:去掉身份校验后该用例变红 同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md 与 shared-memory 决策记录 --- apps/ai-game-creator-shell/src/App.tsx | 4 +- .../project-workspace/activeChatComposer.ts | 15 +-- .../chat/DirectProjectChatView.tsx | 24 +++-- .../DirectProjectComposer.tsx | 16 +++- .../planning/PlanningChatView.tsx | 10 +- .../tests/activeChatComposer.test.ts | 96 +++++++++++++++++++ .../shared-memory/decision-log.md | 2 +- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 5 +- 8 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/activeChatComposer.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3bb43d1ec..df310e05d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1282,14 +1282,14 @@ export function App({ 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 策划链路挂策划面,事件本身不携带这个判断。 - 插入失败(空批次,或此刻没有输入区挂载)不再静默:dev 下留一行线索, + 插入失败(空批次,或此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, 否则用户看到的又是一次「点了没反应」。 */ const insertReferences = (references: readonly ChatReference[]) => { if (insertChatReferences(references)) return; if (import.meta.env.DEV) { console.warn( - '[resource-reference] 引用没有落进草稿:当前没有挂载中的聊天输入区', + '[resource-reference] 引用没有落进草稿:没有可用的聊天输入区(未挂载或已卸载)', ); } }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts index c85e09b20..cf3ac137d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -3,11 +3,13 @@ import type { ChatReference } from './resourceReferences'; /** * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 * - * 输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板内部用, - * 不进这里——注册表只负责跨面板的引用插入。 + * `insertReferences` 回答**这一批有没有真的落进草稿**(输入区自己那份句柄可能还没挂上、 + * 或者已经被卸载),注册表按它决定成功还是失败,不让「注册表里有句柄」冒充「用户看得见 + * 的结果」。输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板 + * 内部用,不进这里——注册表只负责跨面板的引用插入。 */ export type ActiveChatComposerHandle = { - insertReferences: (references: ChatReference[]) => void; + insertReferences: (references: ChatReference[]) => boolean; focus: () => void; }; @@ -37,8 +39,9 @@ export function registerActiveChatComposer( /** * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 * - * 空批次与「此刻没有任何输入区挂载」都返回 `false`:这两件事都不能静默, - * 由调用方(`App.tsx` 的事件监听)决定怎么留痕或提示,注册表本身不吞。 + * 三种情况都返回 `false`:空批次、此刻没有任何输入区挂载、注册表里的句柄已经插不进去 + * (它转发的那份输入区没挂上或已卸载)。这些都不能静默,由调用方(`App.tsx` 的事件监听) + * 决定怎么留痕或提示,注册表本身不吞;只有真的插进去了才把焦点交给输入区。 */ export function insertChatReferences( references: readonly ChatReference[], @@ -46,7 +49,7 @@ export function insertChatReferences( if (references.length === 0) return false; const handle = activeChatComposer; if (!handle) return false; - handle.insertReferences([...references]); + if (!handle.insertReferences([...references])) return false; handle.focus(); return true; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index ae909ff64..a429ada6a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -221,14 +221,24 @@ export function DirectProjectChatView({ /* 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 - 互斥,所以同一时刻注册表里只有一个句柄。挂载期间注册、卸载注销,输入区不在位时 - 插入请求会拿到 `false` 而不是静默丢掉(见 `activeChatComposer.ts`)。 + 互斥,所以同一时刻注册表里只有一个句柄。 + + 注册的是一个**按 ref 转发**的句柄、且不依赖「输入区此刻已挂上」:注册只表达「这个聊天面 + 在用」,插入成功与否由转发那一刻的实际情况回答(`DirectProjectComposerHandle.insertReferences` + 返回 boolean)。这样挂载顺序、子组件重挂载都不会让注册表漏挂或指向死句柄。 */ - useEffect(() => { - const handle = composerHandleRef.current; - if (!handle) return; - return registerActiveChatComposer(handle); - }, []); + useEffect( + () => + registerActiveChatComposer({ + insertReferences: (references) => { + const handle = composerHandleRef.current; + if (!handle) return false; + return handle.insertReferences(references); + }, + focus: () => composerHandleRef.current?.focus(), + }), + [], + ); return (
({ - insertReferences: (references) => - composerRef.current?.insertReferences([...references]), + insertReferences: (references: ChatReference[]) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, focus: () => composerRef.current?.focus(), }), [], diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 8cbaad380..928d75d03 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -137,13 +137,17 @@ export function PlanningChatView({ /* 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 - 可变句柄),注册的那个包装对象因此永远读到最新值。 + 可变句柄),注册的那个包装对象因此永远读到最新值,并由它回答「这一批有没有真的插进去」。 */ useEffect(() => { if (!composerRef) return; return registerActiveChatComposer({ - insertReferences: (references) => - composerRef.current?.insertReferences([...references]), + insertReferences: (references) => { + const handle = composerRef.current; + if (!handle) return false; + handle.insertReferences([...references]); + return true; + }, focus: () => composerRef.current?.focus(), }); }, [composerRef]); diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts new file mode 100644 index 000000000..c26deb80e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type ActiveChatComposerHandle, + insertChatReferences, + registerActiveChatComposer, +} from '../src/features/project-workspace/activeChatComposer'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; + +function resourceReference(resourceId: string): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'character', + mediaType: 'image/png', + label: resourceId, + category: 'character', + tags: [], + source: 'resource-card', + }; +} + +/** 一个「插入总是成功」的输入区句柄替身;`inserted` 为 false 时模拟输入区已不在位。 */ +function composerHandle(inserted = true): { + handle: ActiveChatComposerHandle; + insertReferences: ReturnType; + focus: ReturnType; +} { + const insertReferences = vi.fn(() => inserted); + const focus = vi.fn(); + return { handle: { insertReferences, focus }, insertReferences, focus }; +} + +/** + * 「活跃聊天输入区」注册表的合同。 + * + * 这些语义就是 `App.tsx` 那处监听区分「引用落进草稿」与「这一步落空(要留痕)」的唯一依据, + * 所以在这里钉死;链路本身的端到端断言在 `resourceCanvasChatReferenceDrop.test.tsx` 与 + * `appSurface/*.suite.ts`。 + */ +describe('活跃聊天输入区注册表', () => { + it('没有任何输入区挂载时插入返回 false', () => { + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); + + it('空批次不算成功,也不打扰已挂载的输入区', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([])).toBe(false); + expect(insertReferences).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('挂载期间:整批一次交给输入区、插入成功后才聚焦,返回 true', () => { + const { handle, insertReferences, focus } = composerHandle(); + const unregister = registerActiveChatComposer(handle); + expect( + insertChatReferences([ + resourceReference('hero'), + resourceReference('npc'), + ]), + ).toBe(true); + expect(insertReferences).toHaveBeenCalledTimes(1); + expect(insertReferences).toHaveBeenCalledWith([ + expect.objectContaining({ resourceId: 'hero' }), + expect.objectContaining({ resourceId: 'npc' }), + ]); + expect(focus).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('句柄报「这一批没插进去」时返回 false,且不抢焦点', () => { + const { handle, focus } = composerHandle(false); + const unregister = registerActiveChatComposer(handle); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + expect(focus).not.toHaveBeenCalled(); + unregister(); + }); + + it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + const first = composerHandle(); + const second = composerHandle(); + const unregisterFirst = registerActiveChatComposer(first.handle); + const unregisterSecond = registerActiveChatComposer(second.handle); + + // 旧链路后卸载(切换项目 / 两条链路互换时可能发生):新句柄必须留在注册表里。 + unregisterFirst(); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + + unregisterSecond(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6c1a99cfb..14449989a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,7 +3,7 @@ ## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602) - 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。 -- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 空批次或无句柄返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`,`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数;`insertChatReferences` 在空批次 / 无输入区 / 句柄报「这一批没插进去」三种情况返回 `false`)。`DirectProjectComposer` 用 `useImperativeHandle` 暴露 `DirectProjectComposerHandle`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 - 边界:不采用「给 DirectProjectComposer 单独加 ref 出口 + App 按模式分流」的备选(那会把「哪个 ref 此刻是活的」继续留在检测点上)。插入仍经 `ResourceReferenceInput.insertReferences` + `focus()`(光标落在插入之后,连点两次按顺序追加)。真正根治的形态是画布与聊天的共同宿主用 context 下发插入能力;注册表语义与之一致,将来换实现不必动画布。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts`(新增)、`src/App.tsx`、`src/view/project-development/chat/DirectProjectChatView.tsx`、`.../chat/components/DirectProjectComposer/DirectProjectComposer.tsx`、`.../planning/PlanningChatView.tsx`、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`。 - 验证:`npm run test -- apps/ai-game-creator-shell/tests`(195 passed / 1 skipped 文件,1902 passed / 17 skipped 用例)、`npm run test -- src/components/image-editor`(88 passed / 1401 passed)、`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index d45b1efc0..ccf88833b 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -9,8 +9,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, 资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 window 自定义事件(`RESOURCE_REFERENCE_INSERT_EVENT` / `RESOURCE_REFERENCE_INSERT_MANY_EVENT`)。**消费者只有 `App.tsx` 一处**,事件本身不携带「插到哪个输入盒」——那由注册表回答: - `apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts` 用模块级变量保存**当前挂载的那一个**输入区句柄(`{ insertReferences(refs), focus() }`)。 -- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。 -- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`:空批次或没有挂载中的输入区时返回 `false`,dev 下 `console.warn` 留一行线索(不再有可选链静默吞掉整次点击)。 +- `DirectProjectChatView`(普通项目)与 `PlanningChatView`(立项策划)挂载期间各自注册、卸载注销;两条链路互斥渲染,所以同一时刻只有一个句柄。注册的是**按 ref 转发**的句柄、且不在注册时读输入区是否就位,所以挂载顺序与子组件重挂载都不会让注册表漏挂或指向死句柄。 +- `App.tsx` 的单条与批量两个监听都调 `insertChatReferences(refs)`;返回 `false` 的三种情况(空批次、没有挂载中的输入区、注册表里的句柄报「这一批没插进去」)都不静默,dev 下 `console.warn` 留一行线索;只有真的插进去了才把焦点交给输入区。 - `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 @@ -22,6 +22,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, | 工具条「引用」(键盘 + 鼠标两条通路)落进 DirectProject 草稿,光标留在插入之后 | `apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 的「工具条里的「引用」把素材 @ 进真实聊天草稿」 | | 拖拽批量引用整批一次落进草稿、顺序 = 拖动集合顺序、零坐标写入 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` | | 未登记素材不出「引用」按钮、拖到对话栏只给原因 | `tests/resourceCanvasChatReferenceDrop.test.tsx` 的「未登记素材」用例、`tests/resourceCardReferenceDropModel.test.ts` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | | 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | ## 引用来源由宿主注入(2026-09-22)