From a22e8ee4474d88659c561b285fb265eb63992571 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 19:43:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=B4=A0=E6=9D=90=E5=8D=A1=E3=80=8C=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E3=80=8D=E6=97=A0=E6=B6=88=E8=B4=B9=E8=80=85=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E6=B4=BB=E8=B7=83=E8=81=8A=E5=A4=A9=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E5=8C=BA=E6=B3=A8=E5=86=8C=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 features/project-workspace/activeChatComposer.ts:模块级保存当前挂载的输入区句柄,registerActiveChatComposer 注销时校验身份,insertChatReferences 空批次或无句柄返回 false DirectProjectComposer 用 useImperativeHandle 暴露 DirectProjectComposerHandle(按 ref 转发,句柄稳定),新增可选 ref 入参 DirectProjectChatView 挂载期间注册、卸载注销,并把 composerHandleRef 传给 DirectProjectComposer PlanningChatView 同样注册(句柄按 composerRef 转发),两条链路互斥渲染,同一时刻只有一个句柄 App.tsx 收敛为一处监听:单条「引用」与批量拖拽两个事件都走 insertChatReferences,返回 false 时 dev 下 console.warn chatComposerRef 只保留给策划输入盒自己的 getDraft / clear,不再承担跨面板插入 project-development.suite.ts:工具条「引用」用例改为渲染真实 DirectProject 聊天面,断言草稿里出现引用芯片(键盘 + 鼠标两条通路、光标留在插入之后) resourceCanvasChatReferenceDrop.test.tsx:换成真实 DirectProject 聊天面,批量拖拽断言整批一次落进草稿且顺序 = 拖动集合顺序;新增未登记素材不出「引用」按钮、拖拽只给原因的用例 design-agent.suite.ts:新增策划链路引用插入不回归用例 同步 docs/【功能说明】AGC聊天素材引用-2026-09-08.md、shared-memory 的 pitfalls 与 decision-log --- apps/ai-game-creator-shell/src/App.tsx | 40 ++++- .../project-workspace/activeChatComposer.ts | 52 +++++++ .../chat/DirectProjectChatView.tsx | 21 ++- .../DirectProjectComposer.tsx | 35 ++++- .../planning/PlanningChatView.tsx | 16 +- .../tests/appSurface/design-agent.suite.ts | 32 ++++ .../appSurface/project-development.suite.ts | 117 +++++++++------ .../resourceCanvasChatReferenceDrop.test.tsx | 141 ++++++++++++++++-- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 8 + .../【功能说明】AGC聊天素材引用-2026-09-08.md | 27 +++- 11 files changed, 431 insertions(+), 66 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1bd48562a..3bb43d1ec 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -71,6 +71,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; +import { insertChatReferences } from './features/project-workspace/activeChatComposer'; import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { needsInitializedChatProject, @@ -78,10 +79,13 @@ import { } from './features/project-workspace/projectCommandPolicy'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; import { + type ChatReference, directCodexContentToLegacyContentDto, hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + RESOURCE_REFERENCE_INSERT_MANY_EVENT, type ResourceReferenceInsertEventDetail, + type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { readGamePublishAvailability } from './services/gameDistributionPublish'; @@ -1273,22 +1277,52 @@ export function App({ } useEffect(() => { + /* + 画布引用只有这一处消费者:单条「引用」按钮与拖拽批量引用都从这里落进草稿。 + 落到哪份输入区由「活跃聊天输入区」注册表回答——普通项目挂 DirectProject、 + 策划链路挂策划面,事件本身不携带这个判断。 + + 插入失败(空批次,或此刻没有输入区挂载)不再静默:dev 下留一行线索, + 否则用户看到的又是一次「点了没反应」。 + */ + const insertReferences = (references: readonly ChatReference[]) => { + if (insertChatReferences(references)) return; + if (import.meta.env.DEV) { + console.warn( + '[resource-reference] 引用没有落进草稿:当前没有挂载中的聊天输入区', + ); + } + }; const handleResourceReferenceInsert = (event: Event) => { const detail = (event as CustomEvent) .detail; if (!detail?.reference) return; - chatComposerRef.current?.insertReferences([detail.reference]); - chatComposerRef.current?.focus(); + insertReferences([detail.reference]); + }; + const handleResourceReferenceInsertMany = (event: Event) => { + const detail = ( + event as CustomEvent + ).detail; + insertReferences(detail?.references ?? []); }; window.addEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); - return () => + window.addEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + return () => { window.removeEventListener( RESOURCE_REFERENCE_INSERT_EVENT, handleResourceReferenceInsert, ); + window.removeEventListener( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + handleResourceReferenceInsertMany, + ); + }; }, []); /** 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 new file mode 100644 index 000000000..c85e09b20 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/activeChatComposer.ts @@ -0,0 +1,52 @@ +import type { ChatReference } from './resourceReferences'; + +/** + * 当前挂载的聊天输入区句柄:只保留「把引用插进草稿」这一件事。 + * + * 输入区自己还持有 `getDraft` / `clear` 之类的提交期能力,但那些只在所属面板内部用, + * 不进这里——注册表只负责跨面板的引用插入。 + */ +export type ActiveChatComposerHandle = { + insertReferences: (references: ChatReference[]) => void; + focus: () => void; +}; + +/** + * 同一时刻只可能有一个聊天输入区挂载:普通项目走 DirectProject,立项策划走策划面, + * 两条链路互斥渲染(见 `App.tsx` 的 `directProjectMode`)。 + */ +let activeChatComposer: ActiveChatComposerHandle | null = null; + +/** + * 注册当前挂载的聊天输入区,返回注销函数。 + * + * 注销时按身份校验:新输入区已经接管、旧输入区才卸载时(切换项目、两条链路互换), + * 旧注销不能把新句柄一起清掉。 + */ +export function registerActiveChatComposer( + handle: ActiveChatComposerHandle, +): () => void { + activeChatComposer = handle; + return () => { + if (activeChatComposer === handle) { + activeChatComposer = null; + } + }; +} + +/** + * 把一批引用插进当前挂载的聊天输入区,回答**有没有落进草稿**。 + * + * 空批次与「此刻没有任何输入区挂载」都返回 `false`:这两件事都不能静默, + * 由调用方(`App.tsx` 的事件监听)决定怎么留痕或提示,注册表本身不吞。 + */ +export function insertChatReferences( + references: readonly ChatReference[], +): boolean { + if (references.length === 0) return false; + const handle = activeChatComposer; + if (!handle) return false; + handle.insertReferences([...references]); + 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 f2d345d75..ae909ff64 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 @@ -4,17 +4,22 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; -import { DirectProjectComposer } from './components/DirectProjectComposer/DirectProjectComposer'; +import { + DirectProjectComposer, + type DirectProjectComposerHandle, +} from './components/DirectProjectComposer/DirectProjectComposer'; import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation'; import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog'; import { @@ -103,6 +108,7 @@ export function DirectProjectChatView({ }: DirectProjectChatViewProps) { const { assets, projectId, refresh, versions } = useDirectProjectManifest(projectPath); + const composerHandleRef = useRef(null); const [runtimeNotice, setRuntimeNotice] = useState(''); const [settingsOpen, setSettingsOpen] = useState(false); const [approvalOpen, setApprovalOpen] = useState(false); @@ -212,6 +218,18 @@ export function DirectProjectChatView({ }, })); + /* + 画布的「引用」与拖拽批量引用是 window 事件,唯一消费者在 `App.tsx`;它只认注册表里 + **当前挂载**的输入区。普通项目固定渲染这里、策划链路渲染 `PlanningChatView`,两条链路 + 互斥,所以同一时刻注册表里只有一个句柄。挂载期间注册、卸载注销,输入区不在位时 + 插入请求会拿到 `false` 而不是静默丢掉(见 `activeChatComposer.ts`)。 + */ + useEffect(() => { + const handle = composerHandleRef.current; + if (!handle) return; + return registerActiveChatComposer(handle); + }, []); + return (
) : null} Promise; + /** + * 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」, + * 画布的「引用」/拖拽批量引用事件才能落进这份草稿。 + */ + ref?: Ref; }) { const composerRef = useRef(null); const composerRootRef = useRef(null); @@ -166,6 +186,19 @@ export function DirectProjectComposer({ }, [onUploadFiles], ); + /* + 对外句柄只做一层转发:内部输入区句柄会随编辑器重挂载换对象,这里按 ref 读最新值, + 句柄本身(注册表持有的那个)保持稳定。插入与聚焦都不自己实现,避免出现第二套草稿真相。 + */ + useImperativeHandle( + ref, + () => ({ + insertReferences: (references) => + composerRef.current?.insertReferences([...references]), + focus: () => composerRef.current?.focus(), + }), + [], + ); // 这个按钮只在"没有可终止对象"的分支出现(有的话由 ComposerStopButton 接管), // 所以不再带忙态逻辑:队列里有待发消息时它照样在,用户能继续排。 const submitButton = ( 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 7e35bc9a0..8cbaad380 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 @@ -1,6 +1,6 @@ import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -15,6 +15,7 @@ import { projectNameFromPath, projectRuntimeVisibleError, } from '../../../features/agent-runtime'; +import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer'; import { ConversationModelSelect } from '../../../features/project-workspace/ConversationModelSelect'; import { attachmentReferenceProvider } from '../../../features/project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../../../features/project-workspace/reference-source/resourceReferenceProvider'; @@ -133,6 +134,19 @@ export function PlanningChatView({ onDesignRetry, }: PlanningChatViewProps) { const designFileInputRef = useRef(null); + /* + 策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处, + 它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份 + 可变句柄),注册的那个包装对象因此永远读到最新值。 + */ + useEffect(() => { + if (!composerRef) return; + return registerActiveChatComposer({ + insertReferences: (references) => + composerRef.current?.insertReferences([...references]), + focus: () => composerRef.current?.focus(), + }); + }, [composerRef]); /* 策划输入盒的引用来源:**只注入资源与两个静默 provider**。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index d96c09714..84fcbd24b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,3 +1,4 @@ +import { dispatchResourceReferenceInsert } from '../../src/features/project-workspace/resourceReferences'; import { act, App, @@ -916,4 +917,35 @@ export function registerDesignAgentSurfaceTests() { expect(details[1].open).toBe(false); await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); }); + + it('画布派发的「引用」落进策划输入盒草稿', async () => { + const harness = createProjectChatRuntimeHarness({ + initialSessionExists: false, + }); + renderDesignAgent(harness); + const editor = await screen.findByLabelText('项目需求'); + + // 资源画布唯一的生产入口就是这个 window 事件;消费者在 `App.tsx`,落到哪份输入盒由 + // 「活跃聊天输入区」注册表回答。策划链路与普通项目共用一个消费者,这条用例钉的是 + // 它在 `PlanningChatView` 这一侧也真的进了草稿。 + act(() => { + dispatchResourceReferenceInsert({ + type: 'resource', + resourceId: 'planning-hero', + kind: 'character', + mediaType: 'image/png', + label: 'hero.png', + category: 'character', + tags: [], + source: 'resource-card', + }); + }); + + await waitFor(() => + expect( + editor.querySelectorAll('[data-resource-reference-id="planning-hero"]'), + ).toHaveLength(1), + ); + expect(editor.textContent).toContain('@hero.png'); + }); } 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 b66ab4c34..e1bab8945 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 @@ -10,7 +10,6 @@ import { GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -30,8 +29,10 @@ import { } from '../resourceGenerationPromptTestUtils'; import { act, + App, cleanup, createGameCreationAppManifest, + createProjectChatRuntimeHarness, expect, findResourceSelectButton, fireEvent, @@ -3596,7 +3597,7 @@ export function registerProjectWorkbenchFoundationTests() { ); }); - it('工具条里的「引用」用键盘也能插进聊天输入框', async () => { + it('工具条里的「引用」把素材 @ 进真实聊天草稿(键盘与鼠标两条通路)', async () => { const manifest = createGameCreationAppManifest( 'workbench-resource-reference', '引用入口测试', @@ -3611,6 +3612,13 @@ export function registerProjectWorkbenchFoundationTests() { source: { kind: 'generated' }, }); let layoutRevision = 0; + const projectPath = '/tmp/workbench-resource-reference'; + // 对话面用真实 DirectProject 链路(`App` → `DirectProjectChatView` → `DirectProjectComposer`): + // 引用事件的消费者在 `App.tsx`,桩接不住它,链路断点正好在「谁把它落进草稿」。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { @@ -3648,20 +3656,43 @@ export function registerProjectWorkbenchFoundationTests() { dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }; } - throw new Error(`unexpected invoke ${command}`); + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } }; + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + } as unknown as typeof window.__TAURI__; render( React.createElement(ProjectDevelopmentView, { projectName: '引用入口测试', - projectPath: '/tmp/workbench-resource-reference', + projectPath, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, - chat: React.createElement('div', null, '项目总控'), + chat: React.createElement(App, { initialProjectPath: projectPath }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -3689,49 +3720,39 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 hero.png', ); - const inserted: unknown[] = []; - const onInsert = (event: Event) => { - inserted.push( - (event as CustomEvent<{ reference: unknown }>).detail.reference, - ); - }; - window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - try { - // 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip - // (`App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。 - referenceButton.focus(); - expect(document.activeElement).toBe(referenceButton); - await userEvent.setup().keyboard('{Enter}'); - expect(inserted).toHaveLength(1); + // 端到端契约:交付结果是草稿里的引用芯片,不是 window 上的一次 dispatch。 + // 链路断点一直是「谁把引用落进草稿」,所以断言必须读真实输入盒的 DOM。 + const draftChipIds = () => + Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id')); + const composerEditor = () => + document.querySelector( + 'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]', + )!; + // 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。 + const firstChipLabel = () => + document.querySelector( + 'form.project-chat-composer [data-resource-reference-id] .resource-reference-chip-label', + )?.textContent; - // 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。 - fireEvent.click(referenceButton); - expect(inserted).toHaveLength(2); - } finally { - window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert); - } - expect(inserted).toEqual([ - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - { - type: 'resource', - resourceId: 'scene-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene', - tags: ['主舞台'], - source: 'resource-card', - }, - ]); + // 键盘通路:聚焦后回车确认。 + referenceButton.focus(); + expect(document.activeElement).toBe(referenceButton); + await userEvent.setup().keyboard('{Enter}'); + await waitFor(() => expect(draftChipIds()).toEqual(['scene-hero'])); + expect(firstChipLabel()).toBe('hero'); + expect(composerEditor().textContent).toContain('@hero'); + + // 鼠标通路:再点一次,第二枚芯片接在第一枚之后——光标留在插入之后,不覆盖已插的引用。 + fireEvent.click(referenceButton); + await waitFor(() => + expect(draftChipIds()).toEqual(['scene-hero', 'scene-hero']), + ); + // 插入后焦点回到输入盒,用户可以接着打字。 + expect(document.activeElement).toBe(composerEditor()); }); it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx index 446f721ee..7a1333d78 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasChatReferenceDrop.test.tsx @@ -14,8 +14,11 @@ import { type ResourceReferenceInsertManyEventDetail, } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; +import type { ProjectAttachmentResult } from '../src/view/project-development/resourceProjectionModel'; import { + App, createGameCreationAppManifest, + createProjectChatRuntimeHarness, fireEvent, React, render, @@ -87,10 +90,21 @@ type LayoutWrite = { positions: ProjectResourceCanvasPosition[]; }; -function installTauri(): { layoutWrites: LayoutWrite[] } { +/** 画布与真实 DirectProject 聊天面共用的项目身份。 */ +const CANVAS_PROJECT_PATH = '/tmp/chat-reference-drop'; + +function installTauri(manifest: GameCreationAppManifest): { + layoutWrites: LayoutWrite[]; +} { const layoutWrites: LayoutWrite[] = []; const persisted = new Map(); const revisions = new Map(); + // 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答, + // 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。 + const chatHarness = createProjectChatRuntimeHarness({ + projectPath: CANVAS_PROJECT_PATH, + initialSessionExists: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { @@ -140,10 +154,33 @@ function installTauri(): { layoutWrites: LayoutWrite[] } { if (command === 'list_local_project_asset_generations') { return []; } - return undefined; + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'inspect_local_project_directory') { + return { + projectPath: CANVAS_PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'get_design_agent_runtime_mode') { + return null; + } + return chatHarness.invoke(command, args); }, ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + } as unknown as typeof window.__TAURI__; return { layoutWrites }; } @@ -185,21 +222,34 @@ function collectReferenceInserts() { }; } -async function mountCanvas() { - const tauri = installTauri(); +async function mountCanvas( + options: { + /** 项目附件(无 manifest 登记的素材来源)。 */ + attachments?: ProjectAttachmentResult[]; + /** 打开哪个栏目找卡片;未登记素材统一落在「待归类」。 */ + categoryLabel?: string; + } = {}, +) { + const { attachments = [], categoryLabel = '角色与对象' } = options; const manifest = manifestFor('chat-reference-drop', [ characterAsset('drop-a', 'a.png'), characterAsset('drop-b', 'b.png'), ]); + const tauri = installTauri(manifest); render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/chat-reference-drop', + projectPath: CANVAS_PROJECT_PATH, manifest, - attachments: [], + attachments, recentRunStatus: null, recentRunStopReason: null, - supervisor: React.createElement('div', null, '项目总控'), + // 对话面是真实链路:`App`(`directProjectMode`)→ `DirectProjectChatView` → + // `DirectProjectComposer`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」, + // 所以这里不能再拿 `
项目总控
` 顶替。 + chat: React.createElement(App, { + initialProjectPath: CANVAS_PROJECT_PATH, + }), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), @@ -220,7 +270,7 @@ async function mountCanvas() { toJSON: () => ({}), } as DOMRect); act(() => window.dispatchEvent(new Event('resize'))); - fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + fireEvent.click(screen.getByRole('button', { name: `打开${categoryLabel}` })); await settle(); const chat = document.querySelector('.game-workbench-chat')!; vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT); @@ -234,6 +284,20 @@ function cardIn(manager: HTMLElement, resourceId: string) { )!; } +/** + * 真实 DirectProject 输入盒草稿里的资源引用芯片,按 DOM 顺序读出 `resourceId`。 + * + * 事件派发本身不是交付结果:链路断点一直是「谁把它落进草稿」,所以断言必须读草稿, + * 不能再读 window 上的一次 dispatch。 + */ +function draftReferenceIds() { + return Array.from( + document.querySelectorAll( + 'form.project-chat-composer [data-resource-reference-id]', + ), + ).map((chip) => chip.getAttribute('data-resource-reference-id') ?? ''); +} + afterEach(() => { delete window.__TAURI__; vi.restoreAllMocks(); @@ -279,6 +343,8 @@ describe('拖动素材到对话:批量 @ 引用', () => { kind: 'character', source: 'resource-card', }); + // 端到端:这一批引用真的落进了真实 DirectProject 输入盒的草稿。 + await waitFor(() => expect(draftReferenceIds()).toEqual(['drop-a'])); // 拖到对话不是排版:一条坐标都不写。 expect(tauri.layoutWrites).toHaveLength(writesBefore); expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull(); @@ -318,6 +384,10 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect( new Set(inserts[0]!.map((reference) => reference.resourceId)), ).toEqual(new Set(['drop-a', 'drop-b'])); + // 端到端:整批一次插进同一份草稿,顺序就是拖动集合(画布可见顺序)的顺序。 + await waitFor(() => + expect(draftReferenceIds()).toEqual(['drop-a', 'drop-b']), + ); expect(tauri.layoutWrites).toHaveLength(writesBefore); dispose(); }); @@ -395,4 +465,57 @@ describe('拖动素材到对话:批量 @ 引用', () => { expect(inserts).toHaveLength(0); dispose(); }); + + it('未登记素材:不出现「引用」按钮,拖到对话栏只给原因', async () => { + // 导入的附件没有 manifest 登记(`manifestAssetId: null`),画布把它归到「待归类」。 + const { manager, inserts, dispose } = await mountCanvas({ + categoryLabel: '待归类', + attachments: [ + { + fileName: 'raw-shot.png', + mediaType: 'image/png', + status: 'imported', + localPath: 'assets/raw-shot.png', + }, + ], + }); + const card = cardIn(manager, 'attachment:assets/raw-shot.png'); + expect(card).not.toBeNull(); + + fireEvent.click(card); + // 引用入口与「编辑 / 删除」同一条 `manifestAssetId` 判据:没登记就没有这枚按钮。 + expect(screen.queryByRole('button', { name: /^引用资源/ })).toBeNull(); + + fireEvent.pointerDown(card, { + pointerId: 74, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 74, + buttons: 1, + clientX: 1000, + clientY: 300, + }); + // 落点提示也说清原因,不承诺一次插不进去的引用。 + expect( + screen.getByText('这些素材还没登记为项目资源,不能 @ 引用'), + ).not.toBeNull(); + + fireEvent.pointerUp(card, { + pointerId: 74, + button: 0, + clientX: 1000, + clientY: 300, + }); + await settle(); + + expect( + screen.getByText('选中的素材都还没登记为项目资源,暂时不能 @ 引用'), + ).not.toBeNull(); + expect(inserts).toHaveLength(0); + expect(draftReferenceIds()).toEqual([]); + dispose(); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4aa91f9be..6c1a99cfb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 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`。 +- 边界:不采用「给 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`。 + ## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) - 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a6d0d4f..4e888481a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 画布「引用」死按钮:window 事件的消费者挂在一个只在另一条链路赋值的 ref 上 + +- **现象**(Issue 602):AGC 资源画布选中一张已登记素材,选中工具条点「引用」(图标 `@`、可见文案与 `title` 都是「引用」)没有任何反应——聊天输入框里不出现 `@素材名` 芯片,也没有任何提示。普通项目(`directProjectMode`)必现,立项策划项目(`planningStartMode`)复现不出来;把素材卡拖到对话栏的批量引用同样没反应。 +- **原因**:画布侧只 `dispatchResourceReferenceInsert` / `dispatchResourceReferenceInsertMany`,而这两个 window 事件的**唯一**消费者是 `App.tsx` 里的 `chatComposerRef.current?.insertReferences(...)`,`chatComposerRef` 又只赋给 `PlanningChatView`;2026-09-22 DirectProject 拆分引入的 `directProjectMode` 提前 return 让普通项目整段跳过后面的策划面渲染 → ref 恒为 `null`,可选链把整次调用静默吞掉。同一批合并冲突还把 2026-09-21 新加的 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 监听整段丢掉,批量引用连消费者都没有。 +- **处理(现行口径)**:`features/project-workspace/activeChatComposer.ts` 保存当前挂载的**那一个**输入区句柄(`registerActiveChatComposer` 返回带身份校验的注销函数 / `insertChatReferences`),`DirectProjectChatView` 与 `PlanningChatView` 挂载期间各自注册(两条链路互斥,同一时刻只有一个句柄);`App.tsx` 收敛为一处监听,单条与批量都走 `insertChatReferences`,返回 `false`(空批次 / 此刻没有输入区)时 dev 下 `console.warn`;`chatComposerRef` 只留给策划输入盒自己的 `getDraft` / `clear`。 +- **判据/取证**:`npm run test -- apps/ai-game-creator-shell/tests` 里的 `resourceCanvasChatReferenceDrop.test.tsx`、`appSurface/project-development.suite.ts`(工具条「引用」)、`appSurface/design-agent.suite.ts`(策划链路)都断言**真实输入盒草稿**里出现 `[data-resource-reference-id=""]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。 +- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。 + ## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 - **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 2591f9d63..d45b1efc0 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -1,9 +1,29 @@ # AGC 聊天素材引用 -更新时间:2026-09-23 +更新时间:2026-10-03 AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +## 画布引用落到哪份输入区:活跃聊天输入区注册表(2026-10-03) + +资源画布的「引用」按钮与「拖拽批量引用」都只做一件事:派发 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` 留一行线索(不再有可选链静默吞掉整次点击)。 +- `chatComposerRef` 只留给策划输入盒自己的提交(`getDraft` / `clear`),不再承担跨面板插入。 + +这是 2026-09-22 DirectProject 拆分后的回归修复(issue #602):当时 `composerRef={chatComposerRef}` 只剩策划面一处,而 `directProjectMode` 的提前 return 让普通项目永远走不到那条赋值,`chatComposerRef.current?.insertReferences(...)` 的可选链把整次调用静默丢掉;同一批合并冲突还把 2026-09-21 新加的批量监听整段丢了,拖拽批量引用连监听者都没有。两条现在都由上面这一处收口。 + +这条链路由以下用例守住(都渲染真实聊天面,断言草稿 DOM 而不是「事件被派发」): + +| 契约 | 用例 | +| --- | --- | +| 工具条「引用」(键盘 + 鼠标两条通路)落进 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` | +| 策划链路(`PlanningChatView`)不回归 | `apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts` 的「画布派发的「引用」落进策划输入盒草稿」 | + ## 引用来源由宿主注入(2026-09-22) `ResourceReferenceInput` 只接受宿主注入的一组「引用 provider」(`ReferenceProvider`,每种引用一个独立工厂): @@ -60,7 +80,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。 - 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。 -- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。 +- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),由 `App.tsx` 的监听一次性交给当前挂载的输入区(见文首「活跃聊天输入区注册表」),草稿只重建一次、插入顺序即拖动集合顺序。2026-09-22 的合并冲突曾把这条监听整段丢掉(事件无消费者),2026-10-03 修复时补回。 ## 附件进入正文(2026-09-22) @@ -83,7 +103,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; - 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节); -- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; +- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;未登记素材(`manifestAssetId: null`)不渲染这枚按钮,拖到对话栏时落点提示与提示条给出「还没登记为项目资源」的原因; +- 普通项目(DirectProject)与立项策划两条链路都由 `App.tsx` 那一处监听 + 活跃聊天输入区注册表把引用落进当前挂载的输入盒草稿(2026-10-03 修复,见文首); - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; - 提交请求携带 canonical user message item; - Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; -- 2.52.0 From f4ada5061246355dde75de658927e639e6efbc22 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:12:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E8=87=AA=E5=AE=A1=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=EF=BC=9A=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=8C=89=20ref=20=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E6=B3=A8=E5=86=8C=E3=80=81=E6=8F=92=E5=85=A5=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=AF=AD=E4=B9=89=E4=B8=8E=E5=A4=B1=E8=B4=A5=E7=95=99?= =?UTF-8?q?=E7=97=95=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) -- 2.52.0 From 3f18e3dc77e562ff19076526c89249fee1ef11b9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 00:38:06 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=8C=89=E7=8B=AC=E7=AB=8B=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E7=9A=84=E4=B8=89=E6=9D=A1=20P2=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E7=A9=BA=E6=89=B9=E6=AC=A1=E4=B8=8D=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E3=80=81=E9=87=8D=E5=A4=8D=E6=B3=A8=E5=86=8C=E7=95=99=E7=BA=BF?= =?UTF-8?q?=E7=B4=A2=E3=80=81=E6=96=87=E6=A1=A3=E4=B8=8E=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx 的监听处对空批次直接返回:空批次没有要插的东西,不能报成「没有可用的聊天输入区」(原因指向错了方向) activeChatComposer.registerActiveChatComposer 增加重复注册检测:dev 下 console.warn 指出后注册者顶替了前者,不改运行时语义(仍然后注册者接管、身份校验照旧兜住乱序卸载) tests/activeChatComposer.test.ts 补两条:空批次不打扰已挂载输入区;重复注册出现告警且乱序注销清不掉更新的句柄 tests/resourceCanvasChatReferenceDrop.test.tsx 补一条:直接构造空批次引用事件时不产生「没有可用的聊天输入区」告警 decision-log 的「影响范围」补记 tests/activeChatComposer.test.ts,「验证」换成合并 master 后的实跑数字;功能说明的用例表补两行 反向证伪:去掉空批次短路、去掉重复注册告警后,上面两条新用例各红一处 --- apps/ai-game-creator-shell/src/App.tsx | 6 ++- .../project-workspace/activeChatComposer.ts | 11 +++++ .../tests/activeChatComposer.test.ts | 43 ++++++++++++++++++- .../resourceCanvasChatReferenceDrop.test.tsx | 24 +++++++++++ .../shared-memory/decision-log.md | 6 +-- .../【功能说明】AGC聊天素材引用-2026-09-08.md | 3 +- 6 files changed, 86 insertions(+), 7 deletions(-) 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) -- 2.52.0