Compare commits

..

27 Commits

Author SHA1 Message Date
k88936 46e3cd9744 内联 DirectProject 附件与图片内容
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m1s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m41s
Project CI / Native shell tests (pull_request) Successful in 6m9s
新增附件与图片 canonical content part 及 ts-rs 绑定

移除 DirectProject Tauri 附件 sidecar 追加路径

修正前端引用字段与生成类型适配

补充附件图片顺序投影测试并同步里程碑文档
2026-09-16 13:03:11 +08:00
k88936 5fb3db662b 合并主线并保留 DirectProject 输入行为
合并主线回合队列、工具流展示与活动回合管理

保留 Lexical 单一输入状态及附件图片 sidecar 投影

收敛 DirectProject user item 校验、历史恢复与图片预算

同步输入链路里程碑和历史异常恢复文档
2026-09-16 11:27:09 +08:00
lhk229 f7a6235012 Merge pull request 'Fix/ci' (#381) from fix/ci into master
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m48s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m35s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m19s
Project CI / AI game creator shell Rust crates (push) Successful in 2m1s
Project CI / Frontend tests (push) Successful in 4m50s
Project CI / Repository checks (push) Successful in 3m48s
Project CI / Native shell tests (push) Successful in 6m24s
Project CI / Backend tests (push) Successful in 7m2s
Project CI / AI game creator shell web tests (push) Successful in 2m14s
2026-09-16 07:23:58 +08:00
k88936 87b798322e 收敛输入编辑器单一状态并补齐附件投影
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 里程碑验收记录
2026-09-15 22:13:25 +08:00
k88936 ec286b3480 抽取通用富文本输入组件
新增可复用的 RichTextInput Lexical 输入外壳。

让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。

保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。

统一聊天草稿 canonical content 状态并更新资源引用测试。
2026-09-15 19:42:37 +08:00
k88936 35733f0e33 过滤 canonical 草稿中的空白输入 part
在统一 user item 转换入口移除纯空白 input_text,覆盖恢复草稿和所有提交路径。
2026-09-15 17:54:00 +08:00
k88936 3c7b02b9f8 过滤 DirectProject 编辑器的空白内容 part
跳过纯空白文本节点并合并相邻文本,避免引用后的分隔空格和换行触发 Rust 空输入校验。
2026-09-15 17:53:33 +08:00
k88936 f2030a616f 复用 DirectProject 用户输入校验读取的 manifest
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / Frontend tests (pull_request) Failing after 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m21s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m17s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m19s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 10s
让校验返回已读取的 manifest,wire 投影复用同一份数据,避免一次输入重复读取项目清单。
2026-09-15 17:33:49 +08:00
k88936 b3b5d77990 按运行模式创建 DirectProject 回合输入
仅在 DirectCodex 产品运行时生成 direct clientTurnId 和 user item,避免旧 Supervisor 与策划链路复用错误身份。
2026-09-15 17:32:46 +08:00
k88936 1da106af7d 避免重复校验 DirectProject 用户输入
复用 canonical 到 prompt 投影函数中的校验,删除不可达空 prompt 分支。
2026-09-15 17:29:08 +08:00
k88936 cd2edd6966 移除未使用的 DirectProject 输入信封类型
删除未被调用且未生成到前端绑定的 DirectCodexUserMessageEnvelope,收窄内部 API。
2026-09-15 17:27:42 +08:00
k88936 22e830ff93 避免 DirectProject 回合输入的无必要克隆
在互斥的观察器分支中移动 optional user item,避免深层 JSON 克隆。
2026-09-15 17:26:34 +08:00
k88936 c266ae7b50 清理策划模式提交后的聊天草稿
规划 V2 提交时同步清空资源引用和 canonical content,避免切回 DirectProject 后复用旧输入。
2026-09-15 17:25:15 +08:00
k88936 a543b75cf7 拆开聊天草稿内容分支判断
将资源草稿转换的三种回退路径改为显式分支,保持现有优先级并提升可读性。
2026-09-15 17:24:05 +08:00
k88936 f213987f9a 复用资源引用的统一内容映射
让 Lexical 草稿读取复用 chatReferenceToContentPart,避免引用字段映射分叉。
2026-09-15 17:22:55 +08:00
k88936 2c623bb577 移除 DirectProject 回合输入的重复赋值
删除已在对象初始化时设置的 userItem 重复赋值。
2026-09-15 17:19:17 +08:00
k88936 3353906e6f 修正 Response item content 类型投影
区分 turn/start 的 text 与历史 message 的 input_text

将 AGC 私有引用转换为标准 input_text

新增回归测试避免未知 variant text
2026-09-15 16:57:04 +08:00
k88936 17716347e2 收紧历史测试与注释口径
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m17s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m43s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 7m29s
移除 legacy 投影描述

将历史测试统一为 Response item 合同
2026-09-15 16:16:03 +08:00
k88936 9ad66a67a3 接入前端 canonical 用户 Response item
按 Lexical 顺序构造 message.content

DirectProject payload 改为发送 userItem

策略重试保留同一 canonical item
2026-09-15 16:12:56 +08:00
k88936 ba3aa3ccdb 更新用户 Response item 里程碑进度
标记 canonical、wire 与失败关闭验收项

同步实施计划为进行中状态
2026-09-15 16:09:28 +08:00
k88936 2b38eaafba 补充前端用户 Response item 契约测试
覆盖 Lexical 文本与引用交错顺序

验证资源引用只发送稳定 resourceId
2026-09-15 16:08:34 +08:00
k88936 7d5b9071e7 删除旧 DirectProject 引用协议
移除 DirectCodexTurnReference 平行 DTO 与渲染路径

将引用数量上限收回 canonical user item 校验模块
2026-09-15 16:01:45 +08:00
k88936 9e83f1d88c 补齐 Response item 严格合同测试
拒绝资源引用额外身份字段与未知 content part

补充历史 item wire 投影边界测试

同步 DirectProject 历史文档为无 fallback 合同
2026-09-15 15:55:15 +08:00
k88936 7e35d7c344 严格使用 DirectProject Response item 历史
移除 game-creator-conversation.v1 读取 fallback

历史注入前逐项投影 canonical user item 为 Codex message

无效 item 在持久化前失败关闭
2026-09-15 15:42:27 +08:00
k88936 5ec40c8b83 抽离 DirectProject 身份解析模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m20s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m4s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 1m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m22s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Successful in 5m27s
将项目 canonical identity 与 digest 放入独立文件

保持 Codex app-server 线程池调用接口不变
2026-09-15 15:22:39 +08:00
k88936 948a80fc49 重构 DirectProject Agent 深模块
将 direct_runtime 与 codex_app_server 改为目录模块

拆分 user item 的模型、校验与 wire 投影文件

抽离 DirectProject 历史注入载荷构造 seam
2026-09-15 15:19:17 +08:00
k88936 dfd6fadedf 新增 DirectProject 用户 Response item 契约
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m31s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m46s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Native shell tests (pull_request) Successful in 6m35s
新增 Rust canonical user item 与 Codex wire 转换模块

增加 ts-rs 绑定目录及生成文件检查边界

更新聊天素材引用规范与里程碑实施计划
2026-09-15 14:45:58 +08:00
27 changed files with 532 additions and 386 deletions
@@ -5,8 +5,9 @@ mod validation;
mod wire; mod wire;
pub(crate) use model::{ pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
}; };
pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{ pub(crate) use wire::{
@@ -36,6 +36,23 @@ pub(crate) enum DirectCodexUserContentPart {
AgcResourceReference { resource_id: String }, AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")] #[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
#[serde(rename = "agc_image_reference")]
AgcImageReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
@@ -40,6 +40,19 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?; validate_runtime_region_reference(&manifest, reference)?;
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference)
| DirectCodexUserContentPart::AgcImageReference(reference) => {
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
if !reference.local_path.trim().is_empty() {
sanitize_attachment_local_path(&reference.local_path)
.ok_or_else(|| "附件项目路径无效".to_string())?;
}
if !matches!(reference.status.trim(), "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
} }
} }
if reference_count > MAX_DIRECT_CODEX_REFERENCES { if reference_count > MAX_DIRECT_CODEX_REFERENCES {
@@ -100,6 +100,21 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
summary.push(']'); summary.push(']');
summary summary
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference)
| DirectCodexUserContentPart::AgcImageReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
}; };
input.push(serde_json::json!({ "type": "text", "text": text })); input.push(serde_json::json!({ "type": "text", "text": text }));
} }
@@ -174,4 +189,28 @@ mod tests {
.expect_err("history item without type must fail"); .expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}"); assert!(error.contains("缺少 type"), "{error}");
} }
#[test]
fn attachment_and_image_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_image_reference", "name": "hero.png", "mediaType": "image/png", "size": 12, "localPath": "assets/hero.png", "status": "imported"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 3);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("hero.png"));
assert!(content[2]["text"].as_str().unwrap().contains("notes.txt"));
}
} }
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex( pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String, project_path: String,
prompt: String, prompt: String,
mut user_item: DirectCodexUserItem, user_item: DirectCodexUserItem,
creation_type: Option<String>, creation_type: Option<String>,
client_turn_id: Option<String>, client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> { ) -> Result<String, String> {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?; })?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start( let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
validate_direct_codex_user_item(root, &user_item).map_err(|error| { validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false); audit.finish(false);
error error
+82 -60
View File
@@ -891,16 +891,9 @@ export function App({
); );
} }
const [chatInput, setChatInput] = useState(() =>
supervisorChatOnly && initialProjectPath
? readSupervisorChatDraft(initialProjectPath)
: '',
);
const [chatReferences, setChatReferences] = useState<ChatReference[]>([]);
/** /**
* direct-codex chip * direct-codex chip
* `chat_with_game_creator_direct_codex` `attachments` * canonical user item `content[]`
*
*/ */
const [chatAttachments, setChatAttachments] = useState< const [chatAttachments, setChatAttachments] = useState<
DirectCodexTurnAttachment[] DirectCodexTurnAttachment[]
@@ -914,9 +907,6 @@ export function App({
const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = const [directCodexTurnCancelling, setDirectCodexTurnCancelling] =
useState(false); useState(false);
const queuedChatTurnSequenceRef = useRef(0); const queuedChatTurnSequenceRef = useRef(0);
const [chatContent, setChatContent] = useState<DirectCodexUserContentPart[]>(
[],
);
const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null); const chatComposerRef = useRef<ResourceReferenceInputHandle | null>(null);
/** /**
* `localPath` **** * `localPath` ****
@@ -924,7 +914,6 @@ export function App({
*/ */
useEffect(() => { useEffect(() => {
setChatAttachments([]); setChatAttachments([]);
setChatContent([]);
setChatAttachmentNotice(''); setChatAttachmentNotice('');
setChatComposerNotice(''); setChatComposerNotice('');
setChatTurnQueue([]); setChatTurnQueue([]);
@@ -3090,8 +3079,9 @@ export function App({
if (!supervisorChatOnly) { if (!supervisorChatOnly) {
return; return;
} }
persistSupervisorChatDraft(initialProjectPath, chatInput); const draft = chatComposerRef.current?.getDraft();
}, [chatInput, initialProjectPath, supervisorChatOnly]); persistSupervisorChatDraft(initialProjectPath, draft?.text ?? '');
}, [initialProjectPath, supervisorChatOnly]);
useEffect(() => { useEffect(() => {
latestMessagesRef.current = messages; latestMessagesRef.current = messages;
@@ -4381,10 +4371,11 @@ export function App({
setWorkspaceProjectKind(projectKind); setWorkspaceProjectKind(projectKind);
setLocalProject(openedProject); setLocalProject(openedProject);
if (supervisorChatOnly) { if (supervisorChatOnly) {
setChatInput(readSupervisorChatDraft(openedProject.projectPath)); chatComposerRef.current?.replaceText(
readSupervisorChatDraft(openedProject.projectPath),
);
} }
setChatReferences([]); clearChatComposer();
setChatContent([]);
setManifest(openedProject.manifest); setManifest(openedProject.manifest);
setProjectFiles([]); setProjectFiles([]);
setProjectCheckpoints([]); setProjectCheckpoints([]);
@@ -4623,16 +4614,28 @@ export function App({
} }
function prepareChatCommandDraft(commandDraft: string) { function prepareChatCommandDraft(commandDraft: string) {
setChatInput(commandDraft); chatComposerRef.current?.replaceText(commandDraft);
setChatReferences([]);
setChatContent([]);
window.setTimeout(() => chatInputRef.current?.focus(), 0); window.setTimeout(() => chatInputRef.current?.focus(), 0);
} }
function readChatComposerDraft(): ChatComposerDraft {
return (
chatComposerRef.current?.getDraft() ?? {
text: '',
references: [],
content: [],
}
);
}
function clearChatComposer() {
chatComposerRef.current?.clear();
}
function handleChatComposerChange(draft: ChatComposerDraft) { function handleChatComposerChange(draft: ChatComposerDraft) {
setChatInput(draft.text); if (supervisorChatOnly) {
setChatReferences(draft.references); persistSupervisorChatDraft(initialProjectPath, draft.text);
setChatContent(draft.content ?? []); }
} }
useEffect(() => { useEffect(() => {
@@ -5660,8 +5663,9 @@ export function App({
async function handleChatSubmit(event: FormEvent<HTMLFormElement>) { async function handleChatSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const prompt = chatInput.trim(); const draft = readChatComposerDraft();
const references = chatReferences; const prompt = draft.text.trim();
const references = draft.references;
if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) {
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
return; return;
@@ -5670,9 +5674,7 @@ export function App({
return; return;
} }
setChatInput(''); clearChatComposer();
setChatReferences([]);
setChatContent([]);
setMessages((current) => [ setMessages((current) => [
...current, ...current,
{ {
@@ -6762,7 +6764,7 @@ export function App({
const clientTurnId = createDirectCodexConversationTurnId(); const clientTurnId = createDirectCodexConversationTurnId();
const userItem = chatComposerDraftToDirectCodexUserItem( const userItem = chatComposerDraftToDirectCodexUserItem(
{ text: prompt, references, content: chatContent }, draft,
directCodexConversationMessageId(clientTurnId, 'user'), directCodexConversationMessageId(clientTurnId, 'user'),
); );
void executeChatAgentReply({ prompt, references, userItem, clientTurnId }); void executeChatAgentReply({ prompt, references, userItem, clientTurnId });
@@ -7032,12 +7034,32 @@ export function App({
if (directProjectPath && directProjectId && directInvoke) { if (directProjectPath && directProjectId && directInvoke) {
const clientTurnId = const clientTurnId =
directConversationTurnId ?? createDirectCodexConversationTurnId(); directConversationTurnId ?? createDirectCodexConversationTurnId();
const effectiveUserItem = const baseUserItem =
userItem ?? userItem ??
chatComposerDraftToDirectCodexUserItem( chatComposerDraftToDirectCodexUserItem(
{ text: prompt, references: references ?? [], content: [] }, { text: prompt, references: references ?? [], content: [] },
directCodexConversationMessageId(clientTurnId, 'user'), directCodexConversationMessageId(clientTurnId, 'user'),
); );
const effectiveUserItem = attachments?.length
? {
...baseUserItem,
content: [
...baseUserItem.content,
...attachments.map((attachment) => ({
type: attachment.mediaType.toLowerCase().startsWith('image/')
? ('agc_image_reference' as const)
: ('agc_attachment_reference' as const),
name: attachment.name,
mediaType: attachment.mediaType,
size: attachment.size ?? 0,
localPath: attachment.localPath ?? '',
status:
attachment.status ??
(attachment.localPath ? 'imported' : 'failed'),
})),
],
}
: baseUserItem;
if ( if (
!directPolicyChecked && !directPolicyChecked &&
projectConversationWriteConfirmedRef.current !== directProjectPath projectConversationWriteConfirmedRef.current !== directProjectPath
@@ -7178,7 +7200,6 @@ export function App({
prompt: string; prompt: string;
clientTurnId: string; clientTurnId: string;
creationType?: HomeCreationType; creationType?: HomeCreationType;
attachments?: DirectCodexTurnAttachment[];
userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>; userItem: ReturnType<typeof chatComposerDraftToDirectCodexUserItem>;
} = { } = {
projectPath: directProjectPath, projectPath: directProjectPath,
@@ -7189,9 +7210,6 @@ export function App({
if (creationType) { if (creationType) {
directTurnInput.creationType = creationType; directTurnInput.creationType = creationType;
} }
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
directTurnInput.userItem = effectiveUserItem; directTurnInput.userItem = effectiveUserItem;
const reply = await withDirectCodexSessionRefresh(() => { const reply = await withDirectCodexSessionRefresh(() => {
// 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。
@@ -12592,6 +12610,20 @@ export function App({
content?: DirectCodexUserContentPart[]; content?: DirectCodexUserContentPart[];
}) { }) {
const clientTurnId = createDirectCodexConversationTurnId(); const clientTurnId = createDirectCodexConversationTurnId();
const attachmentContent: DirectCodexUserContentPart[] = (
input.attachments ?? []
).map((attachment) => ({
type: attachment.mediaType.toLowerCase().startsWith('image/')
? ('agc_image_reference' as const)
: ('agc_attachment_reference' as const),
name: attachment.name,
mediaType: attachment.mediaType,
size: attachment.size ?? 0,
localPath: attachment.localPath ?? '',
status:
attachment.status ?? (attachment.localPath ? 'imported' : 'failed'),
}));
const content = [...(input.content ?? []), ...attachmentContent];
supervisorChatShouldFollowLatestRef.current = true; supervisorChatShouldFollowLatestRef.current = true;
setMessages((current) => [ setMessages((current) => [
...current, ...current,
@@ -12606,16 +12638,18 @@ export function App({
void executeChatAgentReply({ void executeChatAgentReply({
prompt: input.prompt, prompt: input.prompt,
clientTurnId, clientTurnId,
attachments: input.attachments?.length ? input.attachments : undefined,
references: input.references, references: input.references,
userItem: chatComposerDraftToDirectCodexUserItem( userItem: chatComposerDraftToDirectCodexUserItem(
{ {
text: input.prompt, text: input.prompt,
references: input.references ?? [], references: input.references ?? [],
content: input.content ?? [], content,
}, },
directCodexConversationMessageId(clientTurnId, 'user'), directCodexConversationMessageId(clientTurnId, 'user'),
), ),
// DirectProject 的附件已经是 canonical content part;不能再作为 sidecar
// 传给 Rust,否则会重复追加。
attachments: undefined,
}); });
} }
@@ -12784,8 +12818,10 @@ export function App({
event: FormEvent<HTMLFormElement>, event: FormEvent<HTMLFormElement>,
) { ) {
event.preventDefault(); event.preventDefault();
const prompt = chatInput.trim(); const draft = readChatComposerDraft();
const references = chatReferences; const prompt = draft.text.trim();
const references = draft.references;
const content = draft.content ?? [];
const pendingAttachments = chatAttachments; const pendingAttachments = chatAttachments;
if ( if (
!directCodexProductRuntime && !directCodexProductRuntime &&
@@ -12805,7 +12841,7 @@ export function App({
if ( if (
!prompt && !prompt &&
references.length === 0 && references.length === 0 &&
chatContent.length === 0 && content.length === 0 &&
pendingAttachments.length === 0 pendingAttachments.length === 0
) { ) {
return; return;
@@ -12818,12 +12854,10 @@ export function App({
prompt, prompt,
attachments: pendingAttachments, attachments: pendingAttachments,
references, references,
content: chatContent, content,
}); });
if (enqueued) { if (enqueued) {
setChatInput(''); clearChatComposer();
setChatContent([]);
setChatReferences([]);
setChatAttachments([]); setChatAttachments([]);
setChatAttachmentNotice(''); setChatAttachmentNotice('');
} }
@@ -12835,9 +12869,7 @@ export function App({
if (!nextProjectPath) { if (!nextProjectPath) {
return; return;
} }
setChatInput(''); clearChatComposer();
setChatReferences([]);
setChatContent([]);
void loadProjectConversation(nextProjectPath, false, 'replace'); void loadProjectConversation(nextProjectPath, false, 'replace');
return; return;
} }
@@ -12847,7 +12879,7 @@ export function App({
} }
supervisorChatShouldFollowLatestRef.current = true; supervisorChatShouldFollowLatestRef.current = true;
const clientTurnId = createAgentChatRunId('planning-v2-turn'); const clientTurnId = createAgentChatRunId('planning-v2-turn');
setChatInput(''); clearChatComposer();
setMessages((current) => [ setMessages((current) => [
...current, ...current,
{ {
@@ -12866,23 +12898,19 @@ export function App({
} }
if (directCodexProductRuntime) { if (directCodexProductRuntime) {
// 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。 // 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。
setChatInput(''); clearChatComposer();
setChatReferences([]);
setChatAttachments([]); setChatAttachments([]);
setChatContent([]);
setChatAttachmentNotice(''); setChatAttachmentNotice('');
setChatComposerNotice(''); setChatComposerNotice('');
startDirectCodexConversationTurn({ startDirectCodexConversationTurn({
prompt, prompt,
attachments: pendingAttachments, attachments: pendingAttachments,
references, references,
content: chatContent, content,
}); });
return; return;
} }
setChatInput(''); clearChatComposer();
setChatReferences([]);
setChatContent([]);
setMessages((current) => [ setMessages((current) => [
...current, ...current,
{ {
@@ -12910,8 +12938,6 @@ export function App({
<SupervisorChatOnlyView <SupervisorChatOnlyView
activeVersionId={chatActiveVersionId} activeVersionId={chatActiveVersionId}
chatAgentBusy={chatAgentBusy} chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef} composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets} chatProjectAssets={chatProjectAssets}
messagesRef={supervisorChatMessagesRef} messagesRef={supervisorChatMessagesRef}
@@ -12960,8 +12986,6 @@ export function App({
activeVersionId={chatActiveVersionId} activeVersionId={chatActiveVersionId}
attachments={chatAttachments} attachments={chatAttachments}
attachmentNotice={chatAttachmentNotice} attachmentNotice={chatAttachmentNotice}
chatInput={chatInput}
chatReferences={chatReferences}
composerNotice={chatComposerNotice} composerNotice={chatComposerNotice}
onCancelQueuedTurn={cancelQueuedChatTurn} onCancelQueuedTurn={cancelQueuedChatTurn}
onCancelTurn={() => void handleCancelDirectCodexTurn()} onCancelTurn={() => void handleCancelDirectCodexTurn()}
@@ -13181,8 +13205,6 @@ export function App({
} }
cancelUiCommandConfirmation={cancelUiCommandConfirmation} cancelUiCommandConfirmation={cancelUiCommandConfirmation}
chatAgentBusy={chatAgentBusy} chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
chatReferences={chatReferences}
composerRef={chatComposerRef} composerRef={chatComposerRef}
chatProjectAssets={chatProjectAssets} chatProjectAssets={chatProjectAssets}
chatInputRef={chatInputRef} chatInputRef={chatInputRef}
@@ -0,0 +1,102 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type Klass,
type LexicalNode,
} from 'lexical';
import type { ReactElement, ReactNode, Ref } from 'react';
import { useEffect } from 'react';
type RichTextInputProps = {
namespace: string;
nodes: Klass<LexicalNode>[];
initialEditorState?: EditorState | null;
contentEditable?: ReactElement<typeof ContentEditable>;
placeholder?: ReactElement;
containerClassName?: string;
containerRef?: Ref<HTMLDivElement>;
disabled?: boolean;
onChange?: (editorState: EditorState) => void;
onEnter?: () => void;
children?: ReactNode;
};
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onEnter) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) return false;
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
}
function SetEditorEditable({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
return null;
}
export default function RichTextInput({
namespace,
nodes,
initialEditorState,
contentEditable = <ContentEditable />,
placeholder,
containerClassName,
containerRef,
disabled = false,
onChange,
onEnter,
children,
}: RichTextInputProps) {
return (
<LexicalComposer
initialConfig={{
namespace,
nodes,
editorState: initialEditorState ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div
ref={containerRef}
className={containerClassName}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={contentEditable}
placeholder={placeholder}
ErrorBoundary={LexicalErrorBoundary}
/>
{children}
<SetEditorEditable disabled={disabled} />
<SubmitOnEnter onEnter={onEnter} />
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
</div>
</LexicalComposer>
);
}
@@ -79,7 +79,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallGroup } from './ToolCallGroup';
import { import {
formatClockTime, formatClockTime,
@@ -242,8 +242,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
attachments?: DirectCodexTurnAttachment[]; attachments?: DirectCodexTurnAttachment[];
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */ /** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
attachmentNotice?: string; attachmentNotice?: string;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>; composerRef?: RefObject<ResourceReferenceInputHandle | null>;
directCodex?: boolean; directCodex?: boolean;
@@ -327,8 +325,6 @@ export function ProjectSupervisorView({
activeVersionId = null, activeVersionId = null,
attachments = [], attachments = [],
attachmentNotice = '', attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
directCodex = false, directCodex = false,
@@ -916,8 +912,6 @@ export function ProjectSupervisorView({
Boolean(designView?.session.pendingClarification) Boolean(designView?.session.pendingClarification)
} }
rows={3} rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={!directCodex} showTriggerButton={!directCodex}
placeholder={ placeholder={
directCodex directCodex
@@ -55,7 +55,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
type ProjectWorkspaceChatPaneProps = { type ProjectWorkspaceChatPaneProps = {
activeVersionId?: string | null; activeVersionId?: string | null;
@@ -65,8 +65,6 @@ type ProjectWorkspaceChatPaneProps = {
cancelProjectCreateInNonEmptyFolder: () => void; cancelProjectCreateInNonEmptyFolder: () => void;
cancelUiCommandConfirmation: () => void; cancelUiCommandConfirmation: () => void;
chatAgentBusy: boolean; chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: GameCreationAppAssetManifestEntry[]; chatProjectAssets: GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>; composerRef?: Ref<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>; chatInputRef: RefObject<HTMLDivElement | null>;
@@ -215,8 +213,6 @@ export function ProjectWorkspaceChatPane({
cancelProjectCreateInNonEmptyFolder, cancelProjectCreateInNonEmptyFolder,
cancelUiCommandConfirmation, cancelUiCommandConfirmation,
chatAgentBusy, chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
chatInputRef, chatInputRef,
@@ -962,8 +958,6 @@ export function ProjectWorkspaceChatPane({
projectPath={projectPath} projectPath={projectPath}
disabled={chatAgentBusy || projectSupervisorNeedsUserInput} disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
multiline={false} multiline={false}
value={chatInput}
references={chatReferences}
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源" placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
onChange={onChatComposerChange} onChange={onChatComposerChange}
/> />
@@ -1,9 +1,6 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { import {
LexicalTypeaheadMenuPlugin, LexicalTypeaheadMenuPlugin,
MenuOption, MenuOption,
@@ -48,7 +45,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { createPortal, flushSync } from 'react-dom'; import { createPortal } from 'react-dom';
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar'; import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
@@ -58,6 +55,7 @@ import {
type GameIterationVersion, type GameIterationVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp'; } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri'; import { resolveTauriInvoke } from '../../app/tauri';
import RichTextInput from '../../components/RichTextInput';
import { import {
cancelLocalProjectResourcePreviewScope, cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId, createProjectResourcePreviewRequestId,
@@ -79,7 +77,7 @@ import {
import { import {
type ChatComposerDraft, type ChatComposerDraft,
type ChatReference, type ChatReference,
chatReferenceListKey, chatReferenceToContentPart,
currentIterationVersionAssets, currentIterationVersionAssets,
dedupeChatReferences, dedupeChatReferences,
refreshResourceReference, refreshResourceReference,
@@ -98,9 +96,12 @@ import {
import { usePromptPolish } from './usePromptPolish'; import { usePromptPolish } from './usePromptPolish';
type ResourceReferenceInputProps = { type ResourceReferenceInputProps = {
value: string; value?: EditorState | string | null;
references: ChatReference[]; /** 仅用于尚未迁移的调用方提供初始引用;不会参与后续状态同步。 */
onChange: (draft: ChatComposerDraft) => void; references?: ChatReference[];
onChange?: (draft: ChatComposerDraft) => void;
onEditorStateChange?: (editorState: EditorState) => void;
initialDraft?: Pick<ChatComposerDraft, 'text' | 'references'>;
assets: GameCreationAppAssetManifestEntry[]; assets: GameCreationAppAssetManifestEntry[];
projectPath: string; projectPath: string;
/** /**
@@ -157,10 +158,13 @@ function createResourcePickerScopeStates(): Record<
export type ResourceReferenceInputHandle = { export type ResourceReferenceInputHandle = {
insertReferences: (references: ChatReference[]) => void; insertReferences: (references: ChatReference[]) => void;
/** 追加纯文本(语音识别结果):写在当前光标处,且不覆盖用户已输入的内容。 */
insertText: (text: string) => void; insertText: (text: string) => void;
openPicker: () => void; openPicker: () => void;
focus: () => void; focus: () => void;
clear: () => void;
replaceText: (text: string) => void;
/** 直接读取 Lexical 当前状态,不在宿主组件复制一份编辑器 state。 */
getDraft: () => ChatComposerDraft;
}; };
class ResourceMentionOption extends MenuOption { class ResourceMentionOption extends MenuOption {
@@ -172,15 +176,15 @@ class ResourceMentionOption extends MenuOption {
} }
} }
function referenceListKey(references: ChatReference[]) { function appendInputText(content: DirectCodexUserContentPart[], text: string) {
return chatReferenceListKey(references); const previous = content[content.length - 1];
} if (previous?.type === 'input_text') {
previous.text += text;
function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) { return;
return ( }
left.text === right.text && if (text.trim()) {
referenceListKey(left.references) === referenceListKey(right.references) content.push({ type: 'input_text', text });
); }
} }
function collectDraftParts( function collectDraftParts(
@@ -192,43 +196,25 @@ function collectDraftParts(
if ($isTextNode(node)) { if ($isTextNode(node)) {
const text = node.getTextContent(); const text = node.getTextContent();
textParts.push(text); textParts.push(text);
if (text) content.push({ type: 'input_text', text }); appendInputText(content, text);
return; return;
} }
if ($isLineBreakNode(node)) { if ($isLineBreakNode(node)) {
textParts.push('\n'); textParts.push('\n');
content.push({ type: 'input_text', text: '\n' }); appendInputText(content, '\n');
return; return;
} }
if ($isResourceReferenceNode(node)) { if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`); textParts.push(`@${node.__reference.label}`);
references.push(node.__reference); references.push(node.__reference);
content.push( content.push(chatReferenceToContentPart(node.__reference));
node.__reference.type === 'resource'
? {
type: 'agc_resource_reference',
resourceId: node.__reference.resourceId,
}
: {
type: 'agc_runtime_region_reference',
label: node.__reference.label,
runId: node.__reference.runId,
versionId: node.__reference.versionId,
elementTag: node.__reference.elementTag,
elementRole: node.__reference.elementRole,
text: node.__reference.text,
width: node.__reference.width,
height: node.__reference.height,
resourceIds: node.__reference.resourceIds,
},
);
return; return;
} }
if ($isElementNode(node)) { if ($isElementNode(node)) {
node.getChildren().forEach((child, index) => { node.getChildren().forEach((child, index) => {
if (index > 0 && node.getType() === 'root') { if (index > 0 && node.getType() === 'root') {
textParts.push('\n'); textParts.push('\n');
content.push({ type: 'input_text', text: '\n' }); appendInputText(content, '\n');
} }
collectDraftParts(child, textParts, references, content); collectDraftParts(child, textParts, references, content);
}); });
@@ -248,7 +234,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); return editorState.read(readDraftFromNodes);
} }
@@ -281,13 +274,12 @@ function findDraftMentionToken(line: string, token: string, from: number) {
/** /**
* 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。
* *
* 不变量:切完写进编辑器后,编辑器读回的草稿必须与 props 等价。`collectDraftParts` * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, * 会把每个 chip 读成一段 `@显示名` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾——否则读回来的文本每重建一轮就多一段 `@显示名`, * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
* `sameDraft` 永远判定为不相等,编辑器就会一轮轮重建、文本一轮轮变长。
* *
* 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾 * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾
* 引用不会凭空消失,而且只补一次——下一轮 props 里就带上这个 token,重建随即收敛 * 引用不会凭空消失;这只发生在一次明确的初始草稿/润色写入中
*/ */
function buildDraftSegments( function buildDraftSegments(
value: string, value: string,
@@ -452,30 +444,25 @@ function $staleResourceReferenceNodes(
} }
function ResourceReferenceEditor({ function ResourceReferenceEditor({
value,
references,
onChange, onChange,
onEditorStateChange,
initialDraft,
assets, assets,
projectPath, projectPath,
activeVersionId = null, activeVersionId = null,
versions, versions,
disabled, disabled,
placeholder,
ariaLabel,
multiline, multiline,
rows,
showTriggerButton = true, showTriggerButton = true,
showPolishAction = true, showPolishAction = true,
inputRef,
composerRef, composerRef,
rootRef,
}: ResourceReferenceInputProps & { }: ResourceReferenceInputProps & {
composerRef?: Ref<ResourceReferenceInputHandle>; composerRef?: Ref<ResourceReferenceInputHandle>;
rootRef: RefObject<HTMLDivElement | null>;
}) { }) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const lastEmittedDraftRef = useRef<ChatComposerDraft>({ const skipInitialDraftChangeRef = useRef(false);
text: '',
references: [],
});
const [query, setQuery] = useState<string | null>(null); const [query, setQuery] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为 // Enter 提交要避开候选浮层。用 ref 让命令处理器读到最新状态,不必因为
@@ -498,8 +485,6 @@ function ResourceReferenceEditor({
bottom: number; bottom: number;
width: number; width: number;
} | null>(null); } | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const assetsContentSignature = assetsSignature(assets); const assetsContentSignature = assetsSignature(assets);
const versionsContentSignature = iterationsSignature(versions); const versionsContentSignature = iterationsSignature(versions);
const assetReferences = useMemo( const assetReferences = useMemo(
@@ -604,7 +589,6 @@ function ResourceReferenceEditor({
if (!insert.trim()) return; if (!insert.trim()) return;
editor.update(() => { editor.update(() => {
let selection = $getSelection(); let selection = $getSelection();
// 选区失效(跨会话恢复草稿后常见)时回落到草稿末尾,与 `insertReferences` 同口径。
if ( if (
!$isRangeSelection(selection) || !$isRangeSelection(selection) ||
!selection.anchor.getNode().isAttached() !selection.anchor.getNode().isAttached()
@@ -613,11 +597,8 @@ function ResourceReferenceEditor({
selection = $getSelection(); selection = $getSelection();
} }
if ($isRangeSelection(selection)) { if ($isRangeSelection(selection)) {
// 认领的光标处的已有内容保持不变:这里只插入,不删除任何节点。
const rootText = $getRoot().getTextContent(); const rootText = $getRoot().getTextContent();
if (rootText && !/\s$/u.test(rootText)) { if (rootText && !/\s$/u.test(rootText)) selection.insertText(' ');
selection.insertText(' ');
}
selection.insertText(insert); selection.insertText(insert);
} }
}); });
@@ -633,6 +614,20 @@ function ResourceReferenceEditor({
insertText, insertText,
openPicker, openPicker,
focus: () => editor.focus(), 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, insertText, openPicker], [editor, insertReferences, insertText, openPicker],
); );
@@ -641,24 +636,18 @@ function ResourceReferenceEditor({
editor.setEditable(!disabled); editor.setEditable(!disabled);
}, [disabled, editor]); }, [disabled, editor]);
const initialDraftAppliedRef = useRef(false);
useEffect(() => { useEffect(() => {
const nextDraft: ChatComposerDraft = { if (initialDraftAppliedRef.current || !initialDraft) {
text: value,
references,
};
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) {
return; return;
} }
initialDraftAppliedRef.current = true;
skipInitialDraftChangeRef.current = true;
editor.update(() => { editor.update(() => {
applyDraftToRoot(value, references); applyDraftToRoot(initialDraft.text, initialDraft.references);
// 立刻按编辑器自己的口径读一遍刚写进去的内容,并记为「已同步草稿」:
// `OnChangePlugin` 稍后读到的就是这一份,两边一致才不会触发下一轮重建。
lastEmittedDraftRef.current = readDraftFromNodes();
// 程序化重建草稿(切会话 / 重开会话恢复草稿)后把光标收回草稿末尾:
// 既保证恢复后光标落在文本末尾,也保证后续 @ 引用按顺序追加而不是插到旧位置。
$getRoot().selectEnd(); $getRoot().selectEnd();
}); });
}, [editor, references, value]); }, [editor, initialDraft]);
const assetsById = useMemo( const assetsById = useMemo(
() => new Map(assets.map((asset) => [asset.id, asset])), () => new Map(assets.map((asset) => [asset.id, asset])),
@@ -769,18 +758,22 @@ function ResourceReferenceEditor({
); );
const acknowledgedDraftKeyRef = useRef<string | null>(null); const acknowledgedDraftKeyRef = useRef<string | null>(null);
// 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。
const liveDraftRef = useRef<ChatComposerDraft>({ text: value, references }); const liveDraftRef = useRef<ChatComposerDraft>({
liveDraftRef.current = { text: value, references }; text: initialDraft?.text ?? '',
references: initialDraft?.references ?? [],
content: [],
});
const reminderDisabledRef = useRef(reminderDisabled); const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled; reminderDisabledRef.current = reminderDisabled;
const applyPromptText = useCallback( const applyPromptText = useCallback(
(text: string) => { (text: string) => {
flushSync(() => { editor.update(() => {
onChange({ text, references: liveDraftRef.current.references }); applyDraftToRoot(text, liveDraftRef.current.references);
$getRoot().selectEnd();
}); });
}, },
[onChange], [editor],
); );
const readPromptText = useCallback(() => liveDraftRef.current.text, []); const readPromptText = useCallback(() => liveDraftRef.current.text, []);
@@ -819,7 +812,7 @@ function ResourceReferenceEditor({
setReminderOpen(false); setReminderOpen(false);
acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current); acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current);
rootRef.current?.closest('form')?.requestSubmit(); rootRef.current?.closest('form')?.requestSubmit();
}, []); }, [rootRef]);
const useOriginalAndSubmit = useCallback(() => { const useOriginalAndSubmit = useCallback(() => {
clearPolishError(); clearPolishError();
@@ -867,14 +860,15 @@ function ResourceReferenceEditor({
}; };
form.addEventListener('submit', handleFormSubmit, true); form.addEventListener('submit', handleFormSubmit, true);
return () => form.removeEventListener('submit', handleFormSubmit, true); return () => form.removeEventListener('submit', handleFormSubmit, true);
}, []); }, [rootRef]);
// 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。
useEffect(() => { useEffect(() => {
if (value.trim() !== '' || references.length > 0) return; const draft = liveDraftRef.current;
if (draft.text.trim() !== '' || draft.references.length > 0) return;
resetPromptPolish(); resetPromptPolish();
acknowledgedDraftKeyRef.current = null; acknowledgedDraftKeyRef.current = null;
}, [references, resetPromptPolish, value]); }, [resetPromptPolish]);
const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback( const renderMentionMenu: MenuRenderFn<ResourceMentionOption> = useCallback(
(_anchorElementRef, itemProps) => { (_anchorElementRef, itemProps) => {
@@ -942,7 +936,7 @@ function ResourceReferenceEditor({
document.body, document.body,
); );
}, },
[], [rootRef],
); );
const pickerReferences = useMemo(() => { const pickerReferences = useMemo(() => {
@@ -973,7 +967,7 @@ function ResourceReferenceEditor({
bottom: Math.max(12, window.innerHeight - rect.top + 8), bottom: Math.max(12, window.innerHeight - rect.top + 8),
width, width,
}); });
}, []); }, [rootRef]);
useEffect(() => { useEffect(() => {
if (!pickerOpen) { if (!pickerOpen) {
@@ -990,31 +984,7 @@ function ResourceReferenceEditor({
}, [pickerOpen, updatePickerPosition]); }, [pickerOpen, updatePickerPosition]);
return ( return (
<div <>
ref={rootRef}
className={`resource-reference-input${multiline ? '' : ' is-single-line'}`}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={
<ContentEditable
ref={inputRef}
aria-label={ariaLabel}
className="resource-reference-input-editor"
style={
multiline && rows
? { minHeight: `${Math.max(72, rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<div className="resource-reference-input-actions"> <div className="resource-reference-input-actions">
{showTriggerButton ? ( {showTriggerButton ? (
<button <button
@@ -1037,7 +1007,9 @@ function ResourceReferenceEditor({
aria-label="AI 润色" aria-label="AI 润色"
title="AI 润色" title="AI 润色"
aria-busy={polishing} aria-busy={polishing}
disabled={disabled || polishing || value.trim() === ''} disabled={
disabled || polishing || liveDraftRef.current.text.trim() === ''
}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => void polishPrompt()} onClick={() => void polishPrompt()}
> >
@@ -1241,15 +1213,17 @@ function ResourceReferenceEditor({
) : null} ) : null}
<OnChangePlugin <OnChangePlugin
onChange={(editorState) => { onChange={(editorState) => {
const nextDraft = readDraftFromEditorState(editorState); const nextDraft = readResourceReferenceDraft(editorState);
if (sameDraft(lastEmittedDraftRef.current, nextDraft)) { liveDraftRef.current = nextDraft;
if (skipInitialDraftChangeRef.current) {
skipInitialDraftChangeRef.current = false;
return; return;
} }
lastEmittedDraftRef.current = nextDraft; onEditorStateChange?.(editorState);
onChange(nextDraft); onChange?.(nextDraft);
}} }}
/> />
</div> </>
); );
} }
@@ -1321,17 +1295,46 @@ export const ResourceReferenceInput = forwardRef<
ResourceReferenceInputHandle, ResourceReferenceInputHandle,
ResourceReferenceInputProps ResourceReferenceInputProps
>(function ResourceReferenceInput(props, ref) { >(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 ( return (
<LexicalComposer <RichTextInput
initialConfig={{ namespace="agc-resource-reference-input"
namespace: 'agc-resource-reference-input', nodes={[ResourceReferenceNode]}
nodes: [ResourceReferenceNode], initialEditorState={initialEditorState}
onError: (error) => { containerRef={rootRef}
throw error; containerClassName={`resource-reference-input${props.multiline ? '' : ' is-single-line'}`}
}, disabled={props.disabled}
}} contentEditable={
<ContentEditable
aria-label={props.ariaLabel}
className="resource-reference-input-editor"
ref={props.inputRef}
style={
props.multiline && props.rows
? { minHeight: `${Math.max(72, props.rows * 22)}px` }
: undefined
}
/>
}
placeholder={
<span className="resource-reference-input-placeholder">
{props.placeholder}
</span>
}
> >
<ResourceReferenceEditor {...props} composerRef={ref} /> <ResourceReferenceEditor
</LexicalComposer> {...props}
initialDraft={initialDraft}
composerRef={ref}
rootRef={rootRef}
/>
</RichTextInput>
); );
}); });
@@ -33,7 +33,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
type RuntimeControlProps = ComponentProps< type RuntimeControlProps = ComponentProps<
typeof ProjectSupervisorRuntimeControls typeof ProjectSupervisorRuntimeControls
@@ -44,8 +44,6 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = { type SupervisorChatOnlyViewProps = {
activeVersionId?: string | null; activeVersionId?: string | null;
chatAgentBusy: boolean; chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>; composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean; directCodex?: boolean;
@@ -82,8 +80,6 @@ type SupervisorChatOnlyViewProps = {
export function SupervisorChatOnlyView({ export function SupervisorChatOnlyView({
activeVersionId = null, activeVersionId = null,
chatAgentBusy, chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
directCodex = false, directCodex = false,
@@ -328,8 +324,6 @@ export function SupervisorChatOnlyView({
projectPath={projectPath} projectPath={projectPath}
disabled={chatAgentBusy || needsUserInput} disabled={chatAgentBusy || needsUserInput}
rows={3} rows={3}
value={chatInput}
references={chatReferences}
placeholder={ placeholder={
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息' directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
} }
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserAttachmentReferencePart = {
name: string;
mediaType: string;
size: number;
localPath: string;
status: string;
};
@@ -1,5 +1,5 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type DirectCodexUserContentPart = export type DirectCodexUserContentPart =
@@ -7,4 +7,8 @@ export type DirectCodexUserContentPart =
| { type: 'agc_resource_reference'; resourceId: string } | { type: 'agc_resource_reference'; resourceId: string }
| ({ | ({
type: 'agc_runtime_region_reference'; type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart); } & DirectCodexUserRuntimeRegionPart)
| ({
type: 'agc_attachment_reference';
} & DirectCodexUserAttachmentReferencePart)
| ({ type: 'agc_image_reference' } & DirectCodexUserAttachmentReferencePart);
@@ -1,5 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type DirectCodexUserItem = DirectCodexUserMessageItem; /**
* DirectProject 本轮 user input 的唯一结构化入口。
*/
export type DirectCodexUserItem = {
type: 'message';
} & DirectCodexUserMessageItem;
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserItem } from './DirectCodexUserItem';
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
@@ -1,11 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
import type { DirectCodexUserRole } from './DirectCodexUserRole'; import type { DirectCodexUserRole } from './DirectCodexUserRole';
export type DirectCodexUserMessageItem = { export type DirectCodexUserMessageItem = {
type: 'message';
role: DirectCodexUserRole; role: DirectCodexUserRole;
content: DirectCodexUserContentPart[]; content: Array<DirectCodexUserContentPart>;
id: string; id: string;
}; };
@@ -1,3 +1,3 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRole = 'user'; export type DirectCodexUserRole = 'user';
@@ -1,13 +1,13 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRuntimeRegionPart = { export type DirectCodexUserRuntimeRegionPart = {
label: string; label: string;
runId?: string; runId: string | null;
versionId?: string; versionId: string | null;
elementTag?: string; elementTag: string | null;
elementRole?: string; elementRole: string | null;
text?: string; text: string | null;
width?: number; width: number | null;
height?: number; height: number | null;
resourceIds: string[]; resourceIds: Array<string>;
}; };
@@ -1,5 +1,7 @@
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserItem } from './DirectCodexUserItem';
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRole } from './DirectCodexUserRole';
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
@@ -13,7 +13,6 @@ import {
import type { import type {
DirectCodexUserContentPart, DirectCodexUserContentPart,
DirectCodexUserItem, DirectCodexUserItem,
DirectCodexUserMessageItem,
} from './generated'; } from './generated';
export type ResourceReferenceSource = export type ResourceReferenceSource =
@@ -52,8 +51,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference;
export type ChatComposerDraft = { export type ChatComposerDraft = {
text: string; text: string;
references: ChatReference[]; references: ChatReference[];
/** Lexical 顺序对应的 canonical user content仅由编辑器读回时提供。 */ /** Lexical 顺序对应的 canonical user content只从 EditorState 派生。 */
content?: DirectCodexUserContentPart[]; content: DirectCodexUserContentPart[];
}; };
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -103,13 +102,13 @@ export function chatReferenceToContentPart(
return { return {
type: 'agc_runtime_region_reference', type: 'agc_runtime_region_reference',
label: reference.label, label: reference.label,
runId: reference.runId, runId: reference.runId ?? null,
versionId: reference.versionId, versionId: reference.versionId ?? null,
elementTag: reference.elementTag, elementTag: reference.elementTag ?? null,
elementRole: reference.elementRole, elementRole: reference.elementRole ?? null,
text: reference.text, text: reference.text ?? null,
width: reference.width, width: reference.width ?? null,
height: reference.height, height: reference.height ?? null,
resourceIds: reference.resourceIds, resourceIds: reference.resourceIds,
}; };
} }
@@ -118,24 +117,15 @@ export function chatComposerDraftToDirectCodexUserItem(
draft: ChatComposerDraft, draft: ChatComposerDraft,
id: string, id: string,
): DirectCodexUserItem { ): DirectCodexUserItem {
const content = draft.content?.length const content = draft.content.filter(
? draft.content (part) => part.type !== 'input_text' || part.text.trim().length > 0,
: draft.references.length > 0 );
? [
...(draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: []),
...draft.references.map(chatReferenceToContentPart),
]
: draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: [];
return { return {
type: 'message', type: 'message',
role: 'user', role: 'user',
content, content,
id, id,
} satisfies DirectCodexUserMessageItem; } satisfies DirectCodexUserItem;
} }
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
@@ -1,9 +1,5 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager'; import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import { import {
$createParagraphNode, $createParagraphNode,
@@ -13,13 +9,13 @@ import {
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
createCommand, createCommand,
KEY_ENTER_COMMAND,
type LexicalCommand, type LexicalCommand,
PASTE_COMMAND, PASTE_COMMAND,
} from 'lexical'; } from 'lexical';
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import RichTextInput from '../../../../components/RichTextInput';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; import { $createAttachmentNode, AttachmentNode } from './attachmentNode';
@@ -101,29 +97,9 @@ function selectEditableEndWhenNeeded() {
root.selectEnd(); root.selectEnd();
} }
function EditorPlugins({ function EditorPlugins() {
onChange,
onEnter,
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor, onEnter],
);
useEffect( useEffect(
() => () =>
editor.registerCommand( editor.registerCommand(
@@ -188,13 +164,7 @@ function EditorPlugins({
[editor], [editor],
); );
return ( return null;
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState);
}}
/>
);
} }
export function UploadButton() { export function UploadButton() {
@@ -229,34 +199,27 @@ export function UploadButton() {
export default function RichInputArea(props: RichInputAreaProps) { export default function RichInputArea(props: RichInputAreaProps) {
return ( return (
<LexicalComposer <RichTextInput
initialConfig={{ namespace="home-rich-input"
namespace: 'home-rich-input', nodes={[AttachmentNode]}
nodes: [AttachmentNode], initialEditorState={props.value}
editorState: props.value ?? undefined, onChange={props.onChange}
onError: (error) => { onEnter={props.onEnter}
throw error; containerClassName="relative grid min-h-9 gap-2"
}, contentEditable={
}} <ContentEditable
> aria-label="创作想法"
<div className="relative grid min-h-9 gap-2"> className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/> />
{props.children} }
</div> placeholder={
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} /> <span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
</LexicalComposer> {props.placeholder}
</span>
}
>
<EditorPlugins />
{props.children}
</RichTextInput>
); );
} }
@@ -108,7 +108,6 @@ import {
import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput';
import { import {
type ChatComposerDraft, type ChatComposerDraft,
type ChatReference,
dispatchResourceReferenceInsert, dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget, isResourceReferenceOverlayTarget,
resolveActiveIterationVersion, resolveActiveIterationVersion,
@@ -1563,9 +1562,6 @@ export default function ProjectDevelopmentView({
* `@` * `@`
* *
*/ */
const [quickEditReferences, setQuickEditReferences] = useState<
ChatReference[]
>([]);
/** /**
* *
* *
@@ -6470,7 +6466,6 @@ export default function ProjectDevelopmentView({
setQuickEditSourceLayer(layer); setQuickEditSourceLayer(layer);
const panelDraft = createResourceQuickEditPanelDraft(layer); const panelDraft = createResourceQuickEditPanelDraft(layer);
setQuickEditPanel(panelDraft); setQuickEditPanel(panelDraft);
setQuickEditReferences([]);
resourceQuickEditRequestRef.current = { resourceQuickEditRequestRef.current = {
...createResourceEditRequestIdentity(panelDraft.prompt), ...createResourceEditRequestIdentity(panelDraft.prompt),
sourceLayerId: layer.id, sourceLayerId: layer.id,
@@ -6510,7 +6505,6 @@ export default function ProjectDevelopmentView({
*/ */
const applyResourceQuickEditDraft = useCallback( const applyResourceQuickEditDraft = useCallback(
(draft: ChatComposerDraft) => { (draft: ChatComposerDraft) => {
setQuickEditReferences(draft.references);
applyResourceQuickEditPrompt(draft.text); applyResourceQuickEditPrompt(draft.text);
}, },
[applyResourceQuickEditPrompt], [applyResourceQuickEditPrompt],
@@ -8128,9 +8122,12 @@ export default function ProjectDevelopmentView({
// 由它拼装,因此出站 payload 与聊天 `@` 一致。 // 由它拼装,因此出站 payload 与聊天 `@` 一致。
<div className="resource-canvas-quick-edit-prompt-input"> <div className="resource-canvas-quick-edit-prompt-input">
<ResourceReferenceInput <ResourceReferenceInput
key={quickEditSourceLayer?.id}
ariaLabel="快速编辑提示词" ariaLabel="快速编辑提示词"
value={quickEditPanel.prompt} initialDraft={{
references={quickEditReferences} text: quickEditPanel.prompt,
references: [],
}}
onChange={applyResourceQuickEditDraft} onChange={applyResourceQuickEditDraft}
assets={manifest.assets} assets={manifest.assets}
projectPath={projectPath} projectPath={projectPath}
@@ -1,4 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// @vitest-environment-options {"url":"http://localhost"}
import { import {
cleanup, cleanup,
fireEvent, fireEvent,
@@ -8,7 +9,7 @@ import {
within, within,
} from '@testing-library/react'; } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useState } from 'react'; import { useRef } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { import {
@@ -20,6 +21,7 @@ import {
shouldRemindChatPromptPolish, shouldRemindChatPromptPolish,
writeChatPromptPolishReminderDisabled, writeChatPromptPolishReminderDisabled,
} from '../src/features/project-workspace/chatPromptPolish'; } from '../src/features/project-workspace/chatPromptPolish';
import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput';
import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput';
import { import {
type ChatComposerDraft, type ChatComposerDraft,
@@ -27,6 +29,17 @@ import {
resourceReferenceFromAsset, resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences'; } 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 = ( type TauriInvoke = (
command: string, command: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
@@ -78,21 +91,30 @@ function ControlledChatComposer({
initialReferences?: ChatReference[]; initialReferences?: ChatReference[];
onSubmitDraft: (draft: ChatComposerDraft) => void; onSubmitDraft: (draft: ChatComposerDraft) => void;
}) { }) {
const [draft, setDraft] = useState<ChatComposerDraft>({ const composerRef = useRef<ResourceReferenceInputHandle | null>(null);
text: initialText,
references: initialReferences,
});
return ( return (
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); 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 <ResourceReferenceInput
value={draft.text} ref={composerRef}
references={draft.references} initialDraft={{ text: initialText, references: initialReferences }}
onChange={setDraft} onChange={() => {}}
assets={[]} assets={[]}
projectPath="C:/project" projectPath="C:/project"
ariaLabel="创作想法" ariaLabel="创作想法"
@@ -9,7 +9,7 @@ import {
} from '@testing-library/react'; } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { $getRoot } from 'lexical'; import { $getRoot } from 'lexical';
import { StrictMode, useState } from 'react'; import { createRef, StrictMode, useState } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { import type {
@@ -20,7 +20,10 @@ import {
LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, LOCAL_GAME_PREVIEW_INSPECT_MESSAGE,
parseLocalGamePreviewInspectMessage, parseLocalGamePreviewInspectMessage,
} from '../src/features/project-workspace/LocalGamePreviewFrame'; } 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 { import {
type ChatComposerDraft, type ChatComposerDraft,
type ChatReference, type ChatReference,
@@ -193,27 +196,20 @@ describe('ResourceReferenceInput', () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
function Controlled() { function Controlled() {
const [draft, setDraft] = useState<ChatComposerDraft>({ const composerRef = createRef<ResourceReferenceInputHandle>();
text: '原始需求',
references: [reference],
});
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => onClick={() => composerRef.current?.replaceText('润色后的需求')}
setDraft((current) => ({ ...current, text: '润色后的需求' }))
}
> >
</button> </button>
<ResourceReferenceInput <ResourceReferenceInput
value={draft.text} ref={composerRef}
references={draft.references} value={null}
onChange={(next) => { initialDraft={{ text: '原始需求', references: [reference] }}
onChange(next); onChange={onChange}
setDraft(next);
}}
assets={assets} assets={assets}
projectPath="C:/project" projectPath="C:/project"
ariaLabel="聊天" ariaLabel="聊天"
@@ -228,10 +224,9 @@ describe('ResourceReferenceInput', () => {
await settleComposer(); await settleComposer();
await settleComposer(); await settleComposer();
// 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主: // 这次替换是对唯一 EditorState 的明确编辑动作,只产生一次派生快照,
// 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名` // 不会因为 props 回写而重复触发。
// 会触发下一轮重建,文本一轮轮变长(渲染循环)。 expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).not.toHaveBeenCalled();
const editorText = const editorText =
document.querySelector('.resource-reference-input-editor')?.textContent ?? 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 () => { test('restores a cross-session draft with the caret at the end of the text', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const { rerender } = render( const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ResourceReferenceInput <ResourceReferenceInput
value="上一个会话的草稿" value="上一个会话的草稿"
references={[]} references={[]}
ref={composerRef}
onChange={onChange} onChange={onChange}
assets={assets} assets={assets}
projectPath="C:/project" projectPath="C:/project"
@@ -777,17 +774,8 @@ describe('ResourceReferenceInput', () => {
/>, />,
); );
// 切换 / 重开会话:外部草稿被整体替换 // 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态
rerender( composerRef.current?.replaceText('恢复出来的草稿');
<ResourceReferenceInput
value="恢复出来的草稿"
references={[]}
onChange={onChange}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('button', { name: '插入素材引用' }));
await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('option', { name: /hero/u }));
@@ -54,6 +54,10 @@ describe('DirectProject user Response item', () => {
source: 'asset-picker', source: 'asset-picker',
}, },
], ],
content: [
{ type: 'input_text', text: '请使用素材' },
{ type: 'agc_resource_reference', resourceId: 'asset-hero' },
],
}; };
expect( expect(

Some files were not shown because too many files have changed in this diff Show More