资源卡拖到对话栏实现批量 @ 引用
- 新增落点模型 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:
@@ -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` 的两块浮层:资源选择器与输入区候选菜单。
|
||||
*
|
||||
|
||||
@@ -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 上方**、和触发钮之间隔着整个输入区,输入区一变高(多行、
|
||||
引用 chip、AI 润色)菜单与提示就跟着往上飘,看起来就是"编辑框把弹层挤开了"。
|
||||
`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) => (
|
||||
|
||||
+101
@@ -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-card,kind / 分类 / 标签全部取自资源投影,
|
||||
* 于是同一个素材无论从按钮还是从拖拽进来,草稿里的引用是同一枚(去重键也跟着走)。
|
||||
* 未登记的卡(任务产物、附件、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.9;2026-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;
|
||||
|
||||
Reference in New Issue
Block a user