diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 42dabda45..77ea9afae 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -246,6 +246,7 @@ import { } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; +import type { DirectCodexUserItem } from './features/project-workspace/generated'; import { appendMemoryContent, memoryScopeLabel, @@ -278,12 +279,12 @@ import { handleProjectSummaryChatCommand } from './features/project-workspace/pr import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; -import type { - ChatComposerDraft, - ChatReference, -} from './features/project-workspace/resourceReferences'; +import type { ChatComposerDraft } from './features/project-workspace/resourceReferences'; import { chatComposerDraftToDirectCodexUserItem, + directCodexContentToPromptText, + directCodexUserItemFromContent, + hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; @@ -704,17 +705,15 @@ type ExecuteChatAgentReplyInput = { prompt: string; clientTurnId?: string; creationType?: HomeCreationType | null; - attachments?: DirectCodexTurnAttachment[]; directPolicyChecked?: boolean; - references?: ChatReference[]; - userItem?: ReturnType; + userItem: DirectCodexUserItem; }; /** * `conversation.write` 策略确认后重跑同一轮直连回合的入参。 * * 确认弹窗会在**同一轮输入**上二次进入 `executeChatAgentReply`。这里从首轮的入参整体派生, - * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 `references`), + * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 canonical user item), * 用户在确认之后拿到的就不是他原本提交的那一轮——`@` 引用会被静默丢掉。 */ export function directCodexPolicyRetryInput( @@ -3080,11 +3079,16 @@ export function App({ return; } const draft = chatComposerRef.current?.getDraft(); - if (!draft?.text.trim()) { + if (!draft || !hasMeaningfulDirectCodexContent(draft.content)) { return; } - persistSupervisorChatDraft(initialProjectPath, draft.text); - }, [initialProjectPath, supervisorChatOnly]); + persistSupervisorChatDraft( + initialProjectPath, + directCodexContentToPromptText(draft.content, manifest.assets), + ); + // 资源改名后草稿里的 @ 显示名要跟着变,所以内容签名(manifest.assets)也是这条 + // 持久化的依赖;重复写入的是同一份快照,不会把用户的编辑改坏。 + }, [initialProjectPath, manifest.assets, supervisorChatOnly]); useEffect(() => { latestMessagesRef.current = messages; @@ -4626,8 +4630,6 @@ export function App({ function readChatComposerDraft(): ChatComposerDraft { return ( chatComposerRef.current?.getDraft() ?? { - text: '', - references: [], content: [], } ); @@ -4637,9 +4639,14 @@ export function App({ chatComposerRef.current?.clear(); } - function handleChatComposerChange(draft: ChatComposerDraft) { + function handleChatComposerChange(draft: { + content: DirectCodexUserContentPart[]; + }) { if (supervisorChatOnly) { - persistSupervisorChatDraft(initialProjectPath, draft.text); + persistSupervisorChatDraft( + initialProjectPath, + directCodexContentToPromptText(draft.content, manifest.assets), + ); } } @@ -5669,13 +5676,15 @@ export function App({ async function handleChatSubmit(event: FormEvent) { event.preventDefault(); const draft = readChatComposerDraft(); - const prompt = draft.text.trim(); - const references = draft.references; + const prompt = directCodexContentToPromptText( + draft.content, + manifest.assets, + ); if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ((!prompt && references.length === 0) || chatAgentBusy) { + if (!hasMeaningfulDirectCodexContent(draft.content) || chatAgentBusy) { return; } @@ -6772,7 +6781,7 @@ export function App({ draft, directCodexConversationMessageId(clientTurnId, 'user'), ); - void executeChatAgentReply({ prompt, references, userItem, clientTurnId }); + void executeChatAgentReply({ prompt, userItem, clientTurnId }); } async function executeLlmConfigStatus() { @@ -6998,9 +7007,7 @@ export function App({ prompt, clientTurnId: directConversationTurnId, creationType, - attachments, directPolicyChecked = false, - references, userItem, }: ExecuteChatAgentReplyInput) { if (planningV2ActiveRef.current || planningStartMode) { @@ -7039,32 +7046,6 @@ export function App({ if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - const baseUserItem = - userItem ?? - chatComposerDraftToDirectCodexUserItem( - { text: prompt, references: references ?? [], content: [] }, - directCodexConversationMessageId(clientTurnId, 'user'), - ); - const effectiveUserItem = attachments?.length - ? { - ...baseUserItem, - content: [ - ...baseUserItem.content, - ...attachments.map((attachment) => ({ - type: attachment.mediaType.toLowerCase().startsWith('image/') - ? ('agc_image_reference' as const) - : ('agc_attachment_reference' as const), - name: attachment.name, - mediaType: attachment.mediaType, - size: attachment.size ?? 0, - localPath: attachment.localPath ?? '', - status: - attachment.status ?? - (attachment.localPath ? 'imported' : 'failed'), - })), - ], - } - : baseUserItem; if ( !directPolicyChecked && projectConversationWriteConfirmedRef.current !== directProjectPath @@ -7074,9 +7055,7 @@ export function App({ prompt, clientTurnId, creationType, - attachments, - references, - userItem: effectiveUserItem, + userItem, }); try { const policyPaused = await queueProjectPolicyConfirmationIfNeeded( @@ -7210,12 +7189,12 @@ export function App({ projectPath: directProjectPath, prompt, clientTurnId, - userItem: effectiveUserItem, + userItem, }; if (creationType) { directTurnInput.creationType = creationType; } - directTurnInput.userItem = effectiveUserItem; + directTurnInput.userItem = userItem; const reply = await withDirectCodexSessionRefresh(() => { // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 activeDirectCodexTurnRef.current = { @@ -7529,9 +7508,14 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; - const directConversationTurnId = directCodexProductRuntime - ? createDirectCodexConversationTurnId() - : undefined; + const directConversationTurnId = createDirectCodexConversationTurnId(); + const initialUserItem = directCodexUserItemFromContent( + [ + { type: 'input_text', text: latch.prompt }, + ...attachmentContentParts(latch.attachments), + ], + directCodexConversationMessageId(directConversationTurnId, 'user'), + ); setMessages((current) => [ ...current, { @@ -7553,7 +7537,9 @@ export function App({ prompt: latch.prompt, clientTurnId: directConversationTurnId, creationType: latch.creationType, - attachments: latch.attachments, + // TODO:该首页策划入口在 planning/legacy 模式仍复用此函数;迁移完成后拆出 + // 非 Direct Codex 的提交函数,避免旧链路携带 canonical user item 参数。 + userItem: initialUserItem, }); }, [ chatAgentBusy, @@ -12609,52 +12595,28 @@ export function App({ * 消息落盘/回合 id/附件参数走样。 */ function startDirectCodexConversationTurn(input: { - prompt: string; - attachments?: DirectCodexTurnAttachment[]; - references?: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; }) { - const clientTurnId = createDirectCodexConversationTurnId(); - const attachmentContent: DirectCodexUserContentPart[] = ( - input.attachments ?? [] - ).map((attachment) => ({ - type: attachment.mediaType.toLowerCase().startsWith('image/') - ? ('agc_image_reference' as const) - : ('agc_attachment_reference' as const), - name: attachment.name, - mediaType: attachment.mediaType, - size: attachment.size ?? 0, - localPath: attachment.localPath ?? '', - status: - attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), - })); - const content = [...(input.content ?? []), ...attachmentContent]; + const prompt = directCodexContentToPromptText( + input.userItem.content, + manifest.assets, + ); supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, { role: 'user', - text: input.prompt, + text: prompt, runtimeOwned: true, - messageId: directCodexConversationMessageId(clientTurnId, 'user'), + messageId: input.userItem.id, updatedAt: Date.now(), }, ]); void executeChatAgentReply({ - prompt: input.prompt, - clientTurnId, - references: input.references, - userItem: chatComposerDraftToDirectCodexUserItem( - { - text: input.prompt, - references: input.references ?? [], - content, - }, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - // DirectProject 的附件已经是 canonical content part;不能再作为 sidecar - // 传给 Rust,否则会重复追加。 - attachments: undefined, + prompt, + clientTurnId: input.clientTurnId, + userItem: input.userItem, }); } @@ -12715,12 +12677,24 @@ export function App({ ); } + function attachmentContentParts( + attachments: readonly DirectCodexTurnAttachment[], + ): DirectCodexUserContentPart[] { + return attachments.map((attachment) => ({ + type: 'agc_attachment_reference' as const, + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), + })); + } + /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ function enqueueChatTurnForRunningTurn(input: { - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; }): boolean { if (isChatTurnQueueFull(chatTurnQueueRef.current)) { setChatComposerNotice(chatQueueFullNotice()); @@ -12729,10 +12703,8 @@ export function App({ queuedChatTurnSequenceRef.current += 1; const turn = createQueuedChatTurn({ id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, - prompt: input.prompt, - attachments: input.attachments, - references: input.references, - content: input.content, + clientTurnId: input.clientTurnId, + userItem: input.userItem, createdAt: Date.now(), }); const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); @@ -12763,10 +12735,8 @@ export function App({ setChatComposerNotice(''); } startDirectCodexConversationTurn({ - prompt: next.prompt, - attachments: next.attachments, - references: next.references, - content: next.content, + clientTurnId: next.clientTurnId, + userItem: next.userItem, }); } @@ -12824,10 +12794,12 @@ export function App({ ) { event.preventDefault(); const draft = readChatComposerDraft(); - const prompt = draft.text.trim(); - const references = draft.references; - const content = draft.content ?? []; const pendingAttachments = chatAttachments; + const content: DirectCodexUserContentPart[] = [ + ...draft.content, + ...attachmentContentParts(pendingAttachments), + ]; + const prompt = directCodexContentToPromptText(content, manifest.assets); if ( !directCodexProductRuntime && supervisorChatOnly && @@ -12843,23 +12815,20 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ( - !prompt && - references.length === 0 && - content.length === 0 && - pendingAttachments.length === 0 - ) { + if (!hasMeaningfulDirectCodexContent(content)) { return; } if (chatAgentBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { + const queuedTurnId = createDirectCodexConversationTurnId(); const enqueued = enqueueChatTurnForRunningTurn({ - prompt, - attachments: pendingAttachments, - references, - content, + clientTurnId: queuedTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(queuedTurnId, 'user'), + ), }); if (enqueued) { clearChatComposer(); @@ -12895,7 +12864,16 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, clientTurnId }); + // TODO:planning V2 仍未迁移到 canonical user item,这里只是为了满足必填契约 + // 显式构造一份纯文本 item;该链路迁走后应改走独立的非 Direct Codex 提交函数。 + void executeChatAgentReply({ + prompt, + clientTurnId, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text: prompt }], + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }); return; } if (supervisorChatOnly || directCodexProductRuntime) { @@ -12907,11 +12885,13 @@ export function App({ setChatAttachments([]); setChatAttachmentNotice(''); setChatComposerNotice(''); + const clientTurnId = createDirectCodexConversationTurnId(); startDirectCodexConversationTurn({ - prompt, - attachments: pendingAttachments, - references, - content, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); return; } @@ -12925,7 +12905,16 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, references }); + // TODO:旧 Supervisor harness 链路仍未迁移到 canonical user item,这里只是为了满足 + // 必填契约显式构造一份纯文本 item;该链路退役后应连同此分支一起删除。 + const legacyTurnId = createAgentChatRunId('supervisor-chat-turn'); + void executeChatAgentReply({ + prompt, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text: prompt }], + directCodexConversationMessageId(legacyTurnId, 'user'), + ), + }); } const visibleProfessionalAgentCards = agentStatusCards.filter( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 67b45d6c9..da7eda167 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -80,6 +80,7 @@ import { chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, + directCodexContentToPromptText, refreshResourceReference, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_SCOPES, @@ -96,12 +97,9 @@ import { import { usePromptPolish } from './usePromptPolish'; type ResourceReferenceInputProps = { - value?: EditorState | string | null; - /** 仅用于尚未迁移的调用方提供初始引用;不会参与后续状态同步。 */ - references?: ChatReference[]; onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; - initialDraft?: Pick; + initialContent?: DirectCodexUserContentPart[]; assets: GameCreationAppAssetManifestEntry[]; projectPath: string; /** @@ -189,23 +187,18 @@ function appendInputText(content: DirectCodexUserContentPart[], text: string) { function collectDraftParts( node: LexicalNode, - textParts: string[], references: ChatReference[], content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { - const text = node.getTextContent(); - textParts.push(text); - appendInputText(content, text); + appendInputText(content, node.getTextContent()); return; } if ($isLineBreakNode(node)) { - textParts.push('\n'); appendInputText(content, '\n'); return; } if ($isResourceReferenceNode(node)) { - textParts.push(`@${node.__reference.label}`); references.push(node.__reference); content.push(chatReferenceToContentPart(node.__reference)); return; @@ -213,34 +206,41 @@ function collectDraftParts( if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - textParts.push('\n'); appendInputText(content, '\n'); } - collectDraftParts(child, textParts, references, content); + collectDraftParts(child, references, content); }); } } -/** 按编辑器自己的口径读草稿;只能在 Lexical 的读 / 更新上下文里调用。 */ -function readDraftFromNodes(): ChatComposerDraft { - const textParts: string[] = []; +type DraftProjection = { + references: ChatReference[]; + content: DirectCodexUserContentPart[]; +}; + +/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ +function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; const content: DirectCodexUserContentPart[] = []; - collectDraftParts($getRoot(), textParts, references, content); + collectDraftParts($getRoot(), references, content); return { - text: textParts.join('').trim(), references: dedupeChatReferences(references), content, }; } +/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */ +function readDraftFromNodes(): ChatComposerDraft { + return { content: readDraftProjectionFromNodes().content }; +} + // The pure projection is exported for submit-time reads and focused tests. // eslint-disable-next-line react-refresh/only-export-components export function readResourceReferenceDraft( editorState: EditorState | null, ): ChatComposerDraft { if (!editorState) { - return { text: '', references: [], content: [] }; + return { content: [] }; } return editorState.read(readDraftFromNodes); } @@ -352,6 +352,59 @@ function applyDraftToRoot(value: string, references: ChatReference[]) { }); } +function referenceFromContentPart( + part: DirectCodexUserContentPart, + assetsById: ReadonlyMap, +): ChatReference | null { + if (part.type === 'agc_resource_reference') { + const asset = assetsById.get(part.resourceId); + return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null; + } + if (part.type === 'agc_runtime_region_reference') { + return { + type: 'runtime-region', + label: part.label, + runId: part.runId ?? undefined, + versionId: part.versionId ?? undefined, + elementTag: part.elementTag ?? undefined, + elementRole: part.elementRole ?? undefined, + text: part.text ?? undefined, + width: part.width ?? undefined, + height: part.height ?? undefined, + resourceIds: part.resourceIds, + source: 'runtime-picker', + }; + } + return null; +} + +function applyContentToRoot( + content: readonly DirectCodexUserContentPart[], + assetsById: ReadonlyMap, +) { + const root = $getRoot(); + root.clear(); + let paragraph = $createParagraphNode(); + root.append(paragraph); + content.forEach((part) => { + if (part.type === 'input_text') { + const lines = part.text.split('\n'); + lines.forEach((line, index) => { + if (line) paragraph.append($createTextNode(line)); + if (index < lines.length - 1) { + paragraph = $createParagraphNode(); + root.append(paragraph); + } + }); + return; + } + const reference = referenceFromContentPart(part, assetsById); + if (reference) { + paragraph.append($createResourceReferenceNode(reference)); + } + }); +} + function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) { return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/'); } @@ -446,7 +499,7 @@ function $staleResourceReferenceNodes( function ResourceReferenceEditor({ onChange, onEditorStateChange, - initialDraft, + initialContent, assets, projectPath, activeVersionId = null, @@ -487,6 +540,12 @@ function ResourceReferenceEditor({ } | null>(null); const assetsContentSignature = assetsSignature(assets); const versionsContentSignature = iterationsSignature(versions); + const assetsById = useMemo( + () => new Map(assets.map((asset) => [asset.id, asset])), + // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 + // eslint-disable-next-line react-hooks/exhaustive-deps + [assetsContentSignature], + ); const assetReferences = useMemo( () => mentionableAssetReferences(assets, 'asset-picker'), // 依赖内容签名:调用方每次渲染都会重建 assets 数组,内容不变时没必要重算。 @@ -616,20 +675,20 @@ function ResourceReferenceEditor({ focus: () => editor.focus(), clear: () => { editor.update(() => { - applyDraftToRoot('', []); + applyContentToRoot([], assetsById); $getRoot().selectEnd(); }); }, replaceText: (text: string) => { editor.update(() => { - const current = readDraftFromNodes(); + const current = readDraftProjectionFromNodes(); applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, getDraft: () => readResourceReferenceDraft(editor.getEditorState()), }), - [editor, insertReferences, insertText, openPicker], + [assetsById, editor, insertReferences, insertText, openPicker], ); useEffect(() => { @@ -638,25 +697,16 @@ function ResourceReferenceEditor({ const initialDraftAppliedRef = useRef(false); useEffect(() => { - if (initialDraftAppliedRef.current || !initialDraft) { + if (initialDraftAppliedRef.current || !initialContent) { return; } initialDraftAppliedRef.current = true; editor.update(() => { - applyDraftToRoot(initialDraft.text, initialDraft.references); - skipInitialDraftChangeRef.current = - initialDraft.text.trim().length > 0 || - initialDraft.references.length > 0; + applyContentToRoot(initialContent, assetsById); + skipInitialDraftChangeRef.current = initialContent.length > 0; $getRoot().selectEnd(); }); - }, [editor, initialDraft]); - - const assetsById = useMemo( - () => new Map(assets.map((asset) => [asset.id, asset])), - // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 - // eslint-disable-next-line react-hooks/exhaustive-deps - [assetsContentSignature], - ); + }, [assetsById, editor, initialContent]); // 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin, // 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。 @@ -761,25 +811,32 @@ function ResourceReferenceEditor({ const acknowledgedDraftKeyRef = useRef(null); // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 const liveDraftRef = useRef({ - text: initialDraft?.text ?? '', - references: initialDraft?.references ?? [], - content: [], + content: initialContent ?? [], }); - const [draftText, setDraftText] = useState(initialDraft?.text ?? ''); + const [draftText, setDraftText] = useState(() => + directCodexContentToPromptText(initialContent ?? [], assets), + ); const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; + // 提交拦截只注册一次,但草稿里的 @ 显示名依赖最新 assets;用 ref 让监听器拿到当前值。 + const assetsRef = useRef(assets); + assetsRef.current = assets; const applyPromptText = useCallback( (text: string) => { editor.update(() => { - applyDraftToRoot(text, liveDraftRef.current.references); + const current = readDraftProjectionFromNodes(); + applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, [editor], ); - const readPromptText = useCallback(() => liveDraftRef.current.text, []); + const readPromptText = useCallback( + () => directCodexContentToPromptText(liveDraftRef.current.content, assets), + [assets], + ); const resolvePolishContext = useCallback( () => projectPath || null, [projectPath], @@ -813,7 +870,9 @@ function ResourceReferenceEditor({ // 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。 const submitCurrentDraft = useCallback(() => { setReminderOpen(false); - acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current); + acknowledgedDraftKeyRef.current = chatPromptDraftKey( + liveDraftRef.current.content, + ); rootRef.current?.closest('form')?.requestSubmit(); }, [rootRef]); @@ -850,7 +909,11 @@ function ResourceReferenceEditor({ const handleFormSubmit = (event: Event) => { if ( !shouldRemindChatPromptPolish({ - draft: liveDraftRef.current, + content: liveDraftRef.current.content, + prompt: directCodexContentToPromptText( + liveDraftRef.current.content, + assetsRef.current, + ), acknowledgedDraftKey: acknowledgedDraftKeyRef.current, reminderDisabled: reminderDisabledRef.current, }) @@ -867,8 +930,7 @@ function ResourceReferenceEditor({ // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 useEffect(() => { - const draft = liveDraftRef.current; - if (draft.text.trim() !== '' || draft.references.length > 0) return; + if (liveDraftRef.current.content.length > 0) return; resetPromptPolish(); acknowledgedDraftKeyRef.current = null; }, [resetPromptPolish]); @@ -1216,11 +1278,10 @@ function ResourceReferenceEditor({ onChange={(editorState) => { const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; - setDraftText(nextDraft.text); - if ( - nextDraft.text.trim() === '' && - nextDraft.references.length === 0 - ) { + setDraftText( + directCodexContentToPromptText(nextDraft.content, assets), + ); + if (nextDraft.content.length === 0) { resetPromptPolish(); acknowledgedDraftKeyRef.current = null; } @@ -1305,18 +1366,10 @@ export const ResourceReferenceInput = forwardRef< ResourceReferenceInputProps >(function ResourceReferenceInput(props, ref) { const rootRef = useRef(null); - const initialEditorState = - typeof props.value === 'string' ? null : props.value; - const initialDraft = - props.initialDraft ?? - (typeof props.value === 'string' - ? { text: props.value, references: props.references ?? [] } - : undefined); return ( } > - + ); }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index ff017ce91..11ac62f89 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -4,36 +4,32 @@ * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 */ -import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; -import type { DirectCodexUserContentPart } from './generated'; -import type { ChatReference } from './resourceReferences'; +import type { DirectCodexUserItem } from './generated'; +import { directCodexContentToPromptText } from './resourceReferences'; /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ export const MAX_QUEUED_CHAT_TURNS = 5; export type QueuedChatTurn = { id: string; - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }; export function createQueuedChatTurn(input: { id: string; - prompt: string; - attachments?: readonly DirectCodexTurnAttachment[]; - references?: readonly ChatReference[]; - content?: readonly DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }): QueuedChatTurn { return { id: input.id, - prompt: input.prompt, - attachments: [...(input.attachments ?? [])], - references: [...(input.references ?? [])], - content: [...(input.content ?? [])], + clientTurnId: input.clientTurnId, + userItem: { + ...input.userItem, + content: [...input.userItem.content], + }, createdAt: input.createdAt, }; } @@ -79,14 +75,19 @@ export function chatQueueFullNotice(): string { /** 队列 chip 上显示的文字:单行、有长度上限。 */ export function queuedChatTurnLabel(turn: QueuedChatTurn): string { - const text = turn.prompt.trim().replace(/\s+/gu, ' '); + const text = directCodexContentToPromptText(turn.userItem.content) + .trim() + .replace(/\s+/gu, ' '); if (text) { return text.length > 24 ? `${text.slice(0, 24)}…` : text; } - if (turn.attachments.length > 0) { - return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`; + const attachment = turn.userItem.content.find( + (part) => part.type === 'agc_attachment_reference', + ); + if (attachment?.type === 'agc_attachment_reference') { + return `附件 · ${attachment.name || '未命名'}`; } - if (turn.references.length > 0) { + if (turn.userItem.content.some((part) => part.type !== 'input_text')) { return '素材引用'; } return '未命名消息'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index 68372d749..cc509e140 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,8 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import { - type ChatComposerDraft, - chatReferenceListKey, -} from './resourceReferences'; +import type { DirectCodexUserContentPart } from './generated'; /** * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 @@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) { } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ -export function chatPromptDraftKey(draft: ChatComposerDraft) { - return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`; +export function chatPromptDraftKey( + content: readonly DirectCodexUserContentPart[], +) { + return JSON.stringify(content); } /** @@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) { * 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。 */ export function shouldRemindChatPromptPolish({ - draft, + content, + prompt, acknowledgedDraftKey, reminderDisabled, }: { - draft: ChatComposerDraft; + content: readonly DirectCodexUserContentPart[]; + prompt: string; acknowledgedDraftKey: string | null; reminderDisabled: boolean; }) { if (reminderDisabled) { return false; } - const text = draft.text.trim(); + const text = prompt.trim(); if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) { return false; } if (text.startsWith('/')) { return false; } - return chatPromptDraftKey(draft) !== acknowledgedDraftKey; + return chatPromptDraftKey(content) !== acknowledgedDraftKey; } /** 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 72f46c677..f6a8f6712 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 @@ -49,9 +49,7 @@ export type RuntimeRegionReference = { export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatComposerDraft = { - text: string; - references: ChatReference[]; - /** Lexical 顺序对应的 canonical user content;只从 EditorState 派生。 */ + /** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */ content: DirectCodexUserContentPart[]; }; @@ -88,11 +86,39 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) { } export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { - text: '', - references: [], content: [], }; +export function directCodexContentToPromptText( + content: readonly DirectCodexUserContentPart[], + assets: readonly GameCreationAppAssetManifestEntry[] = [], +) { + const labels = new Map( + assets.map((asset) => [asset.id, resourceDisplayName(asset)]), + ); + return content + .map((part) => { + if (part.type === 'input_text') return part.text; + if (part.type === 'agc_resource_reference') { + return `@${labels.get(part.resourceId) ?? part.resourceId}`; + } + if (part.type === 'agc_runtime_region_reference') { + return `@${part.label}`; + } + return ''; + }) + .join('') + .trim(); +} + +export function hasMeaningfulDirectCodexContent( + content: readonly DirectCodexUserContentPart[], +) { + return content.some( + (part) => part.type !== 'input_text' || part.text.trim().length > 0, + ); +} + export function chatReferenceToContentPart( reference: ChatReference, ): DirectCodexUserContentPart { @@ -117,17 +143,21 @@ export function chatComposerDraftToDirectCodexUserItem( draft: ChatComposerDraft, id: string, ): DirectCodexUserItem { - const content = draft.content.filter( - (part) => part.type !== 'input_text' || part.text.trim().length > 0, - ); return { type: 'message', role: 'user', - content, + content: [...draft.content], id, } satisfies DirectCodexUserItem; } +export function directCodexUserItemFromContent( + content: readonly DirectCodexUserContentPart[], + id: string, +): DirectCodexUserItem { + return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id); +} + export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id; 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 ffe964a78..38146ff4a 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 @@ -111,6 +111,7 @@ import { } from '../../features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, + directCodexContentToPromptText, dispatchResourceReferenceInsert, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, @@ -6491,7 +6492,10 @@ export default function ProjectDevelopmentView({ */ const applyResourceQuickEditPrompt = useCallback((text: string) => { const currentDraft = quickEditPromptInputRef.current?.getDraft(); - if (currentDraft && currentDraft.text !== text) { + if ( + currentDraft && + directCodexContentToPromptText(currentDraft.content) !== text + ) { quickEditPromptInputRef.current?.replaceText(text); } setQuickEditPanel((current) => @@ -6515,7 +6519,9 @@ export default function ProjectDevelopmentView({ */ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { - applyResourceQuickEditPrompt(draft.text); + applyResourceQuickEditPrompt( + directCodexContentToPromptText(draft.content), + ); }, [applyResourceQuickEditPrompt], ); @@ -8135,10 +8141,12 @@ export default function ProjectDevelopmentView({ ref={quickEditPromptInputRef} key={quickEditSourceLayer?.id} ariaLabel="快速编辑提示词" - initialDraft={{ - text: quickEditPanel.prompt, - references: [], - }} + initialContent={[ + { + type: 'input_text', + text: quickEditPanel.prompt, + }, + ]} onChange={applyResourceQuickEditDraft} assets={manifest.assets} projectPath={projectPath} diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..ed9c30264 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -4,6 +4,7 @@ import { dequeueChatTurn, enqueueChatTurn, isChatTurnQueueFull, + queuedChatTurnLabel, removeQueuedChatTurn, } from '../../src/features/project-workspace/chatComposerQueue'; import { @@ -15,6 +16,7 @@ import { VOICE_INPUT_UNSUPPORTED_MESSAGE, } from '../../src/features/project-workspace/chatComposerVoice'; import { ComposerVoiceButton } from '../../src/features/project-workspace/ComposerControls'; +import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { act, createGameCreationAppManifest, @@ -175,23 +177,29 @@ function installFakeSpeechRecognition(): { }; } +/** 排队回合只持 canonical user item;展示文案由它派生。 */ +function queuedTurn( + id: string, + clientTurnId: string, + text: string, + createdAt: number, +) { + return createQueuedChatTurn({ + id, + clientTurnId, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text }], + `${clientTurnId}:user`, + ), + createdAt, + }); +} + export function registerChatComposerControlTests() { it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { - const first = createQueuedChatTurn({ - id: 'turn-1', - prompt: '第一条', - createdAt: 1, - }); - const second = createQueuedChatTurn({ - id: 'turn-2', - prompt: '第二条', - createdAt: 2, - }); - const third = createQueuedChatTurn({ - id: 'turn-3', - prompt: '第三条', - createdAt: 3, - }); + const first = queuedTurn('turn-1', 'client-1', '第一条', 1); + const second = queuedTurn('turn-2', 'client-2', '第二条', 2); + const third = queuedTurn('turn-3', 'client-3', '第三条', 3); let queue = enqueueChatTurn([], first); queue = enqueueChatTurn(queue, second); @@ -201,15 +209,18 @@ export function registerChatComposerControlTests() { // FIFO:先入先出,不丢、不乱序。 const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.prompt).toBe('第一条'); - expect(firstOut.rest.map((turn) => turn.prompt)).toEqual([ + expect(firstOut.next?.clientTurnId).toBe('client-1'); + expect(firstOut.next && queuedChatTurnLabel(firstOut.next)).toBe('第一条'); + expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn))).toEqual([ '第二条', '第三条', ]); // 单条取消只移除那一条,顺序不变。 expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => turn.prompt), + removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => + queuedChatTurnLabel(turn), + ), ).toEqual(['第三条']); expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); @@ -222,11 +233,7 @@ export function registerChatComposerControlTests() { for (let index = 0; index < 5; index += 1) { queue = enqueueChatTurn( queue, - createQueuedChatTurn({ - id: `turn-${index}`, - prompt: `第 ${index} 条`, - createdAt: index, - }), + queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), ); } expect(isChatTurnQueueFull(queue)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..416bd28e9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1672,17 +1672,19 @@ export function registerHomeProjectCreationTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '按这个角色做游戏' }], + // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 + content: [ + { type: 'input_text', text: '按这个角色做游戏' }, + { + type: 'agc_attachment_reference', + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }, - attachments: [ - { - name: '角色参考.png', - mediaType: 'image/png', - size: attachment.size, - localPath: 'assets/uploads/reference.png', - status: 'imported', - }, - ], }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1717,7 +1719,11 @@ export function registerHomeProjectCreationTests() { (args as Record | undefined)?.prompt === '再补一句玩法', )?.[1] as Record | undefined; - expect(followUpPayload).not.toHaveProperty('attachments'); + // 后续这一轮没有附件:canonical content 里只有文本 part。 + expect( + (followUpPayload?.userItem as { content?: unknown[] } | undefined) + ?.content, + ).toEqual([{ type: 'input_text', text: '再补一句玩法' }]); }); it('starts an automatic game project from the approved GDD', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index eac40a8c4..1b85e31a0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -2,6 +2,7 @@ import { directCodexPolicyRetryInput, isDirectCodexTurnAlreadyRunningError, } from '../../src/App'; +import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { act, agentRuntimeUserInputRequest, @@ -49,27 +50,30 @@ export function registerProjectConversationTests() { it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { // 确认 `conversation.write` 之后重跑的是同一轮输入:漏掉任何一项都会让用户 - // 在确认之后拿到另一轮内容。历史缺陷正是漏了 `references`(@ 引用被静默丢掉), - // 所以这里把「首轮入参整体带过去」钉成硬约束。 + // 在确认之后拿到另一轮内容。历史缺陷正是漏了 canonical user item 里的 @ 引用 + // (引用被静默丢掉),所以这里把「首轮入参整体带过去」钉成硬约束。 const firstTurn = { prompt: '用这张图改一下', clientTurnId: 'direct-turn-1', creationType: 'game' as const, - attachments: [ - { name: '角色草图.png', mediaType: 'image/png', size: 128 }, - ], - references: [ - { - type: 'resource' as const, - resourceId: 'reference-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene' as const, - tags: ['主舞台'], - source: 'resource-card' as const, - }, - ], + userItem: directCodexUserItemFromContent( + [ + { type: 'input_text' as const, text: '用这张图改一下' }, + { + type: 'agc_resource_reference' as const, + resourceId: 'reference-hero', + }, + { + type: 'agc_attachment_reference' as const, + name: '角色草图.png', + mediaType: 'image/png', + size: 128, + localPath: '', + status: 'imported' as const, + }, + ], + 'direct-turn-1:user', + ), }; expect(directCodexPolicyRetryInput(firstTurn)).toEqual({ @@ -77,9 +81,10 @@ export function registerProjectConversationTests() { directPolicyChecked: true, }); - // 引用是这一次输入的判别项,单独再断言一遍,避免上面整体相等被未来字段扩展掩盖。 - expect(directCodexPolicyRetryInput(firstTurn).references).toEqual( - firstTurn.references, + // canonical content 是这一次输入的判别项,单独再断言一遍,避免上面整体相等 + // 被未来字段扩展掩盖。 + expect(directCodexPolicyRetryInput(firstTurn).userItem.content).toEqual( + firstTurn.userItem.content, ); }); diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index ac2e37ffa..4439cd42f 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -21,11 +21,14 @@ import { shouldRemindChatPromptPolish, writeChatPromptPolishReminderDisabled, } from '../src/features/project-workspace/chatPromptPolish'; +import type { DirectCodexUserContentPart } from '../src/features/project-workspace/generated'; import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, + chatReferenceToContentPart, + hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; @@ -82,6 +85,11 @@ async function composerText() { return screen.getByLabelText('创作想法').textContent ?? ''; } +/** 纯文本草稿的 canonical content。 */ +function textContent(text: string): DirectCodexUserContentPart[] { + return text ? [{ type: 'input_text', text }] : []; +} + function ControlledChatComposer({ initialText, initialReferences = [], @@ -92,28 +100,28 @@ function ControlledChatComposer({ onSubmitDraft: (draft: ChatComposerDraft) => void; }) { const composerRef = useRef(null); + const initialContent: DirectCodexUserContentPart[] = [ + ...textContent(initialText), + ...initialReferences.map(chatReferenceToContentPart), + ]; return (
{ event.preventDefault(); const draft = composerRef.current?.getDraft(); - const submitted = - draft && (draft.text || draft.references.length > 0) - ? draft - : { - text: initialText, - references: initialReferences, - content: [], - }; + const submittedContent = hasMeaningfulDirectCodexContent( + draft?.content ?? [], + ) + ? draft!.content + : initialContent; onSubmitDraft({ - text: submitted.text, - references: submitted.references, - } as ChatComposerDraft); + content: submittedContent, + }); }} > {}} assets={[]} projectPath="C:/project" @@ -161,41 +169,45 @@ afterEach(() => { describe('发送前提醒判据', () => { test('only reminds for long plain prompts that were not acknowledged this round', () => { - const draft: ChatComposerDraft = { - text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH), - references: [], - }; + const longPrompt = '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH); + const content = textContent(longPrompt); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(true); expect( shouldRemindChatPromptPolish({ - draft: { text: '短需求', references: [] }, + content: textContent('短需求'), + prompt: '短需求', acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: true, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, - acknowledgedDraftKey: chatPromptDraftKey(draft), + content, + prompt: longPrompt, + acknowledgedDraftKey: chatPromptDraftKey(content), reminderDisabled: false, }), ).toBe(false); + const command = `/${'长'.repeat(60)}`; expect( shouldRemindChatPromptPolish({ - draft: { text: `/${'长'.repeat(60)}`, references: [] }, + content: textContent(command), + prompt: command, acknowledgedDraftKey: null, reminderDisabled: false, }), @@ -213,8 +225,11 @@ describe('发送前提醒判据', () => { }, 'asset-picker', ); - expect(chatPromptDraftKey({ text: '需求', references: [] })).not.toBe( - chatPromptDraftKey({ text: '需求', references: [reference] }), + expect(chatPromptDraftKey(textContent('需求'))).not.toBe( + chatPromptDraftKey([ + ...textContent('需求'), + chatReferenceToContentPart(reference), + ]), ); }); @@ -325,8 +340,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -357,8 +371,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); expect(await composerText()).toBe('润色后的长需求'); @@ -386,8 +399,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); }); @@ -424,8 +436,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { resolvePolish('润色后的长需求'); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); }); @@ -447,8 +458,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -464,8 +474,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); @@ -474,8 +483,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(shortSubmit).toHaveBeenCalledWith({ - text: '做个跳跃游戏', - references: [], + content: textContent('做个跳跃游戏'), }); cleanup(); @@ -485,8 +493,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(commandSubmit).toHaveBeenCalledWith({ - text: `/${'命令'.repeat(40)}`, - references: [], + content: textContent(`/${'命令'.repeat(40)}`), }); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index a21ddbacd..68ace0833 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -28,8 +28,10 @@ import { type ChatComposerDraft, type ChatReference, chatReferenceListKey, + chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, + directCodexContentToPromptText, dispatchResourceReferenceInsert, resolveActiveIterationVersion, RESOURCE_REFERENCE_FILTERS, @@ -83,6 +85,18 @@ async function settleComposer() { }); } +/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */ +function draftText(draft: ChatComposerDraft | undefined) { + return directCodexContentToPromptText(draft?.content ?? []); +} + +/** 草稿里的资源引用 id,按 content 顺序。 */ +function draftResourceIds(draft: ChatComposerDraft | undefined) { + return (draft?.content ?? []).flatMap((part) => + part.type === 'agc_resource_reference' ? [part.resourceId] : [], + ); +} + /** * 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本: * DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。 @@ -207,8 +221,10 @@ describe('ResourceReferenceInput', () => { { const user = userEvent.setup(); function Controlled() { const [draft, setDraft] = useState({ - text: '要一个', - references: [], + content: [{ type: 'input_text', text: '要一个' }], }); return ( { }} > { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - expect(draft?.text).toBe('@hero @enemy'); - expect(draft?.references.map((reference) => reference.resourceId)).toEqual([ - 'hero', - 'enemy', - ]); + // canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版, + // 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。 + expect(draftText(draft)).toBe('@hero@enemy'); + expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).not.toBeNull(); @@ -327,8 +338,6 @@ describe('ResourceReferenceInput', () => { render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { await user.click(screen.getByRole('button', { name: '移除引用 hero' })); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); }); }); @@ -521,8 +528,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(chip?.contains(deleteButton)).toBe(true); // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( - 'hero', - ); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero'); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']); // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 await user.click(deleteButton); @@ -585,8 +586,8 @@ describe('ResourceReferenceInput', () => { expect( document.querySelector('[data-resource-reference-id="hero"]'), ).toBeNull(); - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe(''); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe(''); }); test('exposes the current-version and all-canvas scopes as the only two tabs', () => { @@ -631,8 +632,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), assets[1]!, @@ -733,8 +729,13 @@ describe('ResourceReferenceInput', () => { ]; render( { document.querySelector('.resource-reference-chip-label')?.textContent, ).toBe('hero-final'); }); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( - 'hero-final', - ); - }); + // 改名只影响显示名:canonical content 仍只记稳定 resourceId。 + expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); @@ -764,8 +762,7 @@ describe('ResourceReferenceInput', () => { const composerRef = createRef(); render( { await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + expect( + onChange.mock.calls + .at(-1)?.[0] + .content.filter((part) => part.type === 'input_text') + .map((part) => (part.type === 'input_text' ? part.text : '')) + .join(''), + ).toBe('恢复出来的草稿'); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -814,8 +817,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { render( <> { const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0]; // 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同: // 「快速编辑」不允许出现第二种引用格式。 - expect(chatDraft?.text).toBe('@hero'); - expect(quickEditDraft).toEqual(chatDraft); - expect(quickEditDraft?.references).toEqual([ - resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'), + expect(chatDraft?.content).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, ]); + expect(quickEditDraft).toEqual(chatDraft); expect( document.querySelectorAll('[data-resource-reference-id="hero"]'), ).toHaveLength(2); @@ -961,8 +955,7 @@ describe('ResourceReferenceInput', () => { test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => { render( { it('保留 Lexical content 的文本与引用交错顺序', () => { const draft: ChatComposerDraft = { - text: '忽略的扁平摘要', - references: [], content: [ { type: 'input_text', text: '先看 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -41,19 +41,6 @@ describe('DirectProject user Response item', () => { it('资源引用只投影稳定 resourceId,不携带展示字段', () => { const draft: ChatComposerDraft = { - text: '请使用素材', - references: [ - { - type: 'resource', - resourceId: 'asset-hero', - kind: 'character', - mediaType: 'image/png', - label: '主角', - category: 'character', - tags: ['hero'], - source: 'asset-picker', - }, - ], content: [ { type: 'input_text', text: '请使用素材' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -72,4 +59,48 @@ describe('DirectProject user Response item', () => { ], }); }); + + it('原样保留纯空白 input_text,不替用户改写提示词', () => { + const draft: ChatComposerDraft = { + content: [ + { type: 'input_text', text: '先看' }, + { type: 'input_text', text: ' ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: '\n' }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, + ).toEqual(draft.content); + }); + + it('只有最终 content 全为空白时才判定为空输入', () => { + expect( + hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]), + ).toBe(false); + expect(hasMeaningfulDirectCodexContent([])).toBe(false); + // 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。 + expect( + hasMeaningfulDirectCodexContent([ + { type: 'input_text', text: ' \n' }, + { type: 'input_text', text: '看' }, + ]), + ).toBe(true); + expect( + hasMeaningfulDirectCodexContent([ + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + ]), + ).toBe(true); + }); + + it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { + expect( + directCodexContentToPromptText([ + { type: 'input_text', text: '用 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 做主视觉' }, + ]), + ).toBe('用 @asset-hero 做主视觉'); + }); }); diff --git a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md index 072f9468b..6bdbed240 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md @@ -1,10 +1,10 @@ # 【实施计划】DirectProject canonical content 严格边界 -| 字段 | 值 | -| --- | --- | +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------------------------- | | Milestone | `docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md` | -| Status | in-progress | -| Owner | Codex | +| Status | implemented | +| Owner | Codex | ## 实施顺序 @@ -14,6 +14,13 @@ 4. 收敛策略确认重试为复用同一 canonical user item;旧 Supervisor/Planning caller 加 TODO,不改变其非 Direct Codex 行为。 5. 迁移现有测试 fixture,删除旧字段构造,不增加“字段不存在”测试。 +## 落地结果 + +- `chatComposerDraftToDirectCodexUserItem` 原样传递 `draft.content`;新增 `directCodexUserItemFromContent` 供 caller 直接构造 canonical item。 +- `ChatComposerDraft` 只保留 `content`;`ResourceReferenceInput` 的对外草稿、`chatPromptDraftKey`、`QueuedChatTurn` 全部改为 content-only。 +- 首页首轮、普通聊天提交、运行中队列出队、策略确认重试都携带同一个 canonical user item;`executeChatAgentReply` 的 `userItem` 兜底分支已删除。 +- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO。 + ## 修改边界 - 允许修改:AGC shell 前端 `resourceReferences`、`App`、聊天队列、Direct Codex 相关测试和当前里程碑文档。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md index a1f7eb659..80b8b6a13 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -1,21 +1,22 @@ # 【里程碑】DirectProject canonical content 严格边界 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-16 | +| 字段 | 值 | +| ----------- | ------------------------------------------------ | +| Version | 1.0 | +| Status | implemented | +| Date | 2026-09-16 | | Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | ## 目标 -让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:保留 Lexical 产生的全部 content part(包括纯空白 `input_text`),只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 +让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:转换函数原样传递编辑器草稿的 content part,不做二次预过滤;只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 ## 范围 - 移除 `chatComposerDraftToDirectCodexUserItem` 对纯空白 `input_text` 的预过滤。 - Direct Codex 发送前只做最终 content 的有效性判断,不改写 content。 - 删除 `executeChatAgentReply` 在 `userItem` 缺失时的构造兜底。 +- 删除 `ChatComposerDraft` 的 `text` / `references` 字段,草稿只保留 canonical `content`。 - 修正首页首轮、队列出队和其它 Direct Codex caller,使其直接构造 canonical user item。 - 将队列与策略确认重试按 canonical user item 传递,避免拆回 `text` / `references`。 - 仍在使用的旧 Supervisor/Planning caller 保持非 Direct Codex 行为,并添加后续迁移 TODO。 @@ -31,11 +32,18 @@ - `chatComposerDraftToDirectCodexUserItem` 输出与输入 `draft.content` 顺序和值完全一致。 - 只有当最终 content 不含非空文本且不含任何非文本 part 时,发送入口才拒绝本轮。 - Direct Codex 路径不存在 `userItem ?? ...` 或等价 fallback。 +- `ChatComposerDraft` 只有 `content` 一个字段;`text` / `references` 不再是草稿契约的一部分。 - 首页首轮、普通聊天、队列出队、策略确认重试均发送同一个 canonical user item 语义。 - 旧 Supervisor/Planning caller 上有明确 TODO,且不进入 Direct Codex canonical 发送路径。 +## 实现结论 + +- 唯一的前端空白过滤留在 Lexical 投影层:`agc_attachment`/`input_text` 之外的纯空白文本不作为 content part,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 +- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。 +- 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 + ## 证据 -- 前端 canonical content、有效性判断、caller 与队列定向测试。 -- AGC shell 类型检查与定向 Vitest。 -- `npm run check:encoding`、`git diff --check`、必要的文档索引检查。 +- `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 +- AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。