修复DirectProject canonical内容边界(review 第 8 条)

- 移除 chatComposerDraftToDirectCodexUserItem 对纯空白 input_text 的预过滤,原样传递 draft.content
- ChatComposerDraft 只保留 canonical content,删除 text / references 旧字段
- 新增 directCodexContentToPromptText、hasMeaningfulDirectCodexContent、directCodexUserItemFromContent 供 caller 与展示派生
- 删除 executeChatAgentReply 的 userItem 兜底分支,首页首轮、队列出队、普通提交与策略重试显式构造 canonical user item
- QueuedChatTurn 只保存 clientTurnId 与 userItem,队列 chip 文案由 content 派生
- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO
- 资源输入区对外只暴露 canonical content,内部文本草稿改为文本 → content 重建
- 迁移草稿、润色、队列与 appSurface 测试 fixture 到 content-only
- 更新 canonical content 里程碑与实施计划文档结论
This commit is contained in:
2026-09-16 17:46:41 +08:00
parent f4b496caca
commit 41366dd71d
14 changed files with 551 additions and 410 deletions
+111 -122
View File
@@ -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<typeof chatComposerDraftToDirectCodexUserItem>;
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<HTMLFormElement>) {
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 });
// TODOplanning 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(
@@ -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<ChatComposerDraft, 'text' | 'references'>;
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<string, GameCreationAppAssetManifestEntry>,
): 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<string, GameCreationAppAssetManifestEntry>,
) {
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<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({
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<HTMLDivElement | null>(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 (
<RichTextInput
namespace="agc-resource-reference-input"
nodes={[ResourceReferenceNode]}
initialEditorState={initialEditorState}
containerRef={rootRef}
containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
disabled={props.disabled}
@@ -1338,12 +1391,7 @@ export const ResourceReferenceInput = forwardRef<
</span>
}
>
<ResourceReferenceEditor
{...props}
initialDraft={initialDraft}
composerRef={ref}
rootRef={rootRef}
/>
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
</RichTextInput>
);
});
@@ -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 '未命名消息';
@@ -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;
}
/**
@@ -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;
@@ -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}
@@ -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);
@@ -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<string, unknown> | undefined)?.prompt ===
'再补一句玩法',
)?.[1] as Record<string, unknown> | 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 () => {
@@ -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,
);
});
@@ -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<ResourceReferenceInputHandle | null>(null);
const initialContent: DirectCodexUserContentPart[] = [
...textContent(initialText),
...initialReferences.map(chatReferenceToContentPart),
];
return (
<form
onSubmit={(event) => {
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,
});
}}
>
<ResourceReferenceInput
ref={composerRef}
initialDraft={{ text: initialText, references: initialReferences }}
initialContent={initialContent}
onChange={() => {}}
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)}`),
});
});
});
@@ -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', () => {
</button>
<ResourceReferenceInput
ref={composerRef}
value={null}
initialDraft={{ text: '原始需求', references: [reference] }}
initialContent={[
{ type: 'input_text', text: '原始需求' },
chatReferenceToContentPart(reference),
]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
@@ -240,8 +256,7 @@ describe('ResourceReferenceInput', () => {
const user = userEvent.setup();
function Controlled() {
const [draft, setDraft] = useState<ChatComposerDraft>({
text: '要一个',
references: [],
content: [{ type: 'input_text', text: '要一个' }],
});
return (
<form
@@ -251,8 +266,7 @@ describe('ResourceReferenceInput', () => {
}}
>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
initialContent={draft.content}
onChange={setDraft}
assets={assets}
projectPath="C:/project"
@@ -284,8 +298,6 @@ describe('ResourceReferenceInput', () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
@@ -304,11 +316,10 @@ describe('ResourceReferenceInput', () => {
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(
<StrictMode>
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
@@ -497,8 +506,6 @@ describe('ResourceReferenceInput', () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
@@ -513,7 +520,7 @@ describe('ResourceReferenceInput', () => {
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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
projectPath="C:/project"
@@ -550,8 +555,6 @@ describe('ResourceReferenceInput', () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
@@ -573,10 +576,8 @@ describe('ResourceReferenceInput', () => {
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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[
@@ -683,8 +682,6 @@ describe('ResourceReferenceInput', () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
activeVersionId="v1"
@@ -708,8 +705,6 @@ describe('ResourceReferenceInput', () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={assets}
versions={[]}
@@ -726,6 +721,7 @@ describe('ResourceReferenceInput', () => {
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<ResourceReferenceInputHandle>();
const renamedAssets = [
asset('hero', 'character', 'image/png', 'assets/hero-final.png'),
assets[1]!,
@@ -733,8 +729,13 @@ describe('ResourceReferenceInput', () => {
];
render(
<ResourceReferenceInput
value="用这个角色"
references={[resourceReferenceFromAsset(assets[0]!, 'asset-picker')]}
initialContent={[
{ type: 'input_text', text: '用这个角色' },
chatReferenceToContentPart(
resourceReferenceFromAsset(assets[0]!, 'asset-picker'),
),
]}
ref={composerRef}
onChange={onChange}
assets={renamedAssets}
projectPath="C:/project"
@@ -747,11 +748,8 @@ describe('ResourceReferenceInput', () => {
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<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput
value="上一个会话的草稿"
references={[]}
initialContent={[{ type: 'input_text', text: '上一个会话的草稿' }]}
ref={composerRef}
onChange={onChange}
assets={assets}
@@ -782,7 +779,13 @@ describe('ResourceReferenceInput', () => {
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(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
@@ -867,8 +868,6 @@ describe('ResourceReferenceInput', () => {
const user = userEvent.setup();
render(
<ResourceReferenceInput
value=""
references={[]}
onChange={vi.fn()}
assets={taggedAssets}
versions={[iterationVersion('v1', ['hero', 'theme'])]}
@@ -920,16 +919,12 @@ describe('ResourceReferenceInput', () => {
render(
<>
<ResourceReferenceInput
value=""
references={[]}
onChange={chatOnChange}
assets={taggedAssets}
projectPath="C:/project"
ariaLabel="聊天"
/>
<ResourceReferenceInput
value=""
references={[]}
onChange={quickEditOnChange}
assets={taggedAssets}
projectPath="C:/project"
@@ -948,11 +943,10 @@ describe('ResourceReferenceInput', () => {
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(
<ResourceReferenceInput
value="把夜色改成星空"
references={[]}
initialContent={[{ type: 'input_text', text: '把夜色改成星空' }]}
onChange={vi.fn()}
assets={taggedAssets}
projectPath="C:/project"
@@ -3,13 +3,13 @@ import { describe, expect, it } from 'vitest';
import {
type ChatComposerDraft,
chatComposerDraftToDirectCodexUserItem,
directCodexContentToPromptText,
hasMeaningfulDirectCodexContent,
} from '../src/features/project-workspace/resourceReferences';
describe('DirectProject user Response item', () => {
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 做主视觉');
});
});
@@ -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 相关测试和当前里程碑文档。
@@ -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` 通过