收敛输入编辑器单一状态并补齐附件投影
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
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-7
@@ -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}
|
||||
/>
|
||||
|
||||
+81
-60
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+1
-7
@@ -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 输入有效。
|
||||
|
||||
## 证据要求
|
||||
|
||||
|
||||
Reference in New Issue
Block a user