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 b9d90f269..1a9a984f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -16,10 +16,11 @@ mod design_runtime; 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; +mod direct_thread_manager; mod direct_tool_bridge; mod direct_tools_mcp; mod generation; @@ -35,6 +36,7 @@ mod runtime_tools; mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ + direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, }; use codex_cli::*; @@ -45,10 +47,11 @@ pub(crate) use codex_provider_proxy::*; 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::*; +pub(crate) use direct_thread_manager::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; pub(crate) use generation::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs new file mode 100644 index 000000000..627002154 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -0,0 +1,32 @@ +//! DirectProject 历史注入载荷的单一构造 seam。 +//! +//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。 + +use super::super::*; +use super::direct_project_history_injection_oversize_error; +use serde_json::Value; +use std::path::Path; + +pub(super) fn build_direct_project_history_injection_params( + history_root: &Path, + thread_id: &str, +) -> Result { + let canonical_items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let items = canonical_items + .iter() + .map(|item| { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest) + }) + .collect::, _>>()?; + let params = serde_json::json!({"threadId": thread_id, "items": items}); + let payload_bytes = serde_json::to_vec(¶ms) + .map(|bytes| bytes.len().saturating_add(1)) + .unwrap_or(usize::MAX); + // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 + if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + Ok(params) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs new file mode 100644 index 000000000..901467454 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -0,0 +1,53 @@ +//! DirectProject 线程池身份的 canonical 解析与摘要。 + +use super::super::*; +use sha2::{Digest, Sha256}; + +pub(crate) fn direct_codex_canonical_project_identity( + root: &std::path::Path, +) -> Result<(std::path::PathBuf, String), String> { + let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; + let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) + .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; + let manifest_project_id = manifest.project_id.trim(); + if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { + return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); + } + let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); + Ok(( + canonical_root, + direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), + )) +} + +pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return path.as_os_str().as_bytes().to_vec(); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + return bytes; + } + #[cfg(not(any(unix, windows)))] + path.as_os_str().to_string_lossy().as_bytes().to_vec() +} + +pub(super) fn direct_codex_project_identity_digest( + path_identity: &[u8], + project_id: &[u8], +) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-direct-project-identity.v1\0"); + digest.update((path_identity.len() as u64).to_le_bytes()); + digest.update(path_identity); + digest.update((project_id.len() as u64).to_le_bytes()); + digest.update(project_id); + format!("{:x}", digest.finalize()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs similarity index 96% rename from apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index ea4d981a8..7377f445c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -10,6 +10,12 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use uuid::Uuid; +mod direct_project_history_wire; +use direct_project_history_wire::build_direct_project_history_injection_params; +mod direct_project_identity; +pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands; +use direct_project_identity::*; + const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = @@ -558,6 +564,10 @@ enum CodexTurnEvent { completed: bool, params: serde_json::Value, }, + Request { + event_type: &'static str, + params: serde_json::Value, + }, RawItem(serde_json::Value), Terminal(serde_json::Value), TransportClosed(String), @@ -712,6 +722,25 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } +/// Project an app-server item into the small public payload carried by the +/// DirectProject event queue. Full item contents are persisted in JSONL and +/// must not be forwarded through the runtime event stream. +fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "itemType": item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + }) +} + +fn direct_thread_item_id(item: &serde_json::Value) -> Option { + item.get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -885,7 +914,7 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static | "item/reasoning/summaryTextDelta" | "item/reasoning/summaryPartAdded" | "item/reasoning/textDelta" => Some("preparing"), - "item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"), + "item/mcpToolCall/progress" => Some("controlled-tool"), "item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-write"), "command/exec/outputDelta" | "process/outputDelta" @@ -895,6 +924,23 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } +fn direct_codex_request_event_type(method: &str) -> Option<&'static str> { + match method { + "item/fileChange/requestApproval" + | "item/commandExecution/requestApproval" + | "item/permissions/requestApproval" => Some("approval.requested"), + "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"), + _ => None, + } +} + +fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> { + match method { + "serverRequest/resolved" => Some("request.resolved"), + _ => None, + } +} + fn direct_codex_intermediate_text_for_notification( method: &str, params: &serde_json::Value, @@ -2665,6 +2711,7 @@ impl CodexAppServerConnection { request, None, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2679,6 +2726,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, + direct_user_item: Option<&serde_json::Value>, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, mut audit: Option<&mut DirectCodexTurnAudit>, @@ -2694,12 +2742,19 @@ impl CodexAppServerConnection { )); } if let Some(client_turn_id) = direct_client_turn_id { - let user_item = direct_project_local_message_item( - "user", - current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let user_item = match direct_user_item { + Some(item) => { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + item.clone() + } + None => direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?, + }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; } @@ -2709,24 +2764,14 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let items = match read_direct_project_history_items_at(history_root) { - Ok(items) => items, - Err(error) => { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } - }; - let params = serde_json::json!({"threadId": thread_id.clone(), "items": items}); - let payload_bytes = serde_json::to_vec(¶ms) - .map(|bytes| bytes.len().saturating_add(1)) - .unwrap_or(usize::MAX); - // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 - if let Some(error) = - direct_project_history_injection_oversize_error(¶ms, payload_bytes) - { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } + let params = + match build_direct_project_history_injection_params(history_root, &thread_id) { + Ok(params) => params, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error); + } + }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; return Err(platform_llm::LlmError::Transport(error)); @@ -2812,6 +2857,21 @@ impl CodexAppServerConnection { } }; turn_start_guard.armed = false; + let direct_thread_id = history_root.to_string_lossy().into_owned(); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: "turn.started".to_string(), + turn_id: turn_id.clone(), + item_id: None, + payload: serde_json::json!({ + "threadId": thread_id, + "turnId": turn_id, + }), + }, + ); + } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -2863,6 +2923,15 @@ impl CodexAppServerConnection { Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { direct_project_history.observe_delta(&item_id, &delta); + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: "item.delta".to_string(), + turn_id: turn_id.clone(), + item_id: Some(item_id.clone()), + payload: serde_json::json!({ "delta": delta.clone() }), + }, + ); } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { @@ -2905,6 +2974,37 @@ impl CodexAppServerConnection { })? .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); + let item_id = direct_thread_item_id(&item); + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: "item.completed".to_string(), + turn_id: turn_id.clone(), + item_id, + payload: serde_json::json!({}), + }, + ); + } + } + Some(CodexTurnEvent::Request { event_type, params }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let request_id = params + .get("requestId") + .and_then(serde_json::Value::as_str) + .or_else(|| params.get("id").and_then(serde_json::Value::as_str)) + .filter(|value| !value.is_empty()) + .map(str::to_string); + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: event_type.to_string(), + turn_id: turn_id.clone(), + item_id: None, + payload: request_id + .map(|id| serde_json::json!({ "requestId": id })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ); } } Some(CodexTurnEvent::Activity(activity)) => { @@ -2967,6 +3067,21 @@ impl CodexAppServerConnection { self.inner.workspace_mode.passive_item_boundary_name(), ))); } + if !completed + && self.inner.workspace_mode + == CodexAppServerWorkspaceMode::DirectProject + { + let item_id = direct_thread_item_id(item); + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: "item.started".to_string(), + turn_id: turn_id.clone(), + item_id, + payload: direct_thread_item_started_payload(item), + }, + ); + } } } Some(CodexTurnEvent::Terminal(params)) => { @@ -2983,11 +3098,24 @@ impl CodexAppServerConnection { }) }); } - match turn + let status = turn .get("status") .and_then(serde_json::Value::as_str) - .unwrap_or_default() + .unwrap_or_default(); + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + && matches!(status, "completed" | "interrupted" | "failed") { + append_direct_thread_event( + &direct_thread_id, + DirectThreadRawEventDraft { + event_type: "turn.completed".to_string(), + turn_id: turn_id.clone(), + item_id: None, + payload: serde_json::json!({ "status": status }), + }, + ); + } + match status { "completed" => { return final_text .filter(|text| !text.trim().is_empty()) @@ -3423,7 +3551,9 @@ async fn read_game_creator_codex_app_server_stdout( | "item/completed" | "rawResponseItem/completed" | "turn/completed" - ) && safe_activity.is_none() + ) && direct_codex_request_event_type(method).is_none() + && direct_codex_resolution_event_type(method).is_none() + && safe_activity.is_none() && intermediate_text.is_none() { continue; @@ -3460,7 +3590,9 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(activity) = safe_activity { + let event = if let Some(event_type) = direct_codex_resolution_event_type(method) { + CodexTurnEvent::Request { event_type, params } + } else if let Some(activity) = safe_activity { // Preparing notifications may carry private plan/reasoning text; // expose only the safe activity category. Other categories may // retain their bounded, redacted intermediate text below. @@ -3509,6 +3641,13 @@ async fn read_game_creator_codex_app_server_stdout( .cloned() .unwrap_or(serde_json::Value::Null), ), + method if direct_codex_request_event_type(method).is_some() => { + CodexTurnEvent::Request { + event_type: direct_codex_request_event_type(method) + .expect("request event type checked above"), + params, + } + } _ => CodexTurnEvent::Terminal(params), } }; @@ -3757,6 +3896,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( None, None, None, + None, ) .await } @@ -3774,56 +3914,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( None, Some(observer), None, + None, ) .await } -fn direct_codex_canonical_project_identity( - root: &std::path::Path, -) -> Result<(std::path::PathBuf, String), String> { - let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; - let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) - .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; - let manifest_project_id = manifest.project_id.trim(); - if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { - return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); - } - let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); - Ok(( - canonical_root, - direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), - )) -} - -fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - return path.as_os_str().as_bytes().to_vec(); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - let mut bytes = Vec::new(); - for unit in path.as_os_str().encode_wide() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - return bytes; - } - #[cfg(not(any(unix, windows)))] - path.as_os_str().to_string_lossy().as_bytes().to_vec() -} - -fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { - let mut digest = Sha256::new(); - digest.update(b"genarrative-direct-project-identity.v1\0"); - digest.update((path_identity.len() as u64).to_le_bytes()); - digest.update(path_identity); - digest.update((project_id.len() as u64).to_le_bytes()); - digest.update(project_id); - format!("{:x}", digest.finalize()) -} - pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, @@ -3831,6 +3926,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -3894,6 +3990,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request, Some(&codex_root), effective_client_turn_id, + direct_user_item.as_ref(), None, observer, audit, @@ -4003,6 +4100,22 @@ pub(crate) fn build_direct_codex_history_prompt( mod tests { use super::*; + #[test] + fn direct_thread_item_projection_drops_full_app_server_payload() { + let item = serde_json::json!({ + "id": "item-1", + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "game/index.html", "token": "secret" }, + "result": { "content": "large output" } + }); + assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); + assert_eq!( + direct_thread_item_started_payload(&item), + serde_json::json!({ "itemType": "mcpToolCall" }) + ); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs deleted file mode 100644 index f1b78b1ca..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::*; - -pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200; -const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160; -const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80; -const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240; - -const DIRECT_CODEX_REFERENCE_HEADER: &str = - "[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]"; - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub(crate) enum DirectCodexTurnReference { - #[serde(rename = "resource")] - Resource(DirectCodexResourceReference), - #[serde(rename = "runtime-region")] - RuntimeRegion(DirectCodexRuntimeRegionReference), -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexResourceReference { - pub(crate) resource_id: String, - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) source: Option, -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexRuntimeRegionReference { - #[serde(default)] - pub(crate) label: Option, - #[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, -} - -fn sanitize_reference_text(value: &str, max_chars: usize) -> Option { - let sanitized = value - .trim() - .chars() - .filter(|character| !character.is_control()) - .take(max_chars) - .collect::(); - if sanitized.is_empty() { - None - } else { - Some(sanitized) - } -} - -fn sanitize_reference_label(value: Option<&str>) -> Option { - value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)) -} - -fn sanitize_reference_source(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_element(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_dimension(value: Option) -> Option { - value - .filter(|value| value.is_finite() && *value > 0.0) - .map(|value| value.round().clamp(1.0, 100_000.0) as u32) -} - -fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String { - let basename = asset - .local_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(&asset.id); - let without_extension = basename - .rsplit_once('.') - .map(|(name, _)| name) - .unwrap_or(basename) - .trim(); - if without_extension.is_empty() { - asset.id.clone() - } else { - without_extension - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS) - .collect() - } -} - -fn validate_resource_reference_id(value: &str) -> Result { - let resource_id = value.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - Ok(resource_id.to_string()) -} - -fn render_resource_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexResourceReference, -) -> Result { - let resource_id = validate_resource_reference_id(&reference.resource_id)?; - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let local_path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| asset_display_label(asset)); - let source = sanitize_reference_source(reference.source.as_deref()) - .unwrap_or_else(|| "unknown".to_string()); - Ok(format!( - "- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}", - asset.kind, asset.media_type - )) -} - -fn render_runtime_region_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexRuntimeRegionReference, -) -> Result { - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| "运行画面区域".to_string()); - let run_id = sanitize_reference_source(reference.run_id.as_deref()); - let version_id = sanitize_reference_source(reference.version_id.as_deref()); - let element_tag = sanitize_reference_element(reference.element_tag.as_deref()); - let element_role = sanitize_reference_element(reference.element_role.as_deref()); - let text = reference - .text - .as_deref() - .and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS)); - let width = sanitize_reference_dimension(reference.width); - let height = sanitize_reference_dimension(reference.height); - - // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, - // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 - // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 - // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" - )); - } - let mut related_resource_ids = Vec::new(); - for resource_id in &reference.resource_ids { - let resource_id = validate_resource_reference_id(resource_id)?; - if !manifest.assets.iter().any(|asset| asset.id == resource_id) { - return Err("运行画面引用的素材已变化,请重新点选".to_string()); - } - // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 - // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 - if !related_resource_ids.contains(&resource_id) { - related_resource_ids.push(resource_id); - } - } - - let mut parts = vec![format!("名称:{label}")]; - if let Some(run_id) = run_id { - parts.push(format!("运行标识:{run_id}")); - } - if let Some(version_id) = version_id { - parts.push(format!("版本标识:{version_id}")); - } - if let Some(element_tag) = element_tag { - parts.push(format!("元素:{element_tag}")); - } - if let Some(element_role) = element_role { - parts.push(format!("角色:{element_role}")); - } - if let Some(text) = text { - parts.push(format!("文本摘要:{text}")); - } - if let (Some(width), Some(height)) = (width, height) { - parts.push(format!("尺寸:{width}x{height}")); - } - if !related_resource_ids.is_empty() { - parts.push(format!("关联素材 ID:{}", related_resource_ids.join(","))); - } - Ok(format!("- 运行画面区域:{}", parts.join(";"))) -} - -pub(crate) fn render_direct_codex_references_section( - root: &Path, - references: &[DirectCodexTurnReference], -) -> Result, String> { - if references.is_empty() { - return Ok(None); - } - if references.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::with_capacity(references.len()); - for reference in references { - lines.push(match reference { - DirectCodexTurnReference::Resource(reference) => { - render_resource_reference_line(&manifest, reference)? - } - DirectCodexTurnReference::RuntimeRegion(reference) => { - render_runtime_region_reference_line(&manifest, reference)? - } - }); - } - Ok(Some( - std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string()) - .chain(lines) - .collect::>() - .join("\n"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_project() -> tempfile::TempDir { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - std::fs::create_dir_all(root.join(".agent")).expect("create agent dir"); - let mut manifest = new_game_creation_app_manifest("project-1", "测试项目"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "asset-hero".to_string(), - kind: "character".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/hero.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - image_sequence_frames: None, - image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), - tags: Vec::new(), - }); - write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); - directory - } - - #[test] - fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#, - ) - .expect("reference json"); - let section = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect("render") - .expect("section"); - assert!(section.contains("素材 ID:asset-hero")); - assert!(section.contains("名称:主角")); - assert!(section.contains("项目路径:assets/hero.png")); - assert!(!section.contains("C:\\secret.png")); - } - - #[test] - fn deleted_resource_fails_closed() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#, - ) - .expect("reference json"); - let error = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect_err("missing resource"); - assert_eq!(error, "引用的素材已不存在,请移除后重新选择"); - } - - #[test] - fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":" + + ); + })} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 58a188e6f..c0f0edb40 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -25,8 +25,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; +import { ActiveProjectRunsPanel } from './ActiveProjectRunsPanel'; import { DeveloperAgentDialogs, DeveloperAgentPanel, @@ -96,6 +98,11 @@ export function WorkspaceLauncherShell({ homeCreationBusy, homeCreationRecoverableProjectPath, } = homeProject; + const directInvoke = resolveTauriInvoke(); + const { activeTurns, snapshotReadFailed } = useDirectActiveTurns({ + invoke: directInvoke, + enabled: true, + }); const switchedToGameRuntime = gameRuntimeSwitch !== null && currentProjectContext !== null && @@ -517,6 +524,16 @@ export function WorkspaceLauncherShell({ ) : null} + { + setProjectPath(nextProjectPath); + void openProject(nextProjectPath, 'open'); + }} + /> + {launcherView === 'home' ? ( 0} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 096a53a00..655d5cef5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -85,6 +85,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { directProcessDetail?: string; directProcessKey?: string; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; messagesRef: RefObject; needsUserInput: boolean; onCancelConfirmation: () => void; @@ -139,6 +140,7 @@ export function ProjectSupervisorView({ directProcessDetail = '', directProcessKey = '', hiddenConversationCount, + hasEarlierConversationMessages = false, messagesRef, needsUserInput, onCancelConfirmation, @@ -250,13 +252,15 @@ export function ProjectSupervisorView({ aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'} onScroll={onScroll} > - {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index dd699846a..05e7c1056 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -157,6 +157,7 @@ type ProjectWorkspaceChatPaneProps = { handleRevealCurrentProjectDirectory: () => Promise; handleRuntimeConfigOpen: () => void; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; llmConfigStatus: GameCreatorLlmConfigStatus | null; loadProjectConversation: ( nextProjectPath: string, @@ -256,6 +257,7 @@ export function ProjectWorkspaceChatPane({ handleRevealCurrentProjectDirectory, handleRuntimeConfigOpen, hiddenConversationCount, + hasEarlierConversationMessages = false, llmConfigStatus, loadProjectConversation, localProject, @@ -809,13 +811,15 @@ export function ProjectWorkspaceChatPane({ ) : null}
- {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( ) : null} {visibleMessages.map((message, index) => ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 1971660b5..14d2febcc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -70,6 +70,7 @@ import { writeChatPromptPolishReminderDisabled, } from './chatPromptPolish'; import { ChatPromptPolishReminder } from './ChatPromptPolishReminder'; +import type { DirectCodexUserContentPart } from './generated'; import { $createResourceReferenceNode, $isResourceReferenceNode, @@ -176,7 +177,8 @@ function referenceListKey(references: ChatReference[]) { function sameDraft(left: ChatComposerDraft, right: ChatComposerDraft) { return ( left.text === right.text && - referenceListKey(left.references) === referenceListKey(right.references) + referenceListKey(left.references) === referenceListKey(right.references) && + JSON.stringify(left.content ?? []) === JSON.stringify(right.content ?? []) ); } @@ -184,26 +186,50 @@ function collectDraftParts( node: LexicalNode, textParts: string[], references: ChatReference[], + content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { - textParts.push(node.getTextContent()); + const text = node.getTextContent(); + textParts.push(text); + if (text) content.push({ type: 'input_text', text }); return; } if ($isLineBreakNode(node)) { textParts.push('\n'); + content.push({ type: 'input_text', text: '\n' }); return; } if ($isResourceReferenceNode(node)) { textParts.push(`@${node.__reference.label}`); references.push(node.__reference); + content.push( + 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; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { textParts.push('\n'); + content.push({ type: 'input_text', text: '\n' }); } - collectDraftParts(child, textParts, references); + collectDraftParts(child, textParts, references, content); }); } } @@ -212,10 +238,12 @@ function collectDraftParts( function readDraftFromNodes(): ChatComposerDraft { const textParts: string[] = []; const references: ChatReference[] = []; - collectDraftParts($getRoot(), textParts, references); + const content: DirectCodexUserContentPart[] = []; + collectDraftParts($getRoot(), textParts, references, content); return { text: textParts.join('').trim(), references: dedupeChatReferences(references), + content, }; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index c4aa1640c..5e4a391e5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -71,6 +71,7 @@ type SupervisorChatOnlyViewProps = { transientReply: string; hasConversationControls: boolean; hiddenConversationCount: number; + hasEarlierConversationMessages?: boolean; needsUserInput: boolean; visibleMessages: ChatMessage[]; workspaceStatus: string; @@ -108,6 +109,7 @@ export function SupervisorChatOnlyView({ transientReply, hasConversationControls, hiddenConversationCount, + hasEarlierConversationMessages = false, needsUserInput, visibleMessages, workspaceStatus, @@ -188,12 +190,16 @@ export function SupervisorChatOnlyView({ aria-label={directCodex ? '陶泥儿消息' : '项目总控消息'} onScroll={handleScroll} > - {hiddenConversationCount > 0 ? ( + {hiddenConversationCount > 0 || hasEarlierConversationMessages ? ( + > + {hiddenConversationCount > 0 + ? `显示更早 · 还有 ${hiddenConversationCount} 条对话` + : '显示更早的对话'} + ) : null} {visibleMessages.map((message, index) => (
; +}; + +export type DirectThreadSubscriptionBootstrap = { + subscriptionId: string; + lastCompletedItemId: string | null; + events: DirectThreadRawEvent[]; +}; + +export type DirectThreadConsumeResult = { + events: DirectThreadRawEvent[]; +}; + +export type DirectThreadHistorySlice = { + items: unknown[]; + hasMore: boolean; +}; + +export function directThreadHistoryItemsToMessages( + items: unknown[], +): LocalConversationMessageRecord[] { + return items.flatMap((raw) => { + if (!raw || typeof raw !== 'object') return []; + const item = raw as Record; + const role = item.role; + if (role !== 'user' && role !== 'assistant') return []; + const messageRole = role as 'user' | 'assistant'; + const content = Array.isArray(item.content) + ? item.content + .map((part) => + part && typeof part === 'object' && 'text' in part + ? (part as { text?: unknown }).text + : null, + ) + .filter((text): text is string => typeof text === 'string') + .join('') + : ''; + if (!content) return []; + const messageId = typeof item.id === 'string' ? item.id : undefined; + return [ + { + schemaVersion: 'agc-direct-project-context.v1', + role: messageRole, + content, + agentId: null, + messageId, + updatedAt: 0, + }, + ]; + }); +} + +export type DirectThreadReducerState = { + lastSeq: number; + turnId: string | null; + status: + | 'accepted' + | 'running' + | 'streaming' + | 'finalizing' + | 'completed' + | 'failed' + | null; + activeItemIds: Set; + accumulatedText: string; + progress: string; +}; + +export const emptyDirectThreadReducerState = (): DirectThreadReducerState => ({ + lastSeq: 0, + turnId: null, + status: null, + activeItemIds: new Set(), + accumulatedText: '', + progress: '', +}); + +function itemType(event: DirectThreadRawEvent) { + const value = event.payload.itemType; + return typeof value === 'string' ? value : ''; +} + +function activityDetail(type: string) { + switch (type) { + case 'mcpToolCall': + return '正在调用工具'; + case 'commandExecution': + return '正在执行命令'; + case 'fileChange': + return '正在写入文件'; + case 'webSearch': + return '正在搜索资料'; + case 'contextCompaction': + return '正在整理上下文'; + default: + return '正在处理'; + } +} + +export function reduceDirectThreadEvent( + state: DirectThreadReducerState, + event: DirectThreadRawEvent, +): DirectThreadReducerState { + if (!Number.isSafeInteger(event.seq) || event.seq <= state.lastSeq) { + return state; + } + const next: DirectThreadReducerState = { + ...state, + lastSeq: event.seq, + turnId: event.turnId || state.turnId, + activeItemIds: new Set(state.activeItemIds), + }; + switch (event.type) { + case 'turn.started': + next.status = 'running'; + next.progress = '正在处理'; + next.accumulatedText = ''; + next.activeItemIds = new Set(); + break; + case 'item.started': + if (event.itemId) next.activeItemIds.add(event.itemId); + next.status = 'running'; + next.progress = activityDetail(itemType(event)); + break; + case 'item.delta': { + const delta = event.payload.delta; + if (typeof delta === 'string' && delta) { + next.accumulatedText += delta; + next.status = 'streaming'; + next.progress = '正在生成回复'; + } + break; + } + case 'item.completed': + if (event.itemId) next.activeItemIds.delete(event.itemId); + if (next.status === null) next.status = 'running'; + break; + case 'turn.completed': { + const status = event.payload.status; + next.status = status === 'completed' ? 'completed' : 'failed'; + next.progress = + status === 'completed' ? '正在提交回复' : '正在记录失败原因'; + break; + } + default: + break; + } + return next; +} + +export function reduceDirectThreadEvents( + events: DirectThreadRawEvent[], + initial = emptyDirectThreadReducerState(), +) { + return events.reduce(reduceDirectThreadEvent, initial); +} 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/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 320fd9a15..c08fba49a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -10,6 +10,11 @@ import { buildGameCreationAppAssetTagLibrary, type GameCreationAppAssetTagLibraryEntry, } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; +import type { + DirectCodexUserContentPart, + DirectCodexUserItem, + DirectCodexUserMessageItem, +} from './generated'; export type ResourceReferenceSource = | 'asset-picker' @@ -47,6 +52,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatComposerDraft = { text: string; references: ChatReference[]; + /** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */ + content?: DirectCodexUserContentPart[]; }; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; @@ -84,8 +91,53 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) { export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { text: '', references: [], + content: [], }; +export function chatReferenceToContentPart( + reference: ChatReference, +): DirectCodexUserContentPart { + if (reference.type === 'resource') { + return { type: 'agc_resource_reference', resourceId: reference.resourceId }; + } + return { + type: 'agc_runtime_region_reference', + label: reference.label, + runId: reference.runId, + versionId: reference.versionId, + elementTag: reference.elementTag, + elementRole: reference.elementRole, + text: reference.text, + width: reference.width, + height: reference.height, + resourceIds: reference.resourceIds, + }; +} + +export function chatComposerDraftToDirectCodexUserItem( + draft: ChatComposerDraft, + id: string, +): DirectCodexUserItem { + const content = draft.content?.length + ? draft.content + : 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 { + type: 'message', + role: 'user', + content, + id, + } satisfies DirectCodexUserMessageItem; +} + export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8f92d51fe..6dfa52a06 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -903,6 +903,80 @@ textarea { overflow: hidden; } +.launcher-runs-panel { + position: absolute; + top: 16px; + left: 24px; + z-index: 3; + width: min(280px, calc(100% - 260px)); + padding: 10px 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: color-mix(in srgb, var(--platform-subpanel-fill) 94%, transparent); + box-shadow: 0 8px 24px rgb(31 24 16 / 8%); +} + +.launcher-runs-panel-header { + display: flex; + align-items: center; + gap: 7px; + color: var(--platform-text-strong); + font-size: 12px; +} + +.launcher-runs-panel-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4d9f54; + box-shadow: 0 0 0 3px rgb(77 159 84 / 14%); +} + +.launcher-runs-panel-list { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.launcher-runs-panel-item { + display: grid; + width: 100%; + gap: 2px; + padding: 6px 7px; + border: 0; + border-radius: 8px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.launcher-runs-panel-item:hover, +.launcher-runs-panel-item[aria-current='true'] { + background: rgb(255 255 255 / 72%); +} + +.launcher-runs-panel-item:disabled { + cursor: default; +} + +.launcher-runs-panel-name { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.launcher-runs-panel-meta, +.launcher-runs-panel-note { + color: var(--platform-text-muted); + font-size: 11px; +} + .launcher-promo { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx new file mode 100644 index 000000000..c075aaef3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; + +afterEach(() => cleanup()); + +it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); +}); + +it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts new file mode 100644 index 000000000..ebcdf8794 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { + type ChatComposerDraft, + chatComposerDraftToDirectCodexUserItem, +} from '../src/features/project-workspace/resourceReferences'; + +describe('DirectProject user Response item', () => { + it('保留 Lexical content 的文本与引用交错顺序', () => { + const draft: ChatComposerDraft = { + text: '忽略的扁平摘要', + references: [], + content: [ + { type: 'input_text', text: '先看 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 再看 ' }, + { + type: 'agc_runtime_region_reference', + label: '主画面', + runId: 'run-1', + versionId: 'version-1', + elementTag: 'canvas', + elementRole: 'playfield', + text: '可试玩区域', + width: 320, + height: 180, + resourceIds: ['asset-hero'], + }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-1:user'), + ).toEqual({ + type: 'message', + role: 'user', + id: 'turn-1:user', + content: draft.content, + }); + }); + + it('资源引用只投影稳定 resourceId,不携带展示字段', () => { + const draft: ChatComposerDraft = { + text: '请使用素材', + references: [ + { + type: 'resource', + resourceId: 'asset-hero', + kind: 'character', + mediaType: 'image/png', + label: '主角', + category: 'character', + tags: ['hero'], + source: 'asset-picker', + }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-2:user'), + ).toEqual({ + type: 'message', + role: 'user', + id: 'turn-2:user', + content: [ + { type: 'input_text', text: '请使用素材' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + ], + }); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md new file mode 100644 index 000000000..639c30aab --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject Thread Manager事件订阅-2026-09-15.md @@ -0,0 +1,37 @@ +# 【实施计划】DirectProject Thread Manager 事件订阅 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:DirectProject Rust Thread Manager 深模块、app-server 事件适配、Tauri command/event 桥接、DirectProject 前端订阅/reducer/历史加载、对应测试和主规范。 +- 明确不修改:SpacetimeDB、HTTP API、非 DirectProject Runtime、Codex app-server durable thread、用户可见 JSONL 细节。 +- 保持已有 `.env` 未提交修改,不触碰个人配置。 + +## 实现顺序 + +1. 先新增独立 Rust queue/subscriber 深模块,只承载事件追加、逻辑队头回收、subscriber cursor 锁和纯单测。 +2. 将 app-server 公开事件安全标准化后接入 Thread Manager;在 item 完成持久化成功后追加完成事件,并追加 turn 生命周期事件。 +3. 增加 Tauri `subscribe/consume/readHistory` 命令与 notify 事件,固定错误和 bootstrap 原子边界。 +4. 前端改为 subscriptionId 驱动的 raw event reducer;重进/过期时先 bootstrap,完成后原子替换;历史按 itemId 懒加载。 +5. 移除 DirectProject legacy conversation 读取分支,补齐契约、并发、恢复和失败关闭测试;让初始历史切片的 `hasMore` 独立驱动“显示更早”按钮和滚动入口。 +6. 每个独立切片分别运行定向验证并形成中文小提交;里程碑验收后再清理临时计划。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 的 DirectProject/Thread Manager 定向测试。 +2. 相关前端 Vitest 与类型检查。 +3. `npm run check:encoding` +4. `npm run check:doc-index` +5. `git diff --check` + +## 风险与回滚点 + +- 现有 app-server 事件模型与公开 raw event envelope 不完全一致:先在适配层收口,不让协议细节泄漏到前端。 +- 单 Vec 队列不能中间删除;unfinished item 长时间不结束可能暂时 pin 住队头,必须保留可观测上限和测试。 +- Tauri command 无传输层断开回调,subscriber 只通过 queue eviction 失效;测试不能依赖 unsubscribe 或连接断开清理。 +- legacy 删除属于 breaking history 行为;失败关闭测试必须确认不会 fallback 或迁移。 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..ad2f53f07 --- /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 | in-progress | +| 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/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..df94a1580 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,51 @@ +# 【实施计划】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Milestone: `【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md` + +## 固定契约 + +只读快照命令(Tauri 本地命令,`src-tauri/src/agent/direct_runtime/mod.rs`): + +- `list_game_creator_direct_active_turns() -> Vec` +- 字段(camelCase):`projectPath`、`projectName`、`turnId`、`status`、`activity`(可空)、`startedAt`、`updatedAt`、`sequence` +- `status` 取值集合与既有 Direct 回合事件一致:`accepted` / `running` / `streaming` / `finalizing` / `completed` / `failed` + +身份锁与快照共用同一份进程内注册表;注册表条目在回合进入时写入 `startedAt` 与 `projectName`,在每次回合事件发射时更新 `status` / `activity` / `sequence` / `updatedAt`,在回合结束(guard drop)时移除。 + +## 代码边界 + +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs`:注册表结构扩展、快照读写、新命令、Rust 定向测试 +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs`:事件发射时投影到注册表 +- `apps/ai-game-creator-shell/src-tauri/src/main.rs`:命令注册 +- `apps/ai-game-creator-shell/src/App.tsx`:项目打开时重连、忙碌态与进度恢复、面板挂载 +- `apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`:快照轮询与单飞刷新(面板与重连共用) +- 面板组件(新文件,落在既有 feature 目录下)+ 对应测试 +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` + 测试:报错归类修正 + +## 修改顺序 + +1. Rust:扩展活动回合注册表并暴露只读快照命令,配定向用例(进入 / 进度 / 终态移除 / 多项目并存)。 +2. 前端:接入快照读取,实现“重新进入项目 → 恢复忙碌态与进度 → 以快照 sequence 续接 → 阻止并发提交”。 +3. 前端:在左上角空白区域挂载“正在运行的项目”面板,复用既有组件与设计 token。 +4. 报错归类:按审计结论修正会误导的映射,逐条加回归用例;真实权限拒绝保持原提示。 +5. 文档:主规范与共享记忆同步;里程碑验收后删除临时计划文件。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_active_turns -- --test-threads=1`(名称按实际用例调整) +- `npx vitest run apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts` +- `npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` +- 面板组件测试文件单独一条 vitest +- `npm --prefix apps/ai-game-creator-shell run typecheck` +- `npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 快照命令暴露项目绝对路径给前端:与现有 `projectPath` 口径一致,不得额外泄露配置或 token;命令必须是只读、无副作用。 +- 续接基线 `sequence` 若取错,会让重新进入后的进度事件被丢弃或重复消费;取错时回滚“重连”部分,保留只读面板。 +- 忙碌态恢复不得与既有 `chatAgentBusy` 的失败清理互相覆盖;出现卡死忙碌态时优先回滚重连,不影响身份锁与后台回合本体。 +- 面板若在窄窗口挤压主内容,先按既有响应式约定隐藏面板,不改主布局。 +- 报错归类修正若与既有断言冲突,先确认断言锁的是“正确行为”还是历史错误文案,再决定改断言还是改实现。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md new file mode 100644 index 000000000..656fae8f0 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject Thread Manager事件订阅-2026-09-15.md @@ -0,0 +1,53 @@ +# 【里程碑】DirectProject Thread Manager 事件订阅 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | ready | +| Date | 2026-09-15 | +| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | + +## 目标 + +让 DirectProject 对话在页面离开、重进和短暂断线后仍能由前端重建运行态;运行态事件由 Tauri 进程级 Thread Manager 管理,已完成 item 继续以 `project.jsonl` 为持久化事实源。 + +## 范围 + +- 每 thread 一个全局 seq 和 append-only replay queue。 +- 每 subscriber 独立的 Rust 内部 cursor、并发安全消费和 notify 唤醒。 +- `subscribe` bootstrap、`consume`、`SUBSCRIPTION_EXPIRED` 和历史 item 锚点。 +- app-server 公开事件的安全标准化、item 持久化先于完成事件转发。 +- 前端 raw event reducer、历史懒加载和过期重订阅。 +- 删除本链路 legacy conversation 格式支持,不提供 fallback 或 migration。 + +## 不在范围内 + +- SpacetimeDB、HTTP API、Codex thread durable recovery。 +- 新的 item durable/status/pendingInteraction 字段或持久化确认事件。 +- 前端访问 JSONL 路径、格式或持久化细节。 +- 多 active turn;同一 thread 仍只有一个 active turn。 + +## 依赖与前置条件 + +- DirectProject 现有 app-server 事件解析和 `project.jsonl` 读写。 +- 当前 Tauri command/event 注册入口。 +- 现有前端 DirectProject 聊天 reducer 与历史加载入口。 + +## 验收标准 + +- [ ] 页面离开后 app-server 回合继续,重进页面能通过 subscribe 重建 unfinished item。 +- [ ] 同一 thread 的多个 subscriber 各自消费,不互相覆盖或重复推进 cursor。 +- [ ] `consume` 返回 cursor 之后的全局 raw events,通知不携带 payload。 +- [ ] queue eviction 只清理队头;落后 subscriber 得到 `SUBSCRIPTION_EXPIRED` 并可重新 subscribe。 +- [ ] item 完成先持久化,成功后才进入完成事件队列;失败不发送正常完成事件。 +- [ ] `turn.completed` 由 app-server 终态进入 raw queue,前端据此结束运行态。 +- [ ] subscribe 返回 item 历史锚点而不是完整 history;前端可按 itemId 懒加载。 +- [ ] 未完成 item 的每个 delta 可从 `item.started` 开始重放;不截断 active item。 +- [ ] legacy conversation 行直接失败关闭,无 fallback、无迁移。 +- [x] DirectProject 首页历史切片存在 `hasMore` 时,即使当前可见窗口没有隐藏消息,也提供按钮和滚动两种“显示更早”入口。 + +## 证据要求 + +- 自动化:queue/cursor/eviction 并发单测、事件标准化和持久化顺序测试、Tauri command 测试、前端 reducer 与重订阅测试。 +- 运行时:关闭/切页后重进 DirectProject;并发 item;短暂断线 consume;cursor 过期重订阅。 +- 边界:持久化失败、未知 subscription、queue 超限、多个 subscriber、turn 无 item 间隙、legacy 行拒绝。 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..bcf6efc68 --- /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。 + +## 验收标准 + +- [x] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。 +- [x] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。 +- [x] runtime-region 字段经过 Rust 有界清洗并验证关联资源。 +- [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 +- [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 +- [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 +- [ ] assistant、附件和工具链路行为无变化。 + +## 证据要求 + +- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。 +- 运行时:DirectProject 本地 app-server smoke(如环境可用)。 +- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。 diff --git a/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md new file mode 100644 index 000000000..b8ead65f3 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15.md @@ -0,0 +1,33 @@ +# 【里程碑】Direct回合跨页面生命周期与运行中项目可见性-2026-09-15 + +Version: 1 +Status: in-progress +Date: 2026-09-15 +Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`「2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性」 + +## 目标 + +离开项目界面不再等于“回合消失”:后台继续跑的 Direct 回合必须能被前端重新发现并续接进度,同一项目在回合结束前不允许再发起第二条付费回合;壳层左上角提供“正在运行的项目”面板,列出当前确有在跑回合的项目并可点击进入。 + +## 边界 + +- 只读投影:新增命令只读当前 GUI 进程内的活动回合注册表,不写项目文件、不新增持久化账本。 +- 不新增取消入口;不改变身份锁排他性、项目写锁语义、计费与幂等身份。 +- 不新增跨端契约(Tauri 本地命令,不进 `packages/shared` / `shared-contracts` / OpenAPI)。 +- 面板与重连共用同一份快照,不各自维护第二份“谁在跑”的真相。 +- 报错归类修正只处理“说明与真相无关”的情况,不放宽身份锁、不吞真实失败。 + +## 验收标准 + +- 重新进入有在跑回合的项目后:界面进入“正在处理”、显示最近一次进度、以快照 `sequence` 续接后续事件;回合结束前提交第二条需求不会真正发起第二条付费回合。 +- 回合结束(completed / failed)后:忙碌态解除、可以再次发送;不重复追加助手消息。 +- 无在跑回合的项目:行为与今天一致(可正常发送,不出现额外提示或阻塞)。 +- 左上角面板:列出所有在跑项目,按 `startedAt` 升序,显示项目名(缺失时回退目录名)与状态/时长,点击进入对应项目;没有在跑回合时不渲染面板外壳。 +- 快照读取失败不得阻断发送、不得显示成业务失败。 +- 已修的错误映射不回归:`direct-codex-turn-already-running:` 与历史同义中文正文都归一到“仍在处理这个项目的上一条需求”;真正的 `项目权限策略拒绝执行:` 仍显示审批提示。 + +## 未决事项 + +- “离开页面即取消”仍是未采纳的另一种语义;本轮只实现后台继续。 +- 应用重启后的“未完成回合”恢复不在本里程碑范围(回合注册表是进程内状态);若未来要求跨重启恢复,需要另立里程碑并定义持久化身份与对账合同。 +- 面板是否需要展示非 Direct(专业 Agent / 策划 Agent)运行中的项目,本轮不做;先把 Direct 回合这条事实链路做正确。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd8416d40..3fd4eeb46 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8759,4 +8759,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 +## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 +- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 +- 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8d8af7f25..7d6c1ca0b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1398,3 +1398,11 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执 游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。 验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。 + +## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性 + +Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。 + +壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。 + +活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index bf46784af..45accf8b4 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-11` +更新时间:`2026-09-15` ## 目标 @@ -16,16 +16,18 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`project.jsonl` 的 `payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存运行态 delta/started 事件。 + +Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影,再只发送 item 类型、item ID、delta 文本和 turn 终态等必要字段;不得把完整 item、工具参数或调用结果转发到前端。完整 item 仍只通过上述 JSONL 历史读取。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 -读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。 +读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -55,12 +57,75 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 -`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。 - -这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。 +`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 ## 写入与损坏边界 -写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。 +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。 该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。 + +## Thread Manager 运行态事件订阅 + +DirectProject 的页面不是回合执行的所有者。Tauri 进程内的 Thread Manager 按 thread 维护运行态事件,并允许同一 thread 存在多个独立 subscriber。事件队列只服务运行期间和短期断线恢复,不替代 `project.jsonl` 历史事实源。 + +### 公开契约 + +概念接口如下: + +```ts +subscribe(threadId) -> { + subscriptionId, + lastCompletedItemId: string | null, + events: RawEvent[], +} + +consume(subscriptionId) -> { + events: RawEvent[], +} + +notify -> { subscriptionId } + +readHistory(threadId, { beforeItemId?, limit }) -> { + items: CompletedItem[], + hasMore: boolean, +} +``` + +`subscribe` 不返回完整历史。`lastCompletedItemId` 只是历史读取锚点,前端自行按 item ID 懒加载需要的历史切片。`events` 是当前运行态重建所需的未完成 item 原始事件,以及当前 turn 的生命周期锚点;前端用同一个 reducer 重放 bootstrap 和后续事件。Rust 不保存或理解前端 reducer state。只要 DirectProject 历史切片返回 `hasMore`,聊天视图必须显示“显示更早的对话”入口,并允许按钮或滚动触发下一页,即使当前可见消息窗口没有隐藏消息。 + +`consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 + +`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 + +### 事件和顺序 + +Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: + +```ts +{ + seq: number, + type: string, + turnId: string, + itemId?: string, + payload: unknown, +} +``` + +进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。 + +一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 + +### 队列、subscriber 和回收 + +每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。 + +队列有内部最大事件数和最大序列化字节数。超限时先标记长期落后的 subscriber 为 expired,并将其移出有效 subscriber 的最小 cursor 计算;随后只能清理队头连续、已无有效 subscriber 需要且所属 item 已持久化的事件。没有 subscriber 时,已持久化完成 item 的事件副本可以直接清理。未完成 item 的事件仍保留。 + +subscriber 不依赖 `unsubscribe` 或传输层断开清理。每次 `subscribe` 都创建新的独立 subscription;同一 thread 的其它 subscriber 不受影响。旧 subscription 只有在 queue eviction 后才失效,调用 `consume` 返回统一错误 `SUBSCRIPTION_EXPIRED`。前端保留旧 reducer state,重新 subscribe 完成 bootstrap 后再原子替换。 + +### Bootstrap 原子性和恢复 + +`subscribe` 必须在同一个 Thread Manager 边界注册 subscriber、捕获 queue 尾部、确定历史锚点和当前运行态事件;bootstrap 期间产生的新事件由该 subscriber 的内部 cursor 继续通过 `consume` 获取,不能丢失。 + +断线恢复优先调用 `consume(subscriptionId)`。subscription 仍有效时只返回该 subscriber 尚未消费的 queue 事件;subscription 已过期或 Thread Manager 重启后统一走新的 `subscribe`,再由前端按 `lastCompletedItemId` 从历史懒加载。Rust 不提供 `getItemSnapshot(itemId)`,已完成 item 始终通过历史读取。 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 芯片。