修复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:
@@ -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 });
|
||||
// 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(
|
||||
|
||||
+112
-64
@@ -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` 通过。
|
||||
|
||||
Reference in New Issue
Block a user