diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 793e2b4a4..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,6 +164,7 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 17b8a6700..53d1f1db4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -17,6 +17,7 @@ mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_references; +mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; @@ -45,6 +46,7 @@ pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_references::*; +pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs new file mode 100644 index 000000000..1c042b25f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs @@ -0,0 +1,231 @@ +use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use super::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +/// +/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item +/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + 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::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts new file mode 100644 index 000000000..10c4e0f5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -0,0 +1,10 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; + +export type DirectCodexUserContentPart = + | { type: 'input_text'; text: string } + | { type: 'agc_resource_reference'; resourceId: string } + | ({ + type: 'agc_runtime_region_reference'; + } & DirectCodexUserRuntimeRegionPart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts new file mode 100644 index 000000000..4c9ea8ca3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -0,0 +1,5 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; + +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts new file mode 100644 index 000000000..f7447dc5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -0,0 +1,11 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +import type { DirectCodexUserRole } from './DirectCodexUserRole'; + +export type DirectCodexUserMessageItem = { + type: 'message'; + role: DirectCodexUserRole; + content: DirectCodexUserContentPart[]; + id: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts new file mode 100644 index 000000000..e1dc59c59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -0,0 +1,3 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts new file mode 100644 index 000000000..296c0d19a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -0,0 +1,13 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRuntimeRegionPart = { + label: string; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts new file mode 100644 index 000000000..f1012a4c5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -0,0 +1,5 @@ +export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserItem } from './DirectCodexUserItem'; +export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +export type { DirectCodexUserRole } from './DirectCodexUserRole'; +export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..8742e52fb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,36 @@ +# 【实施计划】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 + +## 实现顺序 + +1. 提取深模块:Rust canonical user item 的定义、校验和 Codex wire 转换。 +2. 生成并接入 ts-rs 类型,排除生成文件 lint/style。 +3. 将前端 Lexical 草稿从 `text + references[]` 改为 inline content parts。 +4. 修改 Tauri command 与 DirectProject turn:校验通过后持久化 canonical item,再发送转换后的 Codex 输入。 +5. 删除本链路对 legacy conversation 行的读取 fallback,保留标准 `response_item`。 +6. 补齐定向测试与文档验证;每个独立切片形成小提交。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex` +2. 相关前端 Vitest 与类型检查。 +3. `npm run check:encoding` +4. `npm run check:doc-index` +5. `git diff --check` + +## 风险与回滚点 + +- ts-rs 生成路径或 workspace lint 配置不一致:先固定生成入口,再接入业务。 +- 历史中 canonical item 含 AGC part 时,thread replay 必须经过同一 wire converter;转换失败不得启动 turn。 +- 共享工作树存在用户 `.env` 修改,禁止覆盖或提交。 +- 每个切片保持独立提交,出现协议问题时按提交粒度回滚。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..cc88e97af --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-15 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +将 DirectProject 的用户消息升级为受限 Response API `message` item:文本与 AGC 引用按编辑顺序内联,Rust 校验后持久化 canonical item,并转换为 Codex 可接受的输入。 + +## 范围 + +- Rust 定义 user item 与 content part,并通过 `ts-rs` 生成 TypeScript 绑定。 +- 前端 Lexical 草稿生成 inline `content[]`。 +- `agc_resource_reference` 只使用稳定 `resourceId`。 +- `agc_runtime_region_reference` 保留运行区域语义摘要。 +- Rust 在持久化前完成白名单、manifest 与路径校验。 +- 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 +- 保持 assistant 返回、工具 activity、附件/图片协议不变。 + +## 不在范围内 + +- assistant item 前端投影或 Tauri 返回值改造。 +- 工具 item、reasoning、file change、MCP item 的 UI 模型化。 +- 附件/图片 content part。 +- SpacetimeDB schema 或 HTTP API 变更。 + +## 依赖与前置条件 + +- DirectProject 现有 app-server thread/inject_items/turn/start 链路。 +- 项目 manifest 作为资源身份与路径权威。 +- 现有 `project.jsonl` 的 `response_item` envelope。 + +## 验收标准 + +- [ ] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。 +- [ ] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。 +- [ ] runtime-region 字段经过 Rust 有界清洗并验证关联资源。 +- [ ] 未知 part、失效资源或非法路径在持久化前失败关闭。 +- [ ] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 +- [ ] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 +- [ ] assistant、附件和工具链路行为无变化。 + +## 证据要求 + +- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。 +- 运行时:DirectProject 本地 app-server smoke(如环境可用)。 +- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index e8a921a69..ca8874954 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -13,7 +13,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 -提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。 +提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 当前已完成: @@ -24,9 +24,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 素材芯片可插入、编辑和删除; - 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; -- 提交请求携带结构化 `references`; -- Rust 按 manifest 二次校验并生成安全投影; +- 提交请求携带 canonical user message item; +- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; - 普通无引用消息保持原有行为; - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 资源改名后引用芯片与候选列表的显示名自动刷新; -- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。 +- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。