diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7be35aff2..4d28f041a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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 + ).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) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 938e65e3c..a150971df 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -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( + RESOURCE_REFERENCE_INSERT_MANY_EVENT, + { detail: { references } }, + ), + ); +} + /** * `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。 * diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 6833fd921..d75d23d39 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 8a833b798..af73d293f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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(null); + /** + * 「拖动素材到对话」的落点:Agent 对话栏(`.game-workbench-chat`)。 + * + * 拖动是**指针捕获**的:指针跑到对话栏上时,pointermove 仍然回到卡片上,所以落点只能自己量。 + * 用 ref 拿对话栏元素(不 `document.querySelector`),对话栏在 UI 编辑器下不渲染,读不到就是 + * 没有落点。 + */ + const workbenchChatRef = useRef(null); + /** + * 正在拖到对话上时的提示(`null` = 不在落点上)。 + * + * 只活在这次拖动里:松手(无论落点在哪)与拖动取消都在同两处收干净,不会留下一个挂着的提示条。 + * `count` 是要插入的引用条数,`rect` 是对话栏当时的屏幕矩形(提示条按它铺)。 + */ + const [resourceCardReferenceDrop, setResourceCardReferenceDrop] = useState<{ + count: number; + rect: ResourceCardReferenceDropRect; + } | null>(null); const skipNextResourceCardClickRef = useRef(null); const skipNextResourceCardClickTimerRef = useRef(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, 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({ {!uiEditorRoute ? ( -