diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index df310e05d..4aee35d18 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1282,10 +1282,12 @@ export function App({ 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 策划链路挂策划面,事件本身不携带这个判断。 - 插入失败(空批次,或此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, - 否则用户看到的又是一次「点了没反应」。 + 插入失败(此刻没有可用的输入区——没挂载或已卸载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。空批次不是失败,它没有要插的东西, + 也就不能把原因指到输入区上。 */ const insertReferences = (references: readonly ChatReference[]) => { + if (references.length === 0) return; if (insertChatReferences(references)) return; if (import.meta.env.DEV) { console.warn( 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 cf3ac137d..cc67b7a7b 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 @@ -24,10 +24,21 @@ let activeChatComposer: ActiveChatComposerHandle | null = null; * * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), * 旧注销不能把新句柄一起清掉。 + * + * 两个输入区同时挂载属于调用方接线错误(本应互斥,见 `App.tsx` 的 `directProjectMode`): + * 后注册者会顶掉前者,引用会落进用户看不见的那份草稿。这里只留一条线索,不改运行时语义 + * ——注册表仍然按最后注册的那个工作,注销的身份校验也照旧兜住乱序卸载。 */ export function registerActiveChatComposer( handle: ActiveChatComposerHandle, ): () => void { + if (activeChatComposer && activeChatComposer !== handle) { + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 检测到第二个聊天输入区注册:引用会插进最后注册的那一个', + ); + } + } activeChatComposer = handle; return () => { if (activeChatComposer === handle) { diff --git a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts index c26deb80e..759a376e3 100644 --- a/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts +++ b/apps/ai-game-creator-shell/tests/activeChatComposer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { type ActiveChatComposerHandle, @@ -39,6 +39,11 @@ function composerHandle(inserted = true): { * `appSurface/*.suite.ts`。 */ describe('活跃聊天输入区注册表', () => { + // 注册表是模块级单例:用例之间靠对称注销回到空态,只在这里清 mock 记录。 + afterEach(() => { + vi.restoreAllMocks(); + }); + it('没有任何输入区挂载时插入返回 false', () => { expect(insertChatReferences([resourceReference('hero')])).toBe(false); }); @@ -79,6 +84,8 @@ describe('活跃聊天输入区注册表', () => { }); it('注销按身份校验:旧输入区卸载不会把已经接管的新输入区一起清掉', () => { + // 这条用例故意让两个句柄同时在册(重复注册的告警本身由下一条用例覆盖),先静音。 + vi.spyOn(console, 'warn').mockImplementation(() => {}); const first = composerHandle(); const second = composerHandle(); const unregisterFirst = registerActiveChatComposer(first.handle); @@ -93,4 +100,38 @@ describe('活跃聊天输入区注册表', () => { unregisterSecond(); expect(insertChatReferences([resourceReference('hero')])).toBe(false); }); + + it('重复注册:后注册者接管并留一条告警,乱序注销也清不掉更新的句柄', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const first = composerHandle(); + const second = composerHandle(); + const third = composerHandle(); + + const unregisterFirst = registerActiveChatComposer(first.handle); + expect(warn).not.toHaveBeenCalled(); + + // 第二个注册:接线错误(两个聊天面同时挂载),后注册者接管,只在 dev 留线索。 + const unregisterSecond = registerActiveChatComposer(second.handle); + expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([ + expect.stringContaining('第二个聊天输入区注册'), + ]); + expect(insertChatReferences([resourceReference('hero')])).toBe(true); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + // 再注册第三个(模拟又一条链路接管):同样留线索。 + const unregisterThird = registerActiveChatComposer(third.handle); + expect(warn).toHaveBeenCalledTimes(2); + + // 乱序注销:第一个、第二个先卸载,都不能清掉当前接管的第三个。 + unregisterFirst(); + unregisterSecond(); + expect(insertChatReferences([resourceReference('npc')])).toBe(true); + expect(third.insertReferences).toHaveBeenCalledTimes(1); + expect(first.insertReferences).not.toHaveBeenCalled(); + expect(second.insertReferences).toHaveBeenCalledTimes(1); + + unregisterThird(); + expect(insertChatReferences([resourceReference('hero')])).toBe(false); + warn.mockRestore(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 62ae983e0..e62f08807 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -532,4 +532,28 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(draftReferenceIds()).toEqual([]); dispose(); }); + + it('空批次引用事件不报「没有可用的聊天输入区」', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { dispose } = await mountCanvas(); + // 派发器自己会挡掉空批次(`dispatchResourceReferenceInsertMany` 对空数组直接 return), + // 这里直接造事件:钉的是 App 侧不把「没有要插的东西」说成「没有输入区」。 + act(() => { + window.dispatchEvent( + new CustomEvent(RESOURCE_REFERENCE_INSERT_MANY_EVENT, { + detail: { references: [] }, + }), + ); + }); + await settle(); + + expect( + warn.mock.calls + .map((call) => String(call[0])) + .filter((message) => message.includes('没有可用的聊天输入区')), + ).toEqual([]); + expect(draftReferenceIds()).toEqual([]); + warn.mockRestore(); + dispose(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 14449989a..83dd2bff7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,10 +3,10 @@ ## 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`(按 ref 转发、由它回答插入是否真的递到输入区),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册**按 ref 转发**的句柄(注册时不读输入区是否就位,因此不依赖父子 effect 顺序)(两条链路互斥渲染,同一时刻只有一个句柄)。`App.tsx` 收敛为一处监听,单条 + 批量两个事件都走 `insertChatReferences`;返回 `false` 时 dev 下 `console.warn`。`chatComposerRef` 只保留给策划输入盒自己的 `getDraft` / `clear`。 +- 决策:新增 `features/project-workspace/activeChatComposer.ts`,模块级只保存**当前挂载的那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数,并检测到第二个输入区注册时留一条 dev 告警——不改运行时语义;`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`。 +- 影响范围:`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/activeChatComposer.test.ts`(新增,钉注册表合同)、`tests/resourceCanvasChatReferenceDrop.test.tsx`、`tests/appSurface/{project-development,design-agent}.suite.ts`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`pitfalls.md`、本文件。 +- 验证(合并 master 后的最终一轮):`npx vitest run apps/ai-game-creator-shell/tests`(197 passed / 1 skipped 文件,1918 passed / 17 skipped 用例)、`npx vitest run tests/activeChatComposer.test.ts tests/resourceCanvasChatReferenceDrop.test.tsx`(2 files / 12 passed,含注册表合同:空批次、无输入区、句柄报落空、注销身份校验、重复注册告警、乱序注销)、`npx vitest run tests/appSurface.test.ts -t 引用`(4 passed)、`npm run agc:typecheck`(含 `check:tests:types`,exit 0)、`npm run check:encoding`、`git diff --check`、eslint `--max-warnings 0`(改动文件)。反向证伪:去掉注册调用后端到端用例变红;去掉空批次短路 / 重复注册告警后对应新用例各红一处。 ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ccf88833b..6718d6e2c 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -22,7 +22,8 @@ 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` | +| 注册表自身合同:空批次 / 无输入区 / 句柄报落空 / 注销身份校验 / 重复注册留线索 | `apps/ai-game-creator-shell/tests/activeChatComposer.test.ts` | +| 空批次事件不误报成「没有可用的聊天输入区」 | `apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx` 的「空批次引用事件」用例 | | 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | ## 引用来源由宿主注入(2026-09-22)