修复 AGC 画布素材卡「引用」无消费者:新增活跃聊天输入区注册表
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m44s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / Repository checks (pull_request) Failing after 30s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m10s
Project CI / Frontend tests (pull_request) Successful in 2m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m16s
Project CI / Native shell tests (pull_request) Successful in 5m58s

新增 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
This commit is contained in:
2026-10-03 19:43:18 +08:00
parent 8aee5db84e
commit a22e8ee447
11 changed files with 431 additions and 66 deletions
+37 -3
View File
@@ -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<ResourceReferenceInsertEventDetail>)
.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<ResourceReferenceInsertManyEventDetail>
).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,
);
};
}, []);
/**
@@ -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;
}
@@ -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<DirectProjectComposerHandle | null>(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 (
<section
className="project-chat-surface is-direct-codex"
@@ -252,6 +270,7 @@ export function DirectProjectChatView({
</div>
) : null}
<DirectProjectComposer
ref={composerHandleRef}
assets={assets}
{...(versions ? { versions } : {})}
projectPath={projectPath}
@@ -1,10 +1,18 @@
import { ArrowUp, AtSign } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
type Ref,
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type {
GameCreationAppAssetManifestEntry,
GameIterationVersion,
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ActiveChatComposerHandle } from '../../../../../features/project-workspace/activeChatComposer';
import {
ConversationModelSelect,
type ConversationModelSelectHandle,
@@ -40,6 +48,12 @@ function draftAttachmentCount(draft: { content: UserContentPart[] }) {
).length;
}
/**
* 输入区对外的命令式出口:就是「活跃聊天输入区」注册表要的那两件事
* (见 `features/project-workspace/activeChatComposer.ts`)。
*/
export type DirectProjectComposerHandle = ActiveChatComposerHandle;
/**
* DirectProject 输入区:待发消息 chip、附件、`@` 引用输入框、模型/推理选择和发送/终止。
*
@@ -62,6 +76,7 @@ export function DirectProjectComposer({
onReferencePickerOpen,
onSubmit,
onUploadFiles,
ref,
}: {
assets: GameCreationAppAssetManifestEntry[];
versions?: GameIterationVersion[];
@@ -107,6 +122,11 @@ export function DirectProjectComposer({
files: readonly File[],
draftAttachmentCount: number,
) => Promise<DirectCodexTurnAttachment[]>;
/**
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
*/
ref?: Ref<DirectProjectComposerHandle>;
}) {
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
const composerRootRef = useRef<HTMLDivElement | null>(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 = (
@@ -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<HTMLInputElement>(null);
/*
策划输入盒也进「活跃聊天输入区」注册表:画布的引用事件消费者只有 `App.tsx` 一处,
它不关心当前挂哪条链路。句柄按 ref 转发(`composerRef` 指向的是输入区自己那份
可变句柄),注册的那个包装对象因此永远读到最新值。
*/
useEffect(() => {
if (!composerRef) return;
return registerActiveChatComposer({
insertReferences: (references) =>
composerRef.current?.insertReferences([...references]),
focus: () => composerRef.current?.focus(),
});
}, [composerRef]);
/*
策划输入盒的引用来源:**只注入资源与两个静默 provider**。
@@ -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');
});
}
@@ -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<string, unknown>) => {
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<HTMLElement>(
'form.project-chat-composer [data-resource-reference-id]',
),
).map((chip) => chip.getAttribute('data-resource-reference-id'));
const composerEditor = () =>
document.querySelector<HTMLElement>(
'form.project-chat-composer [contenteditable="true"][aria-label="陶泥儿对话内容"]',
)!;
// 芯片显示名走聊天自己的 manifest 口径(文件名去扩展名),与画布卡片上的文件名不同。
const firstChipLabel = () =>
document.querySelector<HTMLElement>(
'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 () => {
@@ -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<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
// 聊天面是**真的** DirectProject:它自己的清单 / 订阅 / 历史 IPC 全部由这份运行时夹具回答,
// 画布命令再叠在它上面。夹具与 `chat-composer.suite` 同一套,不另造聊天替身。
const chatHarness = createProjectChatRuntimeHarness({
projectPath: CANVAS_PROJECT_PATH,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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`。桩接不住引用事件,链路断点正好在「谁把它落进草稿」,
// 所以这里不能再拿 `<div>项目总控</div>` 顶替。
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<HTMLElement>('.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<HTMLElement>(
'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();
});
});
@@ -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` 原先不接受入口栏目。
@@ -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="<assetId>"]`,不再是「事件被派发」;临时去掉注册调用后这三条会红,证明用例钉的是真链路。详见 [`【功能说明】AGC聊天素材引用-2026-09-08`](../../【功能说明】AGC聊天素材引用-2026-09-08.md) 文首一节。
- **边界**:只要还保留「window 事件 + 模块外 ref 约定」这种形态,新增聊天面就必须一起进注册表;更彻底的形态是画布与聊天的共同宿主(`ProjectDevelopmentView`)用 context 下发插入能力,注册表语义与它一致,将来换实现不必动画布。
## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目
- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。
@@ -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;