diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7bf8b2484..3b84c8502 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -1133,7 +1133,6 @@ fn direct_codex_thread_delta_event( ) -> DirectThreadEvent { DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) } - /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// /// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index e33bfae72..dd941c708 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,8 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem, - DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 266f330cc..5a039c020 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart { AgcResourceReference { resource_id: String }, #[serde(rename = "agc_runtime_region_reference")] AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + /// Uploaded project attachment kept inline in canonical content. + #[serde(rename = "agc_attachment_reference")] + AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserAttachmentReferencePart { + pub(crate) name: String, + pub(crate) media_type: String, + #[ts(type = "number")] + pub(crate) size: u64, + pub(crate) local_path: String, + pub(crate) status: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 01f6a6868..8de0f9440 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -20,18 +20,16 @@ pub(crate) fn validate_direct_codex_user_item( if message.id.trim().is_empty() { return Err("DirectProject user item 缺少稳定 id".to_string()); } - if message.content.is_empty() { + // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落 + // 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。 + if !content_has_meaningful_input(&message.content) { return Err("DirectProject user item content 不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } + DirectCodexUserContentPart::InputText { .. } => {} DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; @@ -40,6 +38,18 @@ pub(crate) fn validate_direct_codex_user_item( reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + if reference.name.trim().is_empty() { + return Err("附件缺少文件名".to_string()); + } + if !reference.local_path.trim().is_empty() { + sanitize_attachment_local_path(&reference.local_path) + .ok_or_else(|| "附件项目路径无效".to_string())?; + } + if !matches!(reference.status.trim(), "imported" | "failed") { + return Err("附件状态无效".to_string()); + } + } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { @@ -48,6 +58,14 @@ pub(crate) fn validate_direct_codex_user_item( Ok(()) } +/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 +pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { + content.iter().any(|part| match part { + DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + _ => true, + }) +} + pub(crate) fn validate_resource_id_and_manifest( manifest: &GameCreationAppManifest, resource_id: &str, @@ -86,3 +104,48 @@ fn validate_runtime_region_reference( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::content_has_meaningful_input; + use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + + fn input_text(text: &str) -> DirectCodexUserContentPart { + DirectCodexUserContentPart::InputText { + text: text.to_string(), + } + } + + #[test] + fn only_all_blank_content_counts_as_empty_input() { + // 空数组与「整条只有空白」是同一种空输入。 + assert!(!content_has_meaningful_input(&[])); + assert!(!content_has_meaningful_input(&[input_text(" \n ")])); + assert!(!content_has_meaningful_input(&[ + input_text("\n"), + input_text(" "), + ])); + } + + #[test] + fn whitespace_parts_are_valid_next_to_meaningful_input() { + // 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。 + assert!(content_has_meaningful_input(&[ + input_text("\n"), + input_text("看"), + ])); + assert!(content_has_meaningful_input(&[ + input_text("看"), + input_text("\n\n"), + ])); + } + + #[test] + fn non_text_parts_always_count_as_input() { + assert!(content_has_meaningful_input(&[ + DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }, + ])); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 2ab90cc62..a05daef41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input( summary.push(']'); summary } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + let mut summary = format!( + "[附件:名称={};类型={};大小={} 字节", + reference.name.trim(), + reference.media_type.trim(), + reference.size + ); + if !reference.local_path.trim().is_empty() { + summary.push_str(&format!(";项目路径={}", reference.local_path.trim())); + } + summary.push_str(&format!(";状态={}", reference.status.trim())); + summary.push(']'); + summary + } }; input.push(serde_json::json!({ "type": "text", "text": text })); } @@ -130,7 +144,8 @@ pub(crate) fn direct_codex_user_item_to_prompt( #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input}; + use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use serde_json::json; use std::path::Path; @@ -174,4 +189,70 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn attachment_parts_remain_in_canonical_order_when_projected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + let content = projected["content"].as_array().expect("content array"); + assert_eq!(content.len(), 2); + assert!(content[0]["text"].as_str().unwrap().contains("先看")); + assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); + } + + #[test] + fn whitespace_only_text_parts_survive_validation() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let wire = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect("whitespace-only part next to real text must pass"); + let parts = wire.as_array().expect("wire input array"); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1]["text"].as_str(), Some("\n")); + assert_eq!(parts[2]["text"].as_str(), Some(" ")); + } + + #[test] + fn all_blank_content_is_rejected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let error = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect_err("all-blank content must fail closed"); + assert!(error.contains("不能为空"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index aa9bbe8f7..1e631cd1f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id( pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, - mut user_item: DirectCodexUserItem, + user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - if !attachments.is_empty() { - let attachment_context = - render_direct_codex_user_prompt("", &attachments).map_err(|error| { - audit.finish(false); - error - })?; - let DirectCodexUserItem::Message(message) = &mut user_item; - message.content.push(DirectCodexUserContentPart::InputText { - text: attachment_context, - }); - } + let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]); validate_direct_codex_user_item(root, &user_item).map_err(|error| { audit.finish(false); error diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bfd4cb80d..1d5dc6195 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -291,7 +291,11 @@ import type { } from './features/project-workspace/resourceReferences'; import { chatComposerDraftToDirectCodexUserItem, + directCodexContentToPromptText, + directCodexUserItemFromContent, + hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + resourceReferenceFromAsset, type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; @@ -4069,9 +4073,39 @@ export function App({ } function handleChatComposerChange(draft: ChatComposerDraft) { - setChatInput(draft.text); - setChatReferences(draft.references); - setChatContent(draft.content ?? []); + const content = draft.content ?? []; + setChatContent(content); + setChatInput(directCodexContentToPromptText(content, manifest.assets)); + setChatReferences( + content.flatMap((part) => { + if (part.type === 'agc_resource_reference') { + const asset = manifest.assets.find( + (item) => item.id === part.resourceId, + ); + return asset + ? [resourceReferenceFromAsset(asset, 'asset-picker')] + : []; + } + if (part.type === 'agc_runtime_region_reference') { + return [ + { + type: 'runtime-region' as const, + 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' as const, + }, + ] as ChatReference[]; + } + return [] as ChatReference[]; + }), + ); } useEffect(() => { @@ -6471,12 +6505,13 @@ export function App({ if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - const effectiveUserItem = - userItem ?? - chatComposerDraftToDirectCodexUserItem( - { text: prompt, references: references ?? [], content: [] }, - directCodexConversationMessageId(clientTurnId, 'user'), + if (!userItem) { + setProjectSupervisorRuntimeError( + 'DirectProject 回合缺少 canonical user item,未发送本轮消息。', ); + return; + } + const effectiveUserItem = userItem; if ( !directPolicyChecked && projectConversationWriteConfirmedRef.current !== directProjectPath @@ -6603,7 +6638,6 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; - attachments?: DirectCodexTurnAttachment[]; userItem: ReturnType; } = { projectPath: directProjectPath, @@ -6614,9 +6648,6 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } - if (attachments?.length) { - directTurnInput.attachments = attachments; - } directTurnInput.userItem = effectiveUserItem; // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, // 因此这里只等回合跑完,不接返回值。 @@ -6701,6 +6732,7 @@ export function App({ setDirectCodexTurnCancelling(false); chatAgentBusyRef.current = false; // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 + // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 dispatchNextQueuedChatTurn(); } } @@ -6922,11 +6954,21 @@ export function App({ updatedAt: Date.now(), }, ]); + const initialUserItem = directConversationTurnId + ? directCodexUserItemFromContent( + [ + { type: 'input_text', text: latch.prompt }, + ...attachmentContentParts(latch.attachments), + ], + directCodexConversationMessageId(directConversationTurnId, 'user'), + ) + : undefined; void executeChatAgentReplyRef.current({ prompt: latch.prompt, clientTurnId: directConversationTurnId, creationType: latch.creationType, attachments: latch.attachments, + ...(initialUserItem ? { userItem: initialUserItem } : {}), }); }, [ chatAgentBusy, @@ -11987,36 +12029,28 @@ export function App({ * 消息落盘/回合 id/附件参数走样。 */ function startDirectCodexConversationTurn(input: { - prompt: string; - attachments?: DirectCodexTurnAttachment[]; - references?: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: ReturnType; }) { - const clientTurnId = createDirectCodexConversationTurnId(); + 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: directCodexConversationMessageId(input.clientTurnId, 'user'), updatedAt: Date.now(), }, ]); void executeChatAgentReply({ - prompt: input.prompt, - clientTurnId, - attachments: input.attachments?.length ? input.attachments : undefined, - references: input.references, - userItem: chatComposerDraftToDirectCodexUserItem( - { - text: input.prompt, - references: input.references ?? [], - content: input.content ?? [], - }, - directCodexConversationMessageId(clientTurnId, 'user'), - ), + prompt, + clientTurnId: input.clientTurnId, + userItem: input.userItem, }); } @@ -12077,12 +12111,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: ReturnType; }): boolean { if (isChatTurnQueueFull(chatTurnQueueRef.current)) { setChatComposerNotice(chatQueueFullNotice()); @@ -12091,10 +12137,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); @@ -12129,10 +12173,8 @@ export function App({ setChatComposerNotice(''); } startDirectCodexConversationTurn({ - prompt: next.prompt, - attachments: next.attachments, - references: next.references, - content: next.content, + clientTurnId: next.clientTurnId, + userItem: next.userItem, }); } @@ -12213,6 +12255,14 @@ export function App({ const prompt = chatInput.trim(); const references = chatReferences; const pendingAttachments = chatAttachments; + const content = [ + ...chatContent, + ...attachmentContentParts(pendingAttachments), + ]; + const canonicalPrompt = directCodexContentToPromptText( + content, + manifest.assets, + ); if ( !directCodexProductRuntime && supervisorChatOnly && @@ -12228,23 +12278,20 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ( - !prompt && - references.length === 0 && - chatContent.length === 0 && - pendingAttachments.length === 0 - ) { + if (!hasMeaningfulDirectCodexContent(content)) { return; } if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { + const clientTurnId = createDirectCodexConversationTurnId(); const enqueued = enqueueChatTurnForRunningTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); if (enqueued) { setChatInput(''); @@ -12256,7 +12303,7 @@ export function App({ } return; } - if (directCodexProductRuntime && prompt === '/history') { + if (directCodexProductRuntime && canonicalPrompt === '/history') { const nextProjectPath = requireChatProjectForUserAction(); if (!nextProjectPath) { return; @@ -12268,7 +12315,7 @@ export function App({ return; } if (planningV2ActiveRef.current || planningStartMode) { - if (!prompt || chatAgentBusy) { + if (!canonicalPrompt || chatAgentBusy) { return; } supervisorChatShouldFollowLatestRef.current = true; @@ -12278,13 +12325,13 @@ export function App({ ...current, { role: 'user', - text: prompt, + text: canonicalPrompt, runtimeOwned: true, messageId: `planning-v2:${clientTurnId}:user`, updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, clientTurnId }); + void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId }); return; } if (supervisorChatOnly || directCodexProductRuntime) { @@ -12298,11 +12345,13 @@ export function App({ setChatContent([]); setChatAttachmentNotice(''); setChatComposerNotice(''); + const clientTurnId = createDirectCodexConversationTurnId(); startDirectCodexConversationTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); return; } diff --git a/apps/ai-game-creator-shell/src/components/RichTextInput.tsx b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx new file mode 100644 index 000000000..02c942e50 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx @@ -0,0 +1,102 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { + COMMAND_PRIORITY_HIGH, + type EditorState, + KEY_ENTER_COMMAND, + type Klass, + type LexicalNode, +} from 'lexical'; +import type { ReactElement, ReactNode, Ref } from 'react'; +import { useEffect } from 'react'; + +type RichTextInputProps = { + namespace: string; + nodes: Klass[]; + initialEditorState?: EditorState | null; + contentEditable?: ReactElement; + placeholder?: ReactElement; + containerClassName?: string; + containerRef?: Ref; + disabled?: boolean; + onChange?: (editorState: EditorState) => void; + onEnter?: () => void; + children?: ReactNode; +}; + +function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onEnter) return undefined; + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) return false; + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onEnter]); + + return null; +} + +function SetEditorEditable({ disabled }: { disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor.setEditable(!disabled); + }, [disabled, editor]); + + return null; +} + +export default function RichTextInput({ + namespace, + nodes, + initialEditorState, + contentEditable = , + placeholder, + containerClassName, + containerRef, + disabled = false, + onChange, + onEnter, + children, +}: RichTextInputProps) { + return ( + { + throw error; + }, + }} + > +
+ + {children} + + + {onChange ? : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx index 0aa574dbc..292ddd245 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -19,6 +19,7 @@ import { import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView, @@ -192,9 +193,12 @@ export function ComposerPendingAttachments({ /** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ export function ComposerTurnQueue({ turns, + assets, onCancel, }: { turns: readonly QueuedChatTurn[]; + /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ + assets: readonly GameCreationAppAssetManifestEntry[]; onCancel: (id: string) => void; }) { if (turns.length === 0) { @@ -211,11 +215,11 @@ export function ComposerTurnQueue({ {index + 1} - {queuedChatTurnLabel(turn)} + {queuedChatTurnLabel(turn, assets)} { - onChange(next); - setDraft(next); - }} + ref={composerRef} + initialContent={[ + { type: 'input_text', text: '原始需求' }, + chatReferenceToContentPart(reference), + ]} + onChange={onChange} assets={assets} projectPath="C:/project" ariaLabel="聊天" @@ -228,10 +240,9 @@ describe('ResourceReferenceInput', () => { await settleComposer(); await settleComposer(); - // 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主: - // 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名` - // 会触发下一轮重建,文本一轮轮变长(渲染循环)。 - expect(onChange).not.toHaveBeenCalled(); + // 这次替换是对唯一 EditorState 的明确编辑动作,只产生一次派生快照, + // 不会因为 props 回写而重复触发。 + expect(onChange).toHaveBeenCalledTimes(1); const editorText = document.querySelector('.resource-reference-input-editor')?.textContent ?? ''; @@ -240,13 +251,57 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); + test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); + function Controlled() { + const composerRef = createRef(); + return ( + <> + + + + ); + } + render(); + await settleComposer(); + + fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); + await settleComposer(); + await settleComposer(); + + const draft = onChange.mock.calls.at(-1)?.[0]; + // chip 与后一段各自成段:段落分隔原样留在 canonical content 里(投影不做空白过滤, + // 也不与相邻 part 合并)。丢掉它读回的 prompt 会粘成 `@hero把这一版改成夜景`, + // 并原样进 agent、队列文案与润色判据。 + expect(draft?.content).toEqual([ + chatReferenceToContentPart(reference), + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '把这一版改成夜景' }, + ]); + expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); + }); + test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); function Controlled() { const [draft, setDraft] = useState({ - text: '要一个', - references: [], + content: [{ type: 'input_text', text: '要一个' }], }); return ( { }} > { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - expect(draft?.text).toBe('@hero @enemy'); - expect(draft?.references.map((reference) => reference.resourceId)).toEqual([ - 'hero', - 'enemy', + // canonical content 是编辑器内容的逐字投影:picker 在每个 chip 后插入的分隔空格 + // 也原样进内容,所以派生文本是 `@hero @enemy`(引用本身仍由稳定 resourceId 表达)。 + const heroPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[0]!, 'asset-picker'), + ); + const enemyPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[1]!, 'asset-picker'), + ); + expect(draft?.content).toEqual([ + heroPart, + { type: 'input_text', text: ' ' }, + enemyPart, + { type: 'input_text', text: ' ' }, ]); + expect(draftText(draft)).toBe('@hero @enemy'); + expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).not.toBeNull(); @@ -332,8 +395,6 @@ describe('ResourceReferenceInput', () => { render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { await user.click(screen.getByRole('button', { name: '移除引用 hero' })); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); }); }); @@ -526,8 +585,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(chip?.contains(deleteButton)).toBe(true); // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( - 'hero', - ); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero'); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']); // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 await user.click(deleteButton); @@ -590,8 +643,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', () => { @@ -636,8 +689,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), assets[1]!, @@ -738,8 +786,13 @@ describe('ResourceReferenceInput', () => { ]; render( { document.querySelector('.resource-reference-chip-label')?.textContent, ).toBe('hero-final'); }); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( - 'hero-final', - ); - }); + // 改名只影响显示名:canonical content 仍只记稳定 resourceId。 + expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); @@ -766,10 +816,11 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const { rerender } = render( + const composerRef = createRef(); + render( { />, ); - // 切换 / 重开会话:外部草稿被整体替换。 - rerender( - , - ); + // 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态。 + composerRef.current?.replaceText('恢复出来的草稿'); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + // 恢复的草稿文本 + picker 插入的 chip 与它后面的分隔空格,逐字就是编辑器里的内容。 + expect( + onChange.mock.calls + .at(-1)?.[0] + .content.filter((part) => part.type === 'input_text'), + ).toEqual([ + { type: 'input_text', text: '恢复出来的草稿' }, + { type: 'input_text', text: ' ' }, + ]); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -826,8 +876,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { render( <> { const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0]; // 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同: // 「快速编辑」不允许出现第二种引用格式。 - expect(chatDraft?.text).toBe('@hero'); - expect(quickEditDraft).toEqual(chatDraft); - expect(quickEditDraft?.references).toEqual([ - resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'), + expect(chatDraft?.content).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' ' }, ]); + expect(quickEditDraft).toEqual(chatDraft); expect( document.querySelectorAll('[data-resource-reference-id="hero"]'), ).toHaveLength(2); @@ -973,8 +1015,7 @@ describe('ResourceReferenceInput', () => { test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => { render( { it('保留 Lexical content 的文本与引用交错顺序', () => { const draft: ChatComposerDraft = { - text: '忽略的扁平摘要', - references: [], content: [ { type: 'input_text', text: '先看 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -41,18 +41,9 @@ 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' }, ], }; @@ -68,4 +59,51 @@ describe('DirectProject user Response item', () => { ], }); }); + + it('原样保留纯空白 input_text,不替用户改写提示词', () => { + const draft: ChatComposerDraft = { + content: [ + { type: 'input_text', text: '先看' }, + { type: 'input_text', text: ' ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: '\n' }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, + ).toEqual(draft.content); + }); + + it('只有最终 content 全为空白时才判定为空输入', () => { + expect( + hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]), + ).toBe(false); + expect(hasMeaningfulDirectCodexContent([])).toBe(false); + // 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。 + expect( + hasMeaningfulDirectCodexContent([ + { type: 'input_text', text: ' \n' }, + { type: 'input_text', text: '看' }, + ]), + ).toBe(true); + expect( + hasMeaningfulDirectCodexContent([ + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + ]), + ).toBe(true); + }); + + it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { + expect( + directCodexContentToPromptText( + [ + { type: 'input_text', text: '用 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 做主视觉' }, + ], + [], + ), + ).toBe('用 @asset-hero 做主视觉'); + }); }); diff --git a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md new file mode 100644 index 000000000..6bdbed240 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,42 @@ +# 【实施计划】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md` | +| Status | implemented | +| Owner | Codex | + +## 实施顺序 + +1. 先把主转换函数改为原样传递 `draft.content`,并在 content-only 测试中覆盖空白 part 保留。 +2. 将最终 content 有效性判断抽为纯函数,接入普通提交、队列入队和其它 Direct Codex 入口。 +3. 删除 Direct Codex 的 `userItem` fallback;首页首轮和队列出队直接构造 canonical user item。 +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 相关测试和当前里程碑文档。 +- 不修改:Rust user item schema、附件 DTO、SpacetimeDB、HTTP API、用户已有 `.env` / `package-lock.json` 修改。 +- 不引入:`text` / `references` 到 canonical Direct Codex item 的任何兼容 fallback。 + +## 验证命令 + +- `npm run test -- apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts` +- `npm run ai-game-creator-shell:typecheck` +- `npm run check:encoding` +- `npm run check:doc-index` +- `git diff --check` + +## 风险与回滚点 + +- 空白 `input_text` 不再前端删除,需确保最终有效性判断只拒绝全空 content,不改变其它 part。 +- Direct caller 漏传 user item 时应在类型检查或明确错误分支暴露,不能静默重建。 +- 共享工作树含用户未提交修改;提交时只 stage 本计划和本次代码 hunk。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md index ad2f53f07..c18716d77 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -9,7 +9,8 @@ ## 修改边界 - 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 -- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片上传 DTO、SpacetimeDB、HTTP API;上传 DTO 仅在入口转换为 canonical `content[]`,不再以 sidecar 文本追加。 +- 本切片明确:不根据 MIME 类型判断图片,不生成 `agc_image_reference`;所有上传文件统一映射为 `agc_attachment_reference`。 ## 实现顺序 diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md new file mode 100644 index 000000000..ffd7767f8 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| ----------- | ------------------------------------------------ | +| Version | 1.0 | +| Status | implemented | +| Date | 2026-09-16 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +让 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。 + +## 不在范围内 + +- 不保留从 `draft.text` / `draft.references` 重建 Direct Codex content 的兼容分支。 +- 不新增断言 legacy 字段“不存在”的测试;现有测试直接迁移到 content-only 输入。 +- 不修改 Rust user item schema、SpacetimeDB schema、HTTP API 或历史迁移。 + +## 验收标准 + +- `chatComposerDraftToDirectCodexUserItem` 输出与输入 `draft.content` 顺序和值完全一致。 +- 只有当最终 content 不含非空文本且不含任何非文本 part 时,发送入口才拒绝本轮。 +- Direct Codex 路径不存在 `userItem ?? ...` 或等价 fallback。 +- `ChatComposerDraft` 只有 `content` 一个字段;`text` / `references` 不再是草稿契约的一部分。 +- 首页首轮、普通聊天、队列出队、策略确认重试均发送同一个 canonical user item 语义。 +- 旧 Supervisor/Planning caller 上有明确 TODO,且不进入 Direct Codex canonical 发送路径。 + +## 实现结论 + +- 前端不再做任何空白过滤:Lexical 投影层原样透传编辑器节点,段落分隔符(root 子节点之间补的 `\n`)、软换行、chip 后的分隔空格都各自成 part,既不丢弃也不与相邻 part 合并 —— 前端不替用户改写他输入的内容。 +- 有效输入只判整条 content:有一段非空白文本或任何一个非文本 part 就算有效输入,单个纯空白 `input_text` 合法。前端 `hasMeaningfulDirectCodexContent` 与 Rust `validate_direct_codex_user_item`(`content_has_meaningful_input`)同口径,Rust 侧不再逐个 part 拒绝空文本;`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段;`assets`(当前项目 manifest)必填,`agc_resource_reference` 按 `@显示名` 展开,消息正文与队列 chip 因此逐字一致。 +- 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 + +## 证据 + +- `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔原样落进 content」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only,并按逐字投影断言 content。 +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`:`validation.rs` 的「只有整条 content 全空白才算空输入」与 `wire.rs` 的「单个纯空白 part 通过校验、整条全空白拒绝」用例。 +- AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md index bcf6efc68..7a36a3be7 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -19,13 +19,13 @@ - `agc_runtime_region_reference` 保留运行区域语义摘要。 - Rust 在持久化前完成白名单、manifest 与路径校验。 - 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 -- 保持 assistant 返回、工具 activity、附件/图片协议不变。 +- 保持 assistant 返回、工具 activity 与上传 DTO 不变;DirectProject canonical user item 不再保存附件 sidecar,所有本地上传文件(包括图片)统一作为 `content[]` 中按顺序排列的 `agc_attachment_reference`。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part。 +- 不新增图片专用 content part;上传 DTO 只作为输入适配,不作为历史事实源。 - SpacetimeDB schema 或 HTTP API 变更。 ## 依赖与前置条件 @@ -42,7 +42,7 @@ - [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 - [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 - [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 -- [ ] assistant、附件和工具链路行为无变化。 +- [x] assistant、附件和工具链路行为无变化;所有上传文件按 content 顺序内联,附件-only 输入也能进入 DirectProject prompt。 ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ec473d2c0..e031134b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8893,6 +8893,22 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 关联规范:`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;开发期计划见 `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` 与对应实施计划。 - 验证:Rust 模板库 8 项定向单测、前端模型 9 项单测、AGC `tsc` 类型检查通过;`templates/index.json` 匿名可读且每个 `zipKey` 回读 SHA-256 与清单一致;发布脚本 `scripts/agc-template-library-publish.mjs` 支持 `--dry-run` 与上传后回读校验。 +## 2026-09-16 DirectProject 上传文件统一使用附件 content part + +- 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。 +- 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。 +- 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。 + +## 2026-09-17 DirectProject canonical content 的有效性只判整条 content + +- 决策:canonical user item 的有效输入判据只落在**整条 content** 上——只要有一段非空白文本、或任何一个非文本 part 就算有效输入;单个纯空白 `input_text`(段落分隔、软换行、chip 后的分隔空格)是合法 part。Rust `validate_direct_codex_user_item` 的 `content_has_meaningful_input` 与前端 `hasMeaningfulDirectCodexContent` 同口径,`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 决策:编辑器投影层(`ResourceReferenceInput` 的 `collectDraftParts`)原样透传编辑器节点:不做空白过滤,也不与相邻 part 合并。前端不替用户改写他输入的内容,canonical content 与编辑器内容逐字对应。 +- 决策:content → 可读文本只有 `directCodexContentToPromptText(content, assets)` 一个口径,`assets`(当前项目 manifest)必填:消息正文、队列 chip 文案、润色判据、草稿持久化与出站 prompt 全部由它派生,`agc_resource_reference` 按 `@显示名` 展开,只有素材已不在清单里时才回落 `resourceId`。 +- 原因:`3c7b02b9f` 为了让 content 通过「空 `input_text`」校验而在投影层丢空白 part,把引用后的段落分隔一起丢了(`@素材` 与下一段粘成一个词);`41366dd71` 又把消息 / 队列 / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- 边界:不新增 content part 类型,不迁移历史(历史 content 原样回放),不为旧口径保留兼容分支;前端仍不发整条全空白的一轮,app-server 输入里出现纯空白 text item 由本决定接受。 +- 验证:Rust `validation.rs` / `wire.rs` 用例「单个纯空白 part 通过校验、整条全空白拒绝」;AGC 侧 `resourceReferenceInput.test.tsx`、`resourceReferences.test.ts`、`appSurface/project-development.suite.ts`(Godot 回合)、`projectResourceLiveIntegration.test.tsx` 改为按逐字投影断言,`ai-game-creator-shell:typecheck` 与定向 vitest 通过。 +- 关联规范:`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 + ## 2026-09-16 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核 - 背景:`genarrative-station`(32 逻辑核)上 Jenkins Built-In Node 与 Gitea Actions runner 共用同一宿主。Jenkins `jenkins.service` 原先没有任何 CPU 限制(`cpu.max=max`),构建期 Web / Api / Stdb 三分支并行(Vitest 8 线程 + 两次默认 32 job 的 cargo)把整机顶到 80%~95%;`gitea-runner` 容器 `--cpus=24`(75%)在 push 触发的 CI 波峰里实测峰值 24.8~25.3 核,是同一时间窗里更大的单一消耗方。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f6651d3b1..55471fedd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5671,6 +5671,23 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 +## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词 + +- **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。 +- **原因**:`3c7b02b9f`(2026-09-15)为了让 content 通过 Rust 的「空 `input_text`」校验,在投影层加了 `appendInputText`(`if (text.trim())` 才落 part,并与相邻文本合并)。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘成 `@hero把这一版改成夜景`。`41366dd71` 又把消息正文 / 队列 chip / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- **处理(最终口径)**:不保留任何前端过滤,而是去掉规则和它的成因——Rust `validate_direct_codex_user_item` 改成只判整条 content(`content_has_meaningful_input`:有一段非空白文本或任意非文本 part 即有效),单个纯空白 `input_text` 合法;`ResourceReferenceInput` 的投影原样透传编辑器节点,既不丢空白也不与相邻 part 合并。中间版本(把待写文本「向前合并」到下一个 part)已随之删除:它仍会丢掉尾随换行与「两个 chip 之间只隔一个换行」的分隔,也仍要让前端替用户改写内容。 +- **验证**:Rust `validation.rs` / `wire.rs` 新增「单个纯空白 part 通过校验、整条全空白拒绝」用例;`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`「引用后面的段落分隔原样落进 content」断言 `[ref, { type: 'input_text', text: '\n' }, { type: 'input_text', text: '…' }]` 与派生文本逐字一致;`tests/appSurface/project-development.suite.ts` 的 Godot 用例断言 Shift+Enter 的两个换行各自成 part。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`collectDraftParts`)、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 + +## 2026-09-16 派生文本漏传 `manifest.assets`:`@引用` 从显示名退化成内部 id + +- **现象**:快速编辑面板里 `@` 插了素材再点「修改」,出站 `derive_local_project_resource` 的 `prompt` 是 `把夜色改成星空@source-rules`,而界面 chip 与聊天输入区显示的是 `@rules`;仓库自带的 `projectResourceLiveIntegration` 用例因此长期是红的(`expected '把夜色改成星空@rules' to be '把夜色改成星空@source-rules'`)。同一根因还让排队消息 chip 显示 `@asset:…`。 +- **原因**:`directCodexContentToPromptText(content, assets)` 的 `assets` 有默认值 `[]`,漏传不报错、只是把 `agc_resource_reference` 退化成 `${resourceId}`。`ResourceReferenceInput` 自己读草稿的四处都带了 `manifest.assets`,而它的三个消费方漏传:`chatComposerQueue.queuedChatTurnLabel`(宿主 `ComposerTurnQueue` 也没接素材清单)、`project-development/index.tsx` 的 `applyResourceQuickEditPrompt` 与 `applyResourceQuickEditDraft`;后两个的 `useCallback` 依赖里同样没有 `manifest.assets`,改完还会读到旧清单。 +- **处理**:三个消费方全部补上素材清单并进依赖数组——`queuedChatTurnLabel(turn, assets)` + `ComposerTurnQueue` 新增 `assets` 属性(由 `ProjectSupervisorView` 传 `chatProjectAssets`)、快速编辑的两处改用 `manifest.assets`。改「比较用的草稿文本」与「落进面板的文本」必须同一个口径,否则 `replaceText` 会每次输入都重跑一遍。 +- **加固**:`directCodexContentToPromptText(content, assets)` 与 `queuedChatTurnLabel(turn, assets)` 的 `assets` 改为**必填**(删掉 `= []` 默认值),测试里刻意不传 manifest 的地方显式写 `[]`。理由:默认值把「漏传素材清单」从编译期错误降级成运行期文案退化,正是本条缺陷的入口;队列 chip 与消息正文从此共用同一个派生(`queuedChatTurnLabel` 只多做「压成单行 + 限长」)。 +- **验证**:`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx` 的「快速编辑提示词里能 @ 出资源选择器」由红转绿(该文件 29 passed);`tests/appSurface/chat-composer.suite.ts` 新增「队列 chip 的 @ 引用按 manifest 显示名展开」;`appSurface.test.ts` 467 passed、定向 51 passed、`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding` 通过。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts`、`ComposerControls.tsx`、`ProjectSupervisorView.tsx`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`。 + ## 2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用 - **现象**:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 `authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试`,或平台工具返回 `HTTP 401 invalid-token`;账号并没有切换,重新登录后短时间内可复现。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ca8874954..53f9f46f3 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 +本地上传入口不区分图片和其它文件:用户从同一个文件选择器提交任意附件,前端不做 MIME 类型分流,所有上传文件统一编码为 `agc_attachment_reference`。图片不会因为扩展名或 MIME 类型获得另一种 canonical part;只有未来真正支持 Codex 多模态 `input_image` 投影时,才另行设计图片协议。 + 当前已完成: - 三个聊天入口共用 `ResourceReferenceInput`;