收敛输入编辑器单一状态并补齐附件投影
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 1m49s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 1m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m2s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m13s
Project CI / Native shell tests (pull_request) Successful in 7m14s

聊天与快速编辑输入统一由 Lexical 编辑器持有唯一当前状态

App 通过输入句柄读取草稿,移除跨视图 EditorState 和字符串镜像

DirectProject 将附件与图片 sidecar 投影纳入 prompt,支持附件-only 输入

迁移润色测试并更新 DirectProject 里程碑验收记录
This commit is contained in:
2026-09-15 22:13:25 +08:00
parent ec286b3480
commit 87b798322e
15 changed files with 294 additions and 209 deletions
@@ -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,
};
@@ -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<GameCreationAppManifest, String> {
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<GameCreationAppManifest, String> {
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)?;
@@ -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<Value, String> {
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<Value, String> {
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<String, String> {
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::<String>()
})
}
#[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"),
""
);
}
}
@@ -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(
+72 -66
View File
@@ -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<ChatComposerDraft>(() => ({
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<ResourceReferenceInputHandle | null>(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<HTMLFormElement>) {
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<HTMLFormElement>,
) {
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({
<SupervisorChatOnlyView
activeVersionId={chatActiveVersionId}
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
messagesRef={supervisorChatMessagesRef}
@@ -11953,8 +11963,6 @@ export function App({
return (
<ProjectSupervisorView
activeVersionId={chatActiveVersionId}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
@@ -12149,8 +12157,6 @@ export function App({
}
cancelUiCommandConfirmation={cancelUiCommandConfirmation}
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets}
chatInputRef={chatInputRef}
@@ -51,7 +51,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
@@ -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<ResourceReferenceInputHandle | null>;
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
@@ -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<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>;
@@ -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}
/>
@@ -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<ChatComposerDraft, 'text' | 'references'>;
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<HTMLDivElement | null>;
}) {
const [editor] = useLexicalComposerContext();
const lastEmittedDraftRef = useRef<ChatComposerDraft>({
text: '',
references: [],
});
const skipInitialDraftChangeRef = useRef(false);
const [query, setQuery] = useState<string | null>(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<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({ text: value, references });
liveDraftRef.current = { text: value, references };
const liveDraftRef = useRef<ChatComposerDraft>({
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<ResourceMentionOption> = 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}
<OnChangePlugin
onChange={(editorState) => {
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<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}
@@ -1287,7 +1303,12 @@ export const ResourceReferenceInput = forwardRef<
</span>
}
>
<ResourceReferenceEditor {...props} composerRef={ref} rootRef={rootRef} />
<ResourceReferenceEditor
{...props}
initialDraft={initialDraft}
composerRef={ref}
rootRef={rootRef}
/>
</RichTextInput>
);
});
@@ -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<ResourceReferenceInputHandle>;
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
? '告诉陶泥儿接下来要做什么,或输入 @ 选择资源'
@@ -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(
@@ -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 与聊天 `@` 一致。
<div className="resource-canvas-quick-edit-prompt-input">
<ResourceReferenceInput
key={quickEditSourceLayer?.id}
ariaLabel="快速编辑提示词"
value={quickEditPanel.prompt}
references={quickEditReferences}
initialDraft={{
text: quickEditPanel.prompt,
references: [],
}}
onChange={applyResourceQuickEditDraft}
assets={manifest.assets}
projectPath={projectPath}
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"http://localhost"}
import {
cleanup,
fireEvent,
@@ -8,7 +9,7 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { useRef } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
@@ -20,6 +21,7 @@ import {
shouldRemindChatPromptPolish,
writeChatPromptPolishReminderDisabled,
} from '../src/features/project-workspace/chatPromptPolish';
import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
@@ -27,6 +29,17 @@ import {
resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences';
const storage = new Map<string, string>();
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<string, unknown>,
@@ -78,21 +91,30 @@ function ControlledChatComposer({
initialReferences?: ChatReference[];
onSubmitDraft: (draft: ChatComposerDraft) => void;
}) {
const [draft, setDraft] = useState<ChatComposerDraft>({
text: initialText,
references: initialReferences,
});
const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
return (
<form
onSubmit={(event) => {
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);
}}
>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={setDraft}
ref={composerRef}
initialDraft={{ text: initialText, references: initialReferences }}
onChange={() => {}}
assets={[]}
projectPath="C:/project"
ariaLabel="创作想法"
@@ -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<ChatComposerDraft>({
text: '原始需求',
references: [reference],
});
const composerRef = createRef<ResourceReferenceInputHandle>();
return (
<>
<button
type="button"
onClick={() =>
setDraft((current) => ({ ...current, text: '润色后的需求' }))
}
onClick={() => composerRef.current?.replaceText('润色后的需求')}
>
</button>
<ResourceReferenceInput
value={draft.text}
references={draft.references}
onChange={(next) => {
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<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput
value="上一个会话的草稿"
references={[]}
ref={composerRef}
onChange={onChange}
assets={assets}
projectPath="C:/project"
@@ -777,17 +774,8 @@ describe('ResourceReferenceInput', () => {
/>,
);
// 切换 / 重开会话:外部草稿被整体替换
rerender(
<ResourceReferenceInput
value="恢复出来的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
// 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态
composerRef.current?.replaceText('恢复出来的草稿');
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u }));
@@ -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。
## 实现顺序
@@ -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 输入有效
## 证据要求