资源卡拖到对话栏实现批量 @ 引用

- 新增落点模型 resourceCardReferenceDropModel:对话栏矩形量取(0 x 0 判成没有落点,避免画布内拖动被误判)、命中判据、引用构造与落点提示文案。
- 引用构造与工具条「引用」按钮逐字一致(只认已登记 manifest 素材、source 为 resource-card、kind/分类/标签取自资源投影),一条都构造不出来时用提示条说明原因。
- 拖动中指针落到对话栏上即从排版切到引用:卡片不再跟指针走,改铺虚线落点浮层并提示「松手即可 @ 引用 N 项素材」;拖回画布内松手仍是原来的排版语义。
- 批量范围与拖动位移同一个集合(多选整批、单选只引用按住的那张);新增批量事件 RESOURCE_REFERENCE_INSERT_MANY_EVENT,App 侧一次插入整批并只聚焦一次,空批次不派发。
- 对话栏挂 ref(不 document.querySelector)、落点浮层按对话栏当时的屏幕矩形铺在根节点上,不改对话栏自身的定位与排版。
- 测试:新增落点模型用例 5 条与画布集成用例 4 条(单卡/多选/拖回画布/pointercancel),resourceReferenceInput 新增批量事件与一次插入整批 chip 两条,共 39 条定向全绿。
- 文档:功能说明新增「拖拽引用」一节与完成清单条目、PRD 更新时间、decision-log 新增 2026-09-21 条目。
This commit is contained in:
2026-09-21 03:37:43 +08:00
parent d56b5ff15a
commit 9639a080c6
11 changed files with 1023 additions and 29 deletions
+27 -1
View File
@@ -283,7 +283,9 @@ import type {
import {
chatComposerDraftToDirectCodexUserItem,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertEventDetail,
type ResourceReferenceInsertManyEventDetail,
} from './features/project-workspace/resourceReferences';
import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView';
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
@@ -3633,15 +3635,39 @@ export function App({
chatComposerRef.current?.insertReferences([detail.reference]);
chatComposerRef.current?.focus();
};
/**
* @
*
* 稿
*
*/
const handleResourceReferenceInsertMany = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
const references = detail?.references ?? [];
if (references.length === 0) return;
chatComposerRef.current?.insertReferences(references);
chatComposerRef.current?.focus();
};
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,
);
};
}, []);
function prepareProjectAssetRegisterDraft(localPath: string) {
@@ -74,6 +74,31 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) {
);
}
/**
* 一次插入多条引用的事件(画布拖拽批量 @ 引用用)。
*
* 与单条事件同一条链路、同一个消费点,只是把「N 条」合成一次派发:逐条派发会让草稿重建
* N 次、聚焦 N 次,而且插入顺序只能靠事件顺序兜着。批次为空时什么都不派发(不开一次空事务)。
*/
export const RESOURCE_REFERENCE_INSERT_MANY_EVENT =
'agc-resource-reference-insert-many';
export type ResourceReferenceInsertManyEventDetail = {
references: ChatReference[];
};
export function dispatchResourceReferenceInsertMany(
references: ChatReference[],
) {
if (typeof window === 'undefined' || references.length === 0) return;
window.dispatchEvent(
new CustomEvent<ResourceReferenceInsertManyEventDetail>(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
{ detail: { references } },
),
);
}
/**
* `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。
*
+111 -22
View File
@@ -1070,7 +1070,11 @@ textarea {
padding: 10px 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent);
background: color-mix(
in srgb,
var(--platform-subpanel-fill) 94%,
transparent
);
box-shadow: 0 8px 24px rgb(31 24 16 / 8%);
}
@@ -5904,6 +5908,38 @@ iframe.preview-frame {
box-shadow: var(--platform-nav-active-shadow);
}
/*
* 拖动素材到对话的落点提示按对话栏当时的屏幕矩形铺一层虚线描边 + 一枚说明 chip
*
* 纯只读浮层`pointer-events: none`不接管命中也不改对话栏自身的排版与定位拖动是指针
* 捕获的命中判定本来就在画布那侧算这里只负责把"松手会发生什么"说出来
*/
.game-workbench-chat-reference-drop {
position: fixed;
z-index: 60;
display: grid;
place-items: start center;
padding-top: 1rem;
border: 2px dashed var(--platform-accent);
border-radius: 12px;
background: color-mix(in srgb, var(--platform-accent) 8%, transparent);
pointer-events: none;
}
.game-workbench-chat-reference-drop-chip {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 0 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 999px;
background: var(--platform-subpanel-fill);
box-shadow: var(--platform-panel-shadow);
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
}
@@ -7557,7 +7593,6 @@ iframe.preview-frame {
padding-right: 50px;
}
/* 替换血缘标注本次会话内有效源素材卡已被 替换/ 替换素材卡替换自
卡片底部整条是卡面名称`.game-resource-card-name`所以血缘角标压在名称条之上
右下角仍是媒体播放钮最大宽度按右侧让出播放钮的宽度
@@ -10423,7 +10458,9 @@ button.design-workspace-tree__entry:hover,
overflow: visible;
}
.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface {
.game-workbench-layout--design
.game-workbench-chat
.project-supervisor-surface {
grid-row: 2;
}
@@ -10781,8 +10818,7 @@ button.design-workspace-tree__entry:hover,
这样消息内容 / 顶栏 / 输入盒三者共用同一个基准值不会再出现
消息内缩 16px输入盒只有 10px这种左右不齐或不一致的上/下间距
direct-codex 的面板仍走上面那条 `padding: 10px` */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex {
.game-workbench-chat .project-supervisor-surface.is-direct-codex {
padding: 0;
}
@@ -10921,7 +10957,9 @@ button.design-workspace-tree__entry:hover,
菜单跑到**整个 composer 上方**和触发钮之间隔着整个输入区输入区一变高多行
引用 chipAI 润色菜单与提示就跟着往上飘看起来就是"编辑框把弹层挤开了"
`relative` 不会把它移出控制排`right/bottom: auto` 仍在原位只补回锚点 */
.game-workbench-layout--design .project-supervisor-composer-controls .conversation-model-select,
.game-workbench-layout--design
.project-supervisor-composer-controls
.conversation-model-select,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10957,7 +10995,9 @@ button.design-workspace-tree__entry:hover,
/* 控制排三只方钮`+` 附件 / `@` 引用 / 发送共用一套尺寸发送钮单独加圆角与主色
见下面两条规则 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-layout--design
.project-supervisor-composer-controls
.project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -11021,7 +11061,9 @@ button.design-workspace-tree__entry:hover,
}
/* 发送钮是圆形主色块(Codex 观感):直径与左右两只方钮同档,圆角收到 999px。 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-layout--design
.project-supervisor-composer-controls
.project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -12253,7 +12295,8 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
> * + * {
> *
+ * {
margin-top: 14px;
}
@@ -12310,14 +12353,16 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] > summary {
details[data-testid='live-reasoning']
> summary {
cursor: pointer;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
details[data-testid='live-reasoning'] pre {
details[data-testid='live-reasoning']
pre {
margin: 6px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -12330,14 +12375,33 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message
:where(p, h1, h2, h3, h4, h5, h6, ul, ol, li, blockquote, pre, code, strong, em, th, td) {
:where(
p,
h1,
h2,
h3,
h4,
h5,
h6,
ul,
ol,
li,
blockquote,
pre,
code,
strong,
em,
th,
td
) {
color: inherit;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message p {
.message
p {
opacity: 1;
}
@@ -12361,19 +12425,23 @@ button.design-workspace-tree__entry:hover,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message p,
.message
p,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message ul,
.message
ul,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message ol,
.message
ol,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-message-list
.message li {
.message
li {
line-height: 1.5 !important;
}
@@ -12429,7 +12497,11 @@ button.design-workspace-tree__entry:hover,
.message-turn-process
> summary
> .agent-process-summary
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) {
:is(
.agent-process-summary-icon,
.agent-process-summary-meta,
.agent-process-summary-chevron
) {
color: inherit;
}
@@ -12501,8 +12573,14 @@ button.design-workspace-tree__entry:hover,
}
/* chevron 旋转只有一处来源:折叠态不旋转,展开态 rotate(180deg)(120ms 过渡内截图会看到中间角度)。 */
.message-turn-process > summary .agent-process-summary .agent-process-summary-chevron,
.design-agent-reasoning > summary .agent-process-summary .agent-process-summary-chevron {
.message-turn-process
> summary
.agent-process-summary
.agent-process-summary-chevron,
.design-agent-reasoning
> summary
.agent-process-summary
.agent-process-summary-chevron {
transform: none;
}
@@ -12559,10 +12637,21 @@ details.design-agent-reasoning[open]
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group[data-has-failure='true']
> .agent-tool-call-group-head
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron),
:is(
.agent-process-summary-icon,
.agent-process-summary-meta,
.agent-process-summary-chevron
),
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.agent-tool-call-group-row[data-status='failed']
:is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) {
:is(
.agent-tool-call-row-head,
.agent-tool-call-row-text,
.agent-tool-call-row-status,
.agent-tool-call-row-icon,
.agent-tool-call-row-duration,
.agent-tool-call-row-chevron
) {
color: var(--platform-button-danger-text, #a6402f);
}
@@ -117,7 +117,9 @@ import { ResourceReferenceInput } from '../../features/project-workspace/Resourc
import {
type ChatComposerDraft,
type ChatReference,
dedupeChatReferences,
dispatchResourceReferenceInsert,
dispatchResourceReferenceInsertMany,
isResourceReferenceOverlayTarget,
resolveActiveIterationVersion,
resourceReferenceCategoryLabel,
@@ -344,6 +346,13 @@ import {
projectResourcePathExtension,
projectResourceStructuredPreviewText,
} from './resourceCardPreviewModel';
import {
resourceCardReferenceDropContains,
resourceCardReferenceDropHintLabel,
type ResourceCardReferenceDropRect,
resourceCardReferenceDropRect,
resourceCardReferenceDropReferences,
} from './resourceCardReferenceDropModel';
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
import {
EMPTY_PROJECT_RESOURCE_GRAPH,
@@ -2365,6 +2374,24 @@ export default function ProjectDevelopmentView({
} | null>(null);
const resourceDependencyOverlayRef =
useRef<ResourceDependencyOverlayHandle>(null);
/**
* Agent `.game-workbench-chat`
*
* ****pointermove
* ref `document.querySelector` UI
*
*/
const workbenchChatRef = useRef<HTMLElement | null>(null);
/**
* `null` =
*
*
* `count` `rect`
*/
const [resourceCardReferenceDrop, setResourceCardReferenceDrop] = useState<{
count: number;
rect: ResourceCardReferenceDropRect;
} | null>(null);
const skipNextResourceCardClickRef = useRef<string | null>(null);
const skipNextResourceCardClickTimerRef = useRef<number | null>(null);
/**
@@ -4179,6 +4206,8 @@ export default function ProjectDevelopmentView({
}
resourceCardDragRef.current = null;
setResourceCardDragPreview(null);
// 取消也要把「拖到对话」的落点提示收干净:它和拖动是同一个生命周期。
setResourceCardReferenceDrop(null);
resourceDependencyOverlayRef.current?.clearDragPreview();
clearSkippedResourceCardClick();
}, [clearSkippedResourceCardClick]);
@@ -5830,6 +5859,27 @@ export default function ProjectDevelopmentView({
[],
);
/**
* Agent
*
* pointermove pointerup
* @ N
*/
const resolveResourceCardReferenceDrop = useCallback(
(clientX: number, clientY: number, movedResourceIds: readonly string[]) => {
const rect = resourceCardReferenceDropRect(workbenchChatRef.current);
if (!rect || !resourceCardReferenceDropContains(rect, clientX, clientY)) {
return null;
}
return {
count: resourceCardReferenceDropReferences(movedResourceIds, resources)
.length,
rect,
};
},
[resources],
);
const handleResourceCardPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, resource: ProjectResource) => {
if (event.button !== 0) {
@@ -5927,6 +5977,23 @@ export default function ProjectDevelopmentView({
skipNextResourceCardClickRef.current = drag.resourceId;
}
drag.changed = true;
/**
*
*
*
* pointermove
*/
const referenceDrop = resolveResourceCardReferenceDrop(
event.clientX,
event.clientY,
drag.moves.map((move) => move.resourceId),
);
setResourceCardReferenceDrop(referenceDrop);
if (referenceDrop) {
setResourceCardDragPreview(null);
resourceDependencyOverlayRef.current?.clearDragPreview();
return;
}
// 位移只有一份:整张选择集按同一个世界坐标位移走,渲染时每张卡各自加上它(见
// `renderResourceBookCard`),落盘时再按同一尺度换成栏目内局部坐标。
const scaledDeltaX = deltaX / drag.startScale;
@@ -5945,7 +6012,7 @@ export default function ProjectDevelopmentView({
y: (primary?.startY ?? 0) + scaledDeltaY,
});
},
[],
[resolveResourceCardReferenceDrop],
);
const finishResourceCardPointerDrag = useCallback(
@@ -5972,6 +6039,38 @@ export default function ProjectDevelopmentView({
deferSkippedResourceCardClickCleanup(drag.resourceId);
}
setResourceCardDragPreview(null);
/**
* @
*
*
* `drag.moves`
*
*/
const referenceDrop =
commit && drag.changed
? resolveResourceCardReferenceDrop(
event.clientX,
event.clientY,
drag.moves.map((move) => move.resourceId),
)
: null;
setResourceCardReferenceDrop(null);
if (referenceDrop) {
const references = dedupeChatReferences(
resourceCardReferenceDropReferences(
drag.moves.map((move) => move.resourceId),
resources,
),
);
if (references.length > 0) {
dispatchResourceReferenceInsertMany(references);
} else {
setResourceWorkbenchNotice(
'选中的素材都还没登记为项目资源,暂时不能 @ 引用',
);
}
return;
}
// 取消手势(pointercancel / 捕获丢失 / 窗口失焦):拖动期间从未改写坐标,丢掉预览即
// 回到拖动前的位置;已选中的多选保持不动,不在这里清选择。
if (!commit || !drag.changed) {
@@ -6014,7 +6113,9 @@ export default function ProjectDevelopmentView({
activeResourceLayout,
clearSkippedResourceCardClick,
deferSkippedResourceCardClickCleanup,
resolveResourceCardReferenceDrop,
resourceCategoryScopeKey,
resources,
setResourceCanvasHistory,
],
);
@@ -10724,12 +10825,41 @@ export default function ProjectDevelopmentView({
</section>
{!uiEditorRoute ? (
<aside className="game-workbench-chat" aria-label="陶泥儿 Agent 对话">
<aside
ref={workbenchChatRef}
className="game-workbench-chat"
aria-label="陶泥儿 Agent 对话"
>
{supervisorSurface}
</aside>
) : null}
</div>
{/*
`pointer-events: none`
**** `.game-workbench-layout`
`overflow`
*/}
{resourceCardReferenceDrop ? (
<div
className="game-workbench-chat-reference-drop"
style={{
left: `${resourceCardReferenceDrop.rect.left}px`,
top: `${resourceCardReferenceDrop.rect.top}px`,
width: `${resourceCardReferenceDrop.rect.width}px`,
height: `${resourceCardReferenceDrop.rect.height}px`,
}}
role="status"
aria-live="polite"
>
<span className="game-workbench-chat-reference-drop-chip">
{resourceCardReferenceDropHintLabel(
resourceCardReferenceDrop.count,
)}
</span>
</div>
) : null}
{professionalDagVisible && !uiEditorRoute ? (
<footer className="game-agent-dock" aria-label="子 Agent 状态栏">
{agentSummaries.map((agent) => (
@@ -0,0 +1,101 @@
import { parseGameCreationAppAssetKind } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceReference } from '../../features/project-workspace/resourceReferences';
import {
type ProjectResource,
projectResourceAssetCategory,
} from './resourceProjectionModel';
/** 对话栏在屏幕坐标里的矩形(拖动落点的唯一判据)。 */
export type ResourceCardReferenceDropRect = {
left: number;
top: number;
width: number;
height: number;
};
/**
* 量对话栏的落点矩形:没有元素、或元素还没量出尺寸(0 x 0,jsdom 与折叠态)时返回 null。
*
* 零面积矩形必须当「没有落点」:命中测试是 contains,一个 0 x 0 的矩形会把任何点都判成
* 在里面,于是画布内正常拖动松手也会被误判成「拖到对话」,排版写入被整段跳过。
*/
export function resourceCardReferenceDropRect(
element: Element | null,
): ResourceCardReferenceDropRect | null {
if (!element) return null;
const rect = element.getBoundingClientRect();
if (!(rect.width > 0) || !(rect.height > 0)) {
return null;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
export function resourceCardReferenceDropContains(
rect: ResourceCardReferenceDropRect,
clientX: number,
clientY: number,
) {
return (
clientX >= rect.left &&
clientX <= rect.left + rect.width &&
clientY >= rect.top &&
clientY <= rect.top + rect.height
);
}
/**
* 拖到对话要插入的引用。
*
* 与资源卡工具条那枚「引用」按钮**同一口径**:只认已登记到 manifest 的素材
* manifestAssetId),来源标 resource-cardkind / 分类 / 标签全部取自资源投影,
* 于是同一个素材无论从按钮还是从拖拽进来,草稿里的引用是同一枚(去重键也跟着走)。
* 未登记的卡(任务产物、附件、Agent 回执等)在这里被过滤掉,调用方按「一条都没有」给原因。
*
* 顺序即插入顺序:movedResourceIds 是本次拖动真正参与位移的那批(第 0 项是被按住的那张),
* 多选后整批引用、单选只引用按住的那张,与拖动位移的语义同源。
*/
export function resourceCardReferenceDropReferences(
movedResourceIds: readonly string[],
resources: readonly ProjectResource[],
): ResourceReference[] {
if (movedResourceIds.length === 0) {
return [];
}
const byId = new Map(resources.map((resource) => [resource.id, resource]));
const references: ResourceReference[] = [];
for (const resourceId of movedResourceIds) {
const resource = byId.get(resourceId);
if (!resource?.manifestAssetId) {
continue;
}
references.push({
type: 'resource',
resourceId: resource.manifestAssetId,
kind: parseGameCreationAppAssetKind(
resource.subtype,
'project-development.resource-card-reference-drop',
),
mediaType: resource.mediaType,
label: resource.label,
category: projectResourceAssetCategory(resource),
tags: resource.assetTags ?? [],
source: 'resource-card',
});
}
return references;
}
/**
* 落点提示文案。条数为 0 时不写「引用 0 项」,而是把「为什么一条也引用不了」说清楚:
* 用户此时已经拖到对话上了,静默什么都不发生会被读成功能坏了。
*/
export function resourceCardReferenceDropHintLabel(count: number) {
return count > 0
? `松手即可 @ 引用 ${count} 项素材`
: '这些素材还没登记为项目资源,不能 @ 引用';
}
@@ -0,0 +1,398 @@
/** @vitest-environment jsdom */
import { act, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
type ChatReference,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
type ResourceReferenceInsertManyEventDetail,
} from '../src/features/project-workspace/resourceReferences';
import ProjectDevelopmentView from '../src/view/project-development';
import {
createGameCreationAppManifest,
fireEvent,
React,
render,
screen,
} from './appSurface/harness';
/**
* 「拖动素材到对话 = 批量 @ 引用」的行为级验收。
*
* 三条口径:
* 1. 把资源卡拖到 Agent 对话栏上松手 → 按这次拖动真正参与位移的那批(多选整批)插入引用,
* 一条布局坐标都不写(拖到对话不是排版)。
* 2. 只认已登记到 manifest 的素材,引用身份、来源标记与资源卡工具条那枚「引用」按钮逐字一致。
* 3. 画布内的拖动语义一行不改:照旧提交手动坐标、照旧不派发引用事件。
*/
type AssetFixture = GameCreationAppAssetManifestEntry;
function characterAsset(id: string, fileName: string): AssetFixture {
return {
id,
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: `assets/${fileName}`,
source: { kind: 'generated', resourceId: `${id}-resource` },
};
}
function manifestFor(
projectId: string,
assets: AssetFixture[],
): GameCreationAppManifest {
return {
...createGameCreationAppManifest(projectId, `${projectId} 项目`),
assets: assets.map((asset) => structuredClone(asset)),
};
}
function resourceGraphFor(
resources: Array<{ resourceId: string }> | undefined,
) {
const resourceIds = (resources ?? []).map((resource) => resource.resourceId);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
type LayoutWrite = {
mode: string;
positions: ProjectResourceCanvasPosition[];
};
function installTauri(): { layoutWrites: LayoutWrite[] } {
const layoutWrites: LayoutWrite[] = [];
const persisted = new Map<string, ProjectResourceCanvasPosition[]>();
const revisions = new Map<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: 1 };
}
if (command === 'read_local_project_resource_graph') {
return resourceGraphFor(
args?.resources as Array<{ resourceId: string }> | undefined,
);
}
if (command === 'read_local_project_resource_canvas_layout') {
const key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`;
return {
schemaVersion: 'game-creator-resource-layout.v1',
// 前端只传 projectPath / mode;读回来的 projectId 必须与当前 scope 一致,否则这份布局
// 会被判成不属于当前项目(写入整段停摆)。
projectId: 'chat-reference-drop',
mode: args?.mode,
revision: revisions.get(key) ?? 0,
positions: structuredClone(persisted.get(key) ?? []),
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const key = `${String(args?.projectPath ?? '')}|${String(args?.mode ?? '')}`;
const positions = structuredClone(
(args?.positions ?? []) as ProjectResourceCanvasPosition[],
);
persisted.set(key, positions);
revisions.set(key, Number(args?.expectedRevision ?? 0) + 1);
layoutWrites.push({ mode: String(args?.mode ?? ''), positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: String(args?.expectedProjectId ?? ''),
mode: String(args?.mode ?? ''),
revision: revisions.get(key)!,
positions,
updatedAt: 1,
},
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'list_local_project_asset_generations') {
return [];
}
return undefined;
},
);
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
return { layoutWrites };
}
async function settle() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
const CHAT_RECT = {
left: 900,
top: 40,
right: 1200,
bottom: 640,
width: 300,
height: 600,
x: 900,
y: 40,
} as DOMRect;
function collectReferenceInserts() {
const inserts: ChatReference[][] = [];
const listener = (event: Event) => {
const detail = (
event as CustomEvent<ResourceReferenceInsertManyEventDetail>
).detail;
inserts.push(detail?.references ?? []);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
return {
inserts,
dispose: () =>
window.removeEventListener(
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
listener,
),
};
}
async function mountCanvas() {
const tauri = installTauri();
const manifest = manifestFor('chat-reference-drop', [
characterAsset('drop-a', 'a.png'),
characterAsset('drop-b', 'b.png'),
]);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/chat-reference-drop',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await settle();
const manager = document.querySelector<HTMLElement>(
'.game-resource-book-manager',
)!;
vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
toJSON: () => ({}),
} as DOMRect);
act(() => window.dispatchEvent(new Event('resize')));
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
await settle();
const chat = document.querySelector<HTMLElement>('.game-workbench-chat')!;
vi.spyOn(chat, 'getBoundingClientRect').mockReturnValue(CHAT_RECT);
const inserts = collectReferenceInserts();
return { chat, manager, tauri, ...inserts };
}
function cardIn(manager: HTMLElement, resourceId: string) {
return manager.querySelector<HTMLElement>(
`[data-resource-id="${resourceId}"]`,
)!;
}
afterEach(() => {
delete window.__TAURI__;
vi.restoreAllMocks();
});
describe('拖动素材到对话:批量 @ 引用', () => {
it('拖到对话栏松手:插入引用、不写布局坐标、提示条只在落点上出现', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
expect(card).not.toBeNull();
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 70,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 70,
buttons: 1,
clientX: 1000,
clientY: 300,
});
// 落在对话上:提示条按对话栏矩形铺,卡片不再跟着指针走(不写成"把素材拖出项目")。
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
expect(card.className).not.toContain('is-dragging');
fireEvent.pointerUp(card, {
pointerId: 70,
button: 0,
clientX: 1000,
clientY: 300,
});
await settle();
expect(inserts).toHaveLength(1);
expect(inserts[0]).toHaveLength(1);
expect(inserts[0]![0]).toMatchObject({
type: 'resource',
resourceId: 'drop-a',
kind: 'character',
source: 'resource-card',
});
// 拖到对话不是排版:一条坐标都不写。
expect(tauri.layoutWrites).toHaveLength(writesBefore);
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
dispose();
});
it('多选后拖任意一张:整批一起 @ 引用', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const cardA = cardIn(manager, 'asset:drop-a');
const cardB = cardIn(manager, 'asset:drop-b');
fireEvent.click(cardA);
fireEvent.click(cardB, { shiftKey: true });
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(cardB, {
pointerId: 71,
button: 0,
clientX: 200,
clientY: 200,
});
fireEvent.pointerMove(cardB, {
pointerId: 71,
buttons: 1,
clientX: 1000,
clientY: 320,
});
expect(screen.getByText('松手即可 @ 引用 2 项素材')).not.toBeNull();
fireEvent.pointerUp(cardB, {
pointerId: 71,
button: 0,
clientX: 1000,
clientY: 320,
});
await settle();
expect(inserts).toHaveLength(1);
expect(
new Set(inserts[0]!.map((reference) => reference.resourceId)),
).toEqual(new Set(['drop-a', 'drop-b']));
expect(tauri.layoutWrites).toHaveLength(writesBefore);
dispose();
});
it('指针移回画布后松手:照旧写手动坐标,不派发任何引用', async () => {
const { manager, tauri, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
const writesBefore = tauri.layoutWrites.length;
fireEvent.pointerDown(card, {
pointerId: 72,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 72,
buttons: 1,
clientX: 1000,
clientY: 300,
});
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
// 又拖回画布里:落点提示收起,拖动回到排版语义。
fireEvent.pointerMove(card, {
pointerId: 72,
buttons: 1,
clientX: 260,
clientY: 200,
});
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
fireEvent.pointerUp(card, {
pointerId: 72,
button: 0,
clientX: 260,
clientY: 200,
});
await settle();
expect(inserts).toHaveLength(0);
await waitFor(() =>
expect(tauri.layoutWrites.length).toBeGreaterThan(writesBefore),
);
expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:drop-a',
manuallyPlaced: true,
}),
]),
);
dispose();
});
it('拖动取消(pointercancel)时落点提示与引用一起收干净', async () => {
const { manager, inserts, dispose } = await mountCanvas();
const card = cardIn(manager, 'asset:drop-a');
fireEvent.pointerDown(card, {
pointerId: 73,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(card, {
pointerId: 73,
buttons: 1,
clientX: 1000,
clientY: 300,
});
expect(screen.getByText('松手即可 @ 引用 1 项素材')).not.toBeNull();
fireEvent.pointerCancel(card, { pointerId: 73 });
await settle();
expect(screen.queryByText('松手即可 @ 引用 1 项素材')).toBeNull();
expect(inserts).toHaveLength(0);
dispose();
});
});
@@ -0,0 +1,145 @@
import { describe, expect, test } from 'vitest';
import {
resourceCardReferenceDropContains,
resourceCardReferenceDropHintLabel,
resourceCardReferenceDropRect,
resourceCardReferenceDropReferences,
} from '../src/view/project-development/resourceCardReferenceDropModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(
patch: Partial<ProjectResource> & { id: string },
): ProjectResource {
return {
category: 'character',
subtype: 'character',
label: patch.id,
path: `assets/${patch.id}.png`,
mediaType: 'image/png',
sourceLabel: '本地上传',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...patch,
};
}
function elementWithRect(rect: {
left: number;
top: number;
width: number;
height: number;
}): Element {
return {
getBoundingClientRect: () =>
({
...rect,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON: () => ({}),
}) as DOMRect,
} as unknown as Element;
}
describe('拖动素材到对话:落点与引用构造', () => {
test('没有元素或没量出尺寸都不算落点', () => {
expect(resourceCardReferenceDropRect(null)).toBeNull();
// 0 x 0 必须当「没有落点」:否则任何点都会被判成落在里面,画布内拖动松手也会被当成拖到对话。
expect(
resourceCardReferenceDropRect(
elementWithRect({ left: 0, top: 0, width: 0, height: 0 }),
),
).toBeNull();
expect(
resourceCardReferenceDropRect(
elementWithRect({ left: 10, top: 20, width: 400, height: 600 }),
),
).toEqual({ left: 10, top: 20, width: 400, height: 600 });
});
test('命中判据按矩形边界闭区间算', () => {
const rect = { left: 800, top: 0, width: 400, height: 600 };
expect(resourceCardReferenceDropContains(rect, 800, 0)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 1200, 600)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 1000, 300)).toBe(true);
expect(resourceCardReferenceDropContains(rect, 799, 300)).toBe(false);
expect(resourceCardReferenceDropContains(rect, 1000, 601)).toBe(false);
});
test('只把已登记的 manifest 素材折成引用,顺序与拖动集合一致', () => {
const references = resourceCardReferenceDropReferences(
['asset:registered-b', 'featured:not-registered', 'asset:registered-a'],
[
resource({
id: 'asset:registered-b',
manifestAssetId: 'registered-b',
subtype: 'character',
label: '主角立绘',
mediaType: 'image/png',
assetCategory: 'character',
assetTags: ['主角', '立绘'],
}),
resource({ id: 'featured:not-registered', label: '任务产物' }),
resource({
id: 'asset:registered-a',
manifestAssetId: 'registered-a',
subtype: 'background-music',
label: '主城 BGM',
mediaType: 'audio/mpeg',
assetCategory: 'audio',
}),
],
);
expect(references.map((reference) => reference.resourceId)).toEqual([
'registered-b',
'registered-a',
]);
expect(references[0]).toMatchObject({
type: 'resource',
// 引用认的是 manifest 资产 id,不是画布投影 id。
resourceId: 'registered-b',
kind: 'character',
label: '主角立绘',
mediaType: 'image/png',
category: 'character',
tags: ['主角', '立绘'],
// 与资源卡工具条那枚「引用」按钮同一个来源标记:同一素材两处进来是同一枚引用。
source: 'resource-card',
});
expect(references[1]).toMatchObject({
resourceId: 'registered-a',
kind: 'background-music',
category: 'audio',
});
});
test('拖动集合里一条登记素材都没有时返回空数组', () => {
expect(
resourceCardReferenceDropReferences(
['featured:task'],
[resource({ id: 'featured:task' })],
),
).toEqual([]);
expect(resourceCardReferenceDropReferences([], [])).toEqual([]);
});
test('提示文案把条数说清楚,0 条时说原因而不是「引用 0 项」', () => {
expect(resourceCardReferenceDropHintLabel(1)).toBe(
'松手即可 @ 引用 1 项素材',
);
expect(resourceCardReferenceDropHintLabel(3)).toBe(
'松手即可 @ 引用 3 项素材',
);
expect(resourceCardReferenceDropHintLabel(0)).toBe(
'这些素材还没登记为项目资源,不能 @ 引用',
);
});
});
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
@@ -9,7 +10,7 @@ import {
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical';
import { StrictMode, useState } from 'react';
import { createRef, StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type {
@@ -20,7 +21,10 @@ import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
@@ -28,9 +32,11 @@ import {
currentIterationVersionAssets,
dedupeChatReferences,
dispatchResourceReferenceInsert,
dispatchResourceReferenceInsertMany,
resolveActiveIterationVersion,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT,
RESOURCE_REFERENCE_INSERT_MANY_EVENT,
RESOURCE_REFERENCE_SCOPES,
resourceReferenceCategory,
resourceReferenceFromAsset,
@@ -430,6 +436,62 @@ describe('ResourceReferenceInput', () => {
).toEqual(reference);
});
test('批量引用一次派发整批,空批次不派发', () => {
const listener = vi.fn();
const references = [
resourceReferenceFromAsset(assets[0]!, 'resource-card'),
resourceReferenceFromAsset(assets[2]!, 'resource-card'),
];
window.addEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
// 画布拖拽批量入口:N 条引用合成一次派发,不拆成 N 次单条事件。
dispatchResourceReferenceInsertMany(references);
// 空批次不开一次空事务:监听方连事件都收不到。
dispatchResourceReferenceInsertMany([]);
window.removeEventListener(RESOURCE_REFERENCE_INSERT_MANY_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
expect(
(listener.mock.calls[0]?.[0] as CustomEvent).detail.references,
).toEqual(references);
});
test('一次 insertReferences 按给定顺序插入整批 chip', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput
ref={composerRef}
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await settleComposer();
const references = [
resourceReferenceFromAsset(assets[2]!, 'resource-card'),
resourceReferenceFromAsset(assets[0]!, 'resource-card'),
];
act(() => composerRef.current?.insertReferences(references));
await settleComposer();
const draft = onChange.mock.calls.at(-1)?.[0];
// 顺序就是派发顺序:拖拽时按住的那张在 `moves[0]`,整批引用的次序跟着它走。
expect(draft?.references.map((reference) => reference.resourceId)).toEqual([
'theme',
'hero',
]);
expect(
document.querySelector('[data-resource-reference-id="theme"]'),
).not.toBeNull();
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
).not.toBeNull();
});
test('runtime inspect messages only expose the bounded safe selection shape', () => {
expect(
parseLocalGamePreviewInspectMessage({
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-09-21`2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**,见 §3.10 / §7.92026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
更新时间:`2026-09-21`2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**,见 §3.10 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md)2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
## 1. 产品定位
@@ -1,5 +1,14 @@
# 决策记录
## 2026-09-21 AGC 资源卡拖到对话实现批量 @ 引用
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「聊天拖拽批量引用」。改前只有两条入口:聊天输入框里输入 `@` 或点 `@` 按钮开素材选择面板,以及资源卡选中工具条上那枚「引用」按钮——都要先把素材找出来再点,多选批量引用没有一次成型的路径。
- 决策:资源卡**按住拖到右侧 Agent 对话栏、松手即批量引用**。落点判据是「指针是否在对话栏矩形内」:pointermove 在对话栏上时语义从排版切成引用(卡片不再跟着指针走,改铺一层虚线落点浮层 + 「松手即可 @ 引用 N 项素材」),拖回画布内松手仍然是原来的排版语义(照旧写手动坐标)。批量范围与拖动位移**同一集合**(`drag.moves`):多选后拖任意一张 = 整批引用,拖未选中的卡 = 只引用它自己。
- 决策(引用构造与派发):引用构造收敛成纯函数 `resourceCardReferenceDropReferences`,口径与工具条「引用」按钮**逐字一致**——只认已登记 manifest 的素材(`manifestAssetId`)、`source: 'resource-card'`、kind / 分类 / 标签取自资源投影,于是同一素材从两处进来是同一枚引用(去重键同样一致)。派发走新增的批量事件 `RESOURCE_REFERENCE_INSERT_MANY_EVENT``dispatchResourceReferenceInsertMany`):N 条引用一次事务插进草稿、只聚焦一次,不逐条重建草稿。一条也构造不出来时(选中的素材都未登记)不静默:提示条说明原因。
- 原因:拖动本来就在指针捕获下走,指针跑到画布外仍回到卡片 handler,因此「落点」只能自己量;不落盘是因为对话栏那一段没有画布坐标可言(写下去会得到跑到画布外的坐标),而且用户在对话栏上松手的意图本来就不是排版。`0 x 0` 的对话栏矩形必须判成「没有落点」:零面积矩形会让任何点都命中,画布内正常拖动会被整段跳过。
- 验证:`resourceCardReferenceDropModel.test.ts` 5 passed(矩形/命中/构造/提示文案)、`resourceCanvasChatReferenceDrop.test.tsx` 4 passed(单卡拖到对话 → 1 条引用且零坐标写入、多选整批 → 2 条、拖回画布 → 照旧写手动坐标且零引用、pointercancel 收干净)、`resourceReferenceInput.test.tsx` 30 passed(新增批量事件一次派发且空批次不派发、一次 insertReferences 按序插入整批 chip);连同 `appSurface.test.ts` 在内 7 个用例文件 660 passed / 17 skipped / 0 failed`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/{index.tsx,resourceCardReferenceDropModel.ts}``.../features/project-workspace/resourceReferences.ts``apps/ai-game-creator-shell/src/App.tsx``apps/ai-game-creator-shell/src/styles.css``apps/ai-game-creator-shell/tests/{resourceCardReferenceDropModel.test.ts,resourceCanvasChatReferenceDrop.test.tsx,resourceReferenceInput.test.tsx}``docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。未动 Rust、SpacetimeDB、`packages/`、共享弹窗组件。
- 已知未覆盖:真实客户端里的手感(拖到对话栏的触发距离、提示条位置、多选整批的视觉反馈)未在 Tauri 目视确认;「复制对话保留有效引用」属于同一条后续需求里的另一半,本轮未做。
## 2026-09-21 AGC「生成任务」侧栏移到画布右上角(照抄美术画布,保留 AGC 样式)
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合」。改前状态:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次),折叠态不留任何常驻入口;分组、失败可见、限高滚动、提交后自动展开这四条当时已经具备。
@@ -1,6 +1,6 @@
# AGC 聊天素材引用
更新时间:2026-09-08
更新时间:2026-09-21
AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。
@@ -15,6 +15,14 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。
## 拖拽引用(2026-09-21
除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。
- 拖动期间落点会铺一层虚线框与「松手即可 @ 引用 N 项素材」提示;拖回画布内松手仍是原来的排版语义(写手动坐标),两条语义由落点决定。
- 只认**已登记到 manifest** 的素材,引用身份、来源标记 `resource-card` 与「引用」按钮逐字一致,所以同一素材两处进来是同一枚引用(去重键也一样);一条都引用不了时(素材都未登记)用提示条说明原因。
- 一次松手只派发一次批量事件(`RESOURCE_REFERENCE_INSERT_MANY_EVENT`),草稿只重建一次、插入顺序即拖动集合顺序。
当前已完成:
- 三个聊天入口共用 `ResourceReferenceInput`
@@ -22,6 +30,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。
- `@` 按钮打开素材选择面板;
- 支持搜索、类型筛选和多选;
- 素材芯片可插入、编辑和删除;
- 资源画布支持把资源卡拖到对话栏批量引用(2026-09-21,见上一节);
- 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片;
- 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用;
- 提交请求携带 canonical user message item