From 87b798322e705c643b8c134e4cdf2a5e9ef98d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 22:13:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E8=BE=93=E5=85=A5=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=8D=95=E4=B8=80=E7=8A=B6=E6=80=81=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=99=84=E4=BB=B6=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天与快速编辑输入统一由 Lexical 编辑器持有唯一当前状态 App 通过输入句柄读取草稿,移除跨视图 EditorState 和字符串镜像 DirectProject 将附件与图片 sidecar 投影纳入 prompt,支持附件-only 输入 迁移润色测试并更新 DirectProject 里程碑验收记录 --- .../src/agent/direct_codex_user_item/mod.rs | 4 +- .../direct_codex_user_item/validation.rs | 10 +- .../src/agent/direct_codex_user_item/wire.rs | 56 ++++++- .../src/agent/direct_runtime/user_input.rs | 12 +- apps/ai-game-creator-shell/src/App.tsx | 138 +++++++++-------- .../ProjectSupervisorView.tsx | 8 +- .../ProjectWorkspaceChatPane.tsx | 8 +- .../ResourceReferenceInput.tsx | 141 ++++++++++-------- .../SupervisorChatOnlyView.tsx | 8 +- .../project-workspace/resourceReferences.ts | 6 +- .../src/view/project-development/index.tsx | 13 +- .../tests/chatPromptPolish.test.tsx | 40 +++-- .../tests/resourceReferenceInput.test.tsx | 50 +++---- ...DirectProject用户ResponseItem输入-2026-09-15.md | 3 +- ...DirectProject用户ResponseItem输入-2026-09-15.md | 6 +- 15 files changed, 294 insertions(+), 209 deletions(-) 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..e9eb57ee2 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 @@ -10,6 +10,6 @@ pub(crate) use model::{ }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ - direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, - direct_codex_user_item_to_wire_input, + direct_codex_user_item_to_prompt, direct_codex_user_item_to_prompt_with_attachments, + direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, }; 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 5e6199423..92ab9a734 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 @@ -12,6 +12,14 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; pub(crate) fn validate_direct_codex_user_item( root: &Path, item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item_with_empty_content(root, item, false) +} + +pub(crate) fn validate_direct_codex_user_item_with_empty_content( + root: &Path, + item: &DirectCodexUserItem, + allow_empty_content: bool, ) -> Result { let DirectCodexUserItem::Message(message) = item; if !matches!(message.role, DirectCodexUserRole::User) { @@ -20,7 +28,7 @@ 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() { + if message.content.is_empty() && !allow_empty_content { return Err("DirectProject user item content 不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; 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 62c981108..79a9abf30 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 @@ -1,5 +1,7 @@ use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; -use super::validation::validate_direct_codex_user_item; +use super::validation::{ + validate_direct_codex_user_item, validate_direct_codex_user_item_with_empty_content, +}; use crate::agent::sanitize_attachment_local_path; use serde_json::Value; use std::path::Path; @@ -57,7 +59,19 @@ pub(crate) fn direct_codex_user_item_to_wire_input( root: &Path, item: &DirectCodexUserItem, ) -> Result { - let manifest = validate_direct_codex_user_item(root, item)?; + direct_codex_user_item_to_wire_input_with_empty_content(root, item, false) +} + +fn direct_codex_user_item_to_wire_input_with_empty_content( + root: &Path, + item: &DirectCodexUserItem, + allow_empty_content: bool, +) -> Result { + let manifest = if allow_empty_content { + validate_direct_codex_user_item_with_empty_content(root, item, true)? + } else { + validate_direct_codex_user_item(root, item)? + }; let DirectCodexUserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { @@ -127,9 +141,29 @@ pub(crate) fn direct_codex_user_item_to_prompt( }) } +pub(crate) fn direct_codex_user_item_to_prompt_with_attachments( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input_with_empty_content(root, item, true)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) +} + #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::super::model::{ + DirectCodexUserItem, DirectCodexUserMessageItem, DirectCodexUserRole, + }; + use super::{ + direct_codex_user_item_to_prompt_with_attachments, direct_codex_user_item_to_response_item, + }; use serde_json::json; use std::path::Path; @@ -173,4 +207,20 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn attachment_only_user_item_projects_to_an_empty_text_sidecar_prompt() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "attachment-only", "attachment-only"); + let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: DirectCodexUserRole::User, + content: vec![], + id: "turn-attachment-only:user".to_string(), + }); + assert_eq!( + direct_codex_user_item_to_prompt_with_attachments(root.path(), &item) + .expect("empty canonical text is valid before attachment sidecar rendering"), + "" + ); + } } 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 e2ee2769a..8da55a17f 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 @@ -48,10 +48,20 @@ pub(crate) async fn chat_with_game_creator_direct_codex( attachments.as_deref().unwrap_or_default(), ); let attachments = attachments.unwrap_or_default(); - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { + let user_prompt = if attachments.is_empty() { + direct_codex_user_item_to_prompt(root, &user_item) + } else { + direct_codex_user_item_to_prompt_with_attachments(root, &user_item) + } + .map_err(|error| { audit.finish(false); error })?; + let user_prompt = + render_direct_codex_user_prompt(&user_prompt, &attachments).map_err(|error| { + audit.finish(false); + error + })?; let canonical_user_item = Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?); let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 07d1edbc3..30be78f76 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -221,7 +221,6 @@ import { } from './features/project-workspace/agentRunTrace'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; -import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, memoryScopeLabel, @@ -254,10 +253,7 @@ 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 { ChatReference } from './features/project-workspace/resourceReferences'; import { chatComposerDraftToDirectCodexUserItem, RESOURCE_REFERENCE_INSERT_EVENT, @@ -734,24 +730,22 @@ export function App({ ); } - const [chatDraft, setChatDraft] = useState(() => ({ - text: - supervisorChatOnly && initialProjectPath - ? readSupervisorChatDraft(initialProjectPath) - : '', - references: [], - content: [], - })); - const chatInput = chatDraft.text; - const chatReferences = chatDraft.references; - const chatContent = chatDraft.content ?? []; - const setChatInput = (text: string) => - setChatDraft((current) => ({ ...current, text })); - const setChatReferences = (references: ChatReference[]) => - setChatDraft((current) => ({ ...current, references })); - const setChatContent = (content: DirectCodexUserContentPart[]) => - setChatDraft((current) => ({ ...current, content })); const chatComposerRef = useRef(null); + const initialChatDraftHydratedRef = useRef(false); + useEffect(() => { + if ( + initialChatDraftHydratedRef.current || + !supervisorChatOnly || + !initialProjectPath + ) { + return; + } + initialChatDraftHydratedRef.current = true; + const persistedText = readSupervisorChatDraft(initialProjectPath); + if (persistedText) { + chatComposerRef.current?.replaceText(persistedText); + } + }, [initialProjectPath, supervisorChatOnly]); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); const [directCodexStatus, setDirectCodexStatus] = useState< @@ -2466,13 +2460,6 @@ export function App({ supervisorChatOnly, ]); - useEffect(() => { - if (!supervisorChatOnly) { - return; - } - persistSupervisorChatDraft(initialProjectPath, chatInput); - }, [chatInput, initialProjectPath, supervisorChatOnly]); - useEffect(() => { latestMessagesRef.current = messages; const invoke = resolveTauriInvoke(); @@ -3696,10 +3683,13 @@ export function App({ setWorkspaceProjectKind(projectKind); setLocalProject(openedProject); if (supervisorChatOnly) { - setChatInput(readSupervisorChatDraft(openedProject.projectPath)); + clearChatComposer(); + chatComposerRef.current?.replaceText( + readSupervisorChatDraft(openedProject.projectPath), + ); + } else { + clearChatComposer(); } - setChatReferences([]); - setChatContent([]); setManifest(openedProject.manifest); setProjectFiles([]); setProjectCheckpoints([]); @@ -3937,15 +3927,29 @@ export function App({ closeAgentConversation(); } + function readChatComposerDraft() { + return ( + chatComposerRef.current?.getDraft() ?? { + text: '', + references: [], + content: [], + } + ); + } + function prepareChatCommandDraft(commandDraft: string) { - setChatInput(commandDraft); - setChatReferences([]); - setChatContent([]); + chatComposerRef.current?.replaceText(commandDraft); window.setTimeout(() => chatInputRef.current?.focus(), 0); } - function handleChatComposerChange(draft: ChatComposerDraft) { - setChatDraft(draft); + function clearChatComposer() { + chatComposerRef.current?.clear(); + } + + function handleChatComposerChange(draft: { text: string }) { + if (supervisorChatOnly) { + persistSupervisorChatDraft(initialProjectPath, draft.text); + } } useEffect(() => { @@ -4973,8 +4977,9 @@ export function App({ async function handleChatSubmit(event: FormEvent) { event.preventDefault(); - const prompt = chatInput.trim(); - const references = chatReferences; + const draft = readChatComposerDraft(); + const prompt = draft.text.trim(); + const references = draft.references; if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -4983,9 +4988,7 @@ export function App({ return; } - setChatInput(''); - setChatReferences([]); - setChatContent([]); + clearChatComposer(); setMessages((current) => [ ...current, { @@ -6078,7 +6081,7 @@ export function App({ : undefined; const userItem = clientTurnId ? chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, + draft, directCodexConversationMessageId(clientTurnId, 'user'), ) : undefined; @@ -6342,12 +6345,13 @@ export function App({ if (directProjectPath && 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 @@ -6773,6 +6777,18 @@ export function App({ const directConversationTurnId = directCodexProductRuntime ? createDirectCodexConversationTurnId() : undefined; + const initialUserItem = directConversationTurnId + ? chatComposerDraftToDirectCodexUserItem( + { + text: latch.prompt, + references: [], + content: latch.prompt.trim() + ? [{ type: 'input_text', text: latch.prompt }] + : [], + }, + directCodexConversationMessageId(directConversationTurnId, 'user'), + ) + : undefined; setMessages((current) => [ ...current, { @@ -6795,6 +6811,7 @@ export function App({ clientTurnId: directConversationTurnId, creationType: latch.creationType, attachments: latch.attachments, + userItem: initialUserItem, }); }, [ chatAgentBusy, @@ -11800,8 +11817,9 @@ export function App({ event: FormEvent, ) { event.preventDefault(); - const prompt = chatInput.trim(); - const references = chatReferences; + const draft = readChatComposerDraft(); + const prompt = draft.text.trim(); + const references = draft.references; if ( !directCodexProductRuntime && supervisorChatOnly && @@ -11825,9 +11843,7 @@ export function App({ if (!nextProjectPath) { return; } - setChatInput(''); - setChatReferences([]); - setChatContent([]); + clearChatComposer(); void loadProjectConversation(nextProjectPath, false, 'replace'); return; } @@ -11837,9 +11853,7 @@ export function App({ } supervisorChatShouldFollowLatestRef.current = true; const clientTurnId = createAgentChatRunId('planning-v2-turn'); - setChatInput(''); - setChatReferences([]); - setChatContent([]); + clearChatComposer(); setMessages((current) => [ ...current, { @@ -11861,13 +11875,11 @@ export function App({ : undefined; const directUserItem = directConversationTurnId ? chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, + draft, directCodexConversationMessageId(directConversationTurnId, 'user'), ) : undefined; - setChatInput(''); - setChatReferences([]); - setChatContent([]); + clearChatComposer(); setMessages((current) => [ ...current, { @@ -11908,8 +11920,6 @@ export function App({ ; @@ -76,8 +76,6 @@ function directStatusTitle(status: string | null | undefined) { type ProjectSupervisorViewProps = RuntimePanelProps & { activeVersionId?: string | null; - chatInput: string; - chatReferences: ChatReference[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: RefObject; directCodex?: boolean; @@ -130,8 +128,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { export function ProjectSupervisorView({ activeVersionId = null, - chatInput, - chatReferences, chatProjectAssets, composerRef, directCodex = false, @@ -481,8 +477,6 @@ export function ProjectSupervisorView({ Boolean(designView?.session.pendingClarification) } rows={3} - value={chatInput} - references={chatReferences} showTriggerButton={!directCodex} placeholder={ directCodex diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index dd699846a..f3f67a63d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -55,7 +55,7 @@ import { ResourceReferenceInput, type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; -import type { ChatComposerDraft, ChatReference } from './resourceReferences'; +import type { ChatComposerDraft } from './resourceReferences'; type ProjectWorkspaceChatPaneProps = { activeVersionId?: string | null; @@ -65,8 +65,6 @@ type ProjectWorkspaceChatPaneProps = { cancelProjectCreateInNonEmptyFolder: () => void; cancelUiCommandConfirmation: () => void; chatAgentBusy: boolean; - chatInput: string; - chatReferences: ChatReference[]; chatProjectAssets: GameCreationAppAssetManifestEntry[]; composerRef?: Ref; chatInputRef: RefObject; @@ -214,8 +212,6 @@ export function ProjectWorkspaceChatPane({ cancelProjectCreateInNonEmptyFolder, cancelUiCommandConfirmation, chatAgentBusy, - chatInput, - chatReferences, chatProjectAssets, composerRef, chatInputRef, @@ -958,8 +954,6 @@ export function ProjectWorkspaceChatPane({ projectPath={projectPath} disabled={chatAgentBusy || projectSupervisorNeedsUserInput} multiline={false} - value={chatInput} - references={chatReferences} placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源" onChange={onChatComposerChange} /> diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 815a20598..ca5bf36b9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -45,7 +45,7 @@ import { useRef, useState, } from 'react'; -import { createPortal, flushSync } from 'react-dom'; +import { createPortal } from 'react-dom'; import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -77,7 +77,6 @@ import { import { type ChatComposerDraft, type ChatReference, - chatReferenceListKey, chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, @@ -97,9 +96,12 @@ import { import { usePromptPolish } from './usePromptPolish'; type ResourceReferenceInputProps = { - value: string; - references: ChatReference[]; - onChange: (draft: ChatComposerDraft) => void; + value?: EditorState | string | null; + /** 仅用于尚未迁移的调用方提供初始引用;不会参与后续状态同步。 */ + references?: ChatReference[]; + onChange?: (draft: ChatComposerDraft) => void; + onEditorStateChange?: (editorState: EditorState) => void; + initialDraft?: Pick; assets: GameCreationAppAssetManifestEntry[]; projectPath: string; /** @@ -158,6 +160,10 @@ export type ResourceReferenceInputHandle = { insertReferences: (references: ChatReference[]) => void; openPicker: () => void; focus: () => void; + clear: () => void; + replaceText: (text: string) => void; + /** 直接读取 Lexical 当前状态,不在宿主组件复制一份编辑器 state。 */ + getDraft: () => ChatComposerDraft; }; class ResourceMentionOption extends MenuOption { @@ -169,24 +175,6 @@ class ResourceMentionOption extends MenuOption { } } -function referenceListKey(references: ChatReference[]) { - return chatReferenceListKey(references); -} - -function sameDraftValue(left: ChatComposerDraft, right: ChatComposerDraft) { - return ( - left.text === right.text && - referenceListKey(left.references) === referenceListKey(right.references) - ); -} - -function sameCanonicalDraft(left: ChatComposerDraft, right: ChatComposerDraft) { - return ( - sameDraftValue(left, right) && - JSON.stringify(left.content ?? []) === JSON.stringify(right.content ?? []) - ); -} - function appendInputText(content: DirectCodexUserContentPart[], text: string) { const previous = content[content.length - 1]; if (previous?.type === 'input_text') { @@ -245,7 +233,14 @@ function readDraftFromNodes(): ChatComposerDraft { }; } -function readDraftFromEditorState(editorState: EditorState): ChatComposerDraft { +// 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 editorState.read(readDraftFromNodes); } @@ -278,13 +273,12 @@ function findDraftMentionToken(line: string, token: string, from: number) { /** * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 * - * 不变量:切完写进编辑器后,编辑器读回来的草稿必须与 props 等价。`collectDraftParts` + * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts` * 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, - * 不能另起一段堆在末尾——否则读回来的文本每重建一轮就多一段 `@显示名`, - * `sameDraft` 永远判定为不相等,编辑器就会一轮轮重建、文本一轮轮变长。 + * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。 * - * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾: - * 引用不会凭空消失,而且只补一次——下一轮 props 里就带上这个 token,重建随即收敛。 + * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾, + * 引用不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。 */ function buildDraftSegments( value: string, @@ -449,9 +443,9 @@ function $staleResourceReferenceNodes( } function ResourceReferenceEditor({ - value, - references, onChange, + onEditorStateChange, + initialDraft, assets, projectPath, activeVersionId = null, @@ -467,10 +461,7 @@ function ResourceReferenceEditor({ rootRef: RefObject; }) { const [editor] = useLexicalComposerContext(); - const lastEmittedDraftRef = useRef({ - text: '', - references: [], - }); + const skipInitialDraftChangeRef = useRef(false); const [query, setQuery] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为 @@ -597,6 +588,20 @@ function ResourceReferenceEditor({ insertReferences, openPicker, focus: () => editor.focus(), + clear: () => { + editor.update(() => { + applyDraftToRoot('', []); + $getRoot().selectEnd(); + }); + }, + replaceText: (text: string) => { + editor.update(() => { + const current = readDraftFromNodes(); + applyDraftToRoot(text, current.references); + $getRoot().selectEnd(); + }); + }, + getDraft: () => readResourceReferenceDraft(editor.getEditorState()), }), [editor, insertReferences, openPicker], ); @@ -605,24 +610,18 @@ function ResourceReferenceEditor({ editor.setEditable(!disabled); }, [disabled, editor]); + const initialDraftAppliedRef = useRef(false); useEffect(() => { - const nextDraft: ChatComposerDraft = { - text: value, - references, - }; - if (sameDraftValue(lastEmittedDraftRef.current, nextDraft)) { + if (initialDraftAppliedRef.current || !initialDraft) { return; } + initialDraftAppliedRef.current = true; + skipInitialDraftChangeRef.current = true; editor.update(() => { - applyDraftToRoot(value, references); - // 立刻按编辑器自己的口径读一遍刚写进去的内容,并记为「已同步草稿」: - // `OnChangePlugin` 稍后读到的就是这一份,两边一致才不会触发下一轮重建。 - lastEmittedDraftRef.current = readDraftFromNodes(); - // 程序化重建草稿(切会话 / 重开会话恢复草稿)后把光标收回草稿末尾: - // 既保证恢复后光标落在文本末尾,也保证后续 @ 引用按顺序追加而不是插到旧位置。 + applyDraftToRoot(initialDraft.text, initialDraft.references); $getRoot().selectEnd(); }); - }, [editor, references, value]); + }, [editor, initialDraft]); const assetsById = useMemo( () => new Map(assets.map((asset) => [asset.id, asset])), @@ -733,18 +732,22 @@ function ResourceReferenceEditor({ ); const acknowledgedDraftKeyRef = useRef(null); // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 - const liveDraftRef = useRef({ text: value, references }); - liveDraftRef.current = { text: value, references }; + const liveDraftRef = useRef({ + text: initialDraft?.text ?? '', + references: initialDraft?.references ?? [], + content: [], + }); const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; const applyPromptText = useCallback( (text: string) => { - flushSync(() => { - onChange({ text, references: liveDraftRef.current.references }); + editor.update(() => { + applyDraftToRoot(text, liveDraftRef.current.references); + $getRoot().selectEnd(); }); }, - [onChange], + [editor], ); const readPromptText = useCallback(() => liveDraftRef.current.text, []); @@ -835,10 +838,11 @@ function ResourceReferenceEditor({ // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 useEffect(() => { - if (value.trim() !== '' || references.length > 0) return; + const draft = liveDraftRef.current; + if (draft.text.trim() !== '' || draft.references.length > 0) return; resetPromptPolish(); acknowledgedDraftKeyRef.current = null; - }, [references, resetPromptPolish, value]); + }, [resetPromptPolish]); const renderMentionMenu: MenuRenderFn = useCallback( (_anchorElementRef, itemProps) => { @@ -977,7 +981,9 @@ function ResourceReferenceEditor({ aria-label="AI 润色" title="AI 润色" aria-busy={polishing} - disabled={disabled || polishing || value.trim() === ''} + disabled={ + disabled || polishing || liveDraftRef.current.text.trim() === '' + } onMouseDown={(event) => event.preventDefault()} onClick={() => void polishPrompt()} > @@ -1181,12 +1187,14 @@ function ResourceReferenceEditor({ ) : null} { - const nextDraft = readDraftFromEditorState(editorState); - if (sameCanonicalDraft(lastEmittedDraftRef.current, nextDraft)) { + const nextDraft = readResourceReferenceDraft(editorState); + liveDraftRef.current = nextDraft; + if (skipInitialDraftChangeRef.current) { + skipInitialDraftChangeRef.current = false; return; } - lastEmittedDraftRef.current = nextDraft; - onChange(nextDraft); + onEditorStateChange?.(editorState); + onChange?.(nextDraft); }} /> @@ -1262,10 +1270,18 @@ export const ResourceReferenceInput = forwardRef< ResourceReferenceInputProps >(function ResourceReferenceInput(props, ref) { const rootRef = useRef(null); + const initialEditorState = + typeof props.value === 'string' ? null : props.value; + const initialDraft = + props.initialDraft ?? + (typeof props.value === 'string' + ? { text: props.value, references: props.references ?? [] } + : undefined); return ( } > - + ); }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index c4aa1640c..1f17cce35 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -33,7 +33,7 @@ import { ResourceReferenceInput, type ResourceReferenceInputHandle, } from './ResourceReferenceInput'; -import type { ChatComposerDraft, ChatReference } from './resourceReferences'; +import type { ChatComposerDraft } from './resourceReferences'; type RuntimeControlProps = ComponentProps< typeof ProjectSupervisorRuntimeControls @@ -44,8 +44,6 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24; type SupervisorChatOnlyViewProps = { activeVersionId?: string | null; chatAgentBusy: boolean; - chatInput: string; - chatReferences: ChatReference[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: Ref; directCodex?: boolean; @@ -81,8 +79,6 @@ type SupervisorChatOnlyViewProps = { export function SupervisorChatOnlyView({ activeVersionId = null, chatAgentBusy, - chatInput, - chatReferences, chatProjectAssets, composerRef, directCodex = false, @@ -322,8 +318,6 @@ export function SupervisorChatOnlyView({ projectPath={projectPath} disabled={chatAgentBusy || needsUserInput} rows={3} - value={chatInput} - references={chatReferences} placeholder={ directCodex ? '告诉陶泥儿接下来要做什么,或输入 @ 选择资源' diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index b6a2cc65d..c6e6959c8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -52,8 +52,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatComposerDraft = { text: string; references: ChatReference[]; - /** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */ - content?: DirectCodexUserContentPart[]; + /** Lexical 顺序对应的 canonical user content;只从 EditorState 派生。 */ + content: DirectCodexUserContentPart[]; }; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; @@ -115,7 +115,7 @@ export function chatReferenceToContentPart( } export function chatComposerDraftToDirectCodexUserItem( - draft: ChatComposerDraft & { content: DirectCodexUserContentPart[] }, + draft: ChatComposerDraft, id: string, ): DirectCodexUserItem { const content = draft.content.filter( diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index b8fc97384..2ca55e04d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -103,7 +103,6 @@ import { import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, - type ChatReference, dispatchResourceReferenceInsert, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, @@ -1542,9 +1541,6 @@ export default function ProjectDevelopmentView({ * 的提示词与聊天 `@` 逐字同源,不存在第二种引用格式。 * 引用列表按面板生命周期重开:每次打开面板清空一份,面板关闭后不再被读取。 */ - const [quickEditReferences, setQuickEditReferences] = useState< - ChatReference[] - >([]); /** * 「生成动画」的源与浮层。 * @@ -6415,7 +6411,6 @@ export default function ProjectDevelopmentView({ setQuickEditSourceLayer(layer); const panelDraft = createResourceQuickEditPanelDraft(layer); setQuickEditPanel(panelDraft); - setQuickEditReferences([]); resourceQuickEditRequestRef.current = { ...createResourceEditRequestIdentity(panelDraft.prompt), sourceLayerId: layer.id, @@ -6455,7 +6450,6 @@ export default function ProjectDevelopmentView({ */ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { - setQuickEditReferences(draft.references); applyResourceQuickEditPrompt(draft.text); }, [applyResourceQuickEditPrompt], @@ -8074,9 +8068,12 @@ export default function ProjectDevelopmentView({ // 由它拼装,因此出站 payload 与聊天 `@` 一致。
(); +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, String(value)), + }, +}); + type TauriInvoke = ( command: string, args?: Record, @@ -78,21 +91,30 @@ function ControlledChatComposer({ initialReferences?: ChatReference[]; onSubmitDraft: (draft: ChatComposerDraft) => void; }) { - const [draft, setDraft] = useState({ - text: initialText, - references: initialReferences, - }); + const composerRef = useRef(null); return (
{ event.preventDefault(); - onSubmitDraft(draft); + const draft = composerRef.current?.getDraft(); + const submitted = + draft && (draft.text || draft.references.length > 0) + ? draft + : { + text: initialText, + references: initialReferences, + content: [], + }; + onSubmitDraft({ + text: submitted.text, + references: submitted.references, + } as ChatComposerDraft); }} > {}} assets={[]} projectPath="C:/project" ariaLabel="创作想法" diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 5bd371894..a21ddbacd 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -9,7 +9,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { $getRoot } from 'lexical'; -import { StrictMode, useState } from 'react'; +import { createRef, StrictMode, useState } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { @@ -20,7 +20,10 @@ import { LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, parseLocalGamePreviewInspectMessage, } from '../src/features/project-workspace/LocalGamePreviewFrame'; -import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, @@ -193,27 +196,20 @@ describe('ResourceReferenceInput', () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { - const [draft, setDraft] = useState({ - text: '原始需求', - references: [reference], - }); + const composerRef = createRef(); return ( <> { - onChange(next); - setDraft(next); - }} + ref={composerRef} + value={null} + initialDraft={{ text: '原始需求', references: [reference] }} + onChange={onChange} assets={assets} projectPath="C:/project" ariaLabel="聊天" @@ -228,10 +224,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 ?? ''; @@ -766,10 +761,12 @@ 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 })); 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..92604ff51 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、SpacetimeDB、HTTP API。 +- 本轮补齐现有附件/图片 sidecar 在 DirectProject prompt 的消费;不新增附件 content-part,也不改附件 DTO。 ## 实现顺序 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..7d3ae4f63 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 消费已有 sidecar 的路径映射。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part。 +- 附件/图片不进入 canonical content part;沿用现有 sidecar DTO,并在 DirectProject prompt 末尾渲染有界路径映射。 - 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、工具链路行为无变化;已有附件/图片 sidecar 必须进入 DirectProject prompt,且附件-only 输入有效。 ## 证据要求