diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md index 577d9e606..fd676d0bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md @@ -13,6 +13,7 @@ Use `agc_browser_playtest` from the `agc_tools` MCP server. Do not replace it wi 2. Inspect both desktop and mobile results, including page readiness, visible text, screenshots, console errors, exceptions, failed requests, Canvas probes, blocked actions, and interaction evidence. 3. Compare screenshots with the user's request. Check that the active game fills its intended area, HUD elements do not cover gameplay, controls are visible, and requested platform art appears in the core experience. 4. If evidence exposes a defect, edit the actual game files and call the tool again when that is useful. The client enforces its own execution and resource bounds; do not invent a fixed repair loop in the response. + Feed the structured diagnostics, console errors, failed requests, and exception text back to the same LLM repair turn before reporting the playtest as failed. Treat the evidence as debugging input and rerun the affected stage after a real code or project change. 5. Treat browser infrastructure failure, an unloaded page, an unhandled exception, or missing evidence as a failed validation. Do not claim success from a partial result. 6. Use game-specific reasoning for quality. Do not require a fixed board, fixed text, fixed number of slices, or a legacy harness scenario; the tool result is evidence for Codex to interpret. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md index 82a42ddee..1365ebd46 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md @@ -27,6 +27,8 @@ For a small edit to an existing game where the brief and suitable assets are unc When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output. +Every repairable failure must be fed back to the current LLM as the next debugging context before the stage is considered failed. Preserve the redacted tool or command error, the stage, the attempted input, and the evidence already collected; ask the LLM to inspect the current project, make the smallest real repair, and rerun the failed stage. A client-side `isError` tool result or a failed verification is feedback for the LLM, not by itself a terminal user-facing result. Do not silently swallow the error, replace it with a placeholder, or stop after the first failed attempt. Authentication, permission, billing, project identity, corrupted history, transport loss, cancellation, and uncertain paid-operation state remain terminal safety boundaries. + Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered. Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 19833fb4f..f2270d7b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.15", + "version": "2026-08-26.16", "skills": [ { "name": "agc-game-production-workflow", @@ -22,7 +22,7 @@ "agents/openai.yaml", "references/workflow-contract.md" ], - "sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c" + "sha256": "f25e5bd27e8fc82c61b08dc66366b5b253ee8d16d7fa72dbf2c94d2462f4e7fc" }, { "name": "agc-project-structure", @@ -98,7 +98,7 @@ "agents/openai.yaml", "references/browser-evidence-contract.md" ], - "sha256": "4437cd8a927a1c79a5faf4bcd40e9946676c08a3b460ab171298cabf899f49ad" + "sha256": "92ecce42d6589e034d32b75bcd155c1fee34a8c7b843eea5780c0577300ed521" }, { "name": "agc-client-projection", 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 index 627002154..d903e008b 100644 --- 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 @@ -7,17 +7,89 @@ use super::direct_project_history_injection_oversize_error; use serde_json::Value; use std::path::Path; +const DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES: usize = 8 * 1024 * 1024; +const DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT: &str = + "[历史图片预览已省略:本次恢复图片预算已用尽]"; + +fn omit_image_block(object: &mut serde_json::Map, text_type: &str) { + object.clear(); + object.insert("type".to_string(), Value::String(text_type.to_string())); + object.insert( + "text".to_string(), + Value::String(DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT.to_string()), + ); +} + +fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) { + match value { + Value::Array(values) => values + .iter_mut() + .for_each(|value| compact_history_images(value, remaining_bytes)), + Value::Object(object) => { + let is_image_block = object.get("type").and_then(Value::as_str) == Some("image"); + if is_image_block { + if let Some(data) = object.get("data").and_then(Value::as_str) { + if let Some((preview, mime_type)) = crate::agent::compact_mcp_image_data(data) { + if preview.len() > *remaining_bytes { + omit_image_block(object, "text"); + } else { + *remaining_bytes -= preview.len(); + object.insert("data".to_string(), Value::String(preview)); + object.insert( + "mimeType".to_string(), + Value::String(mime_type.to_string()), + ); + } + } + } + } + if object.get("type").and_then(Value::as_str) == Some("input_image") { + if let Some(url) = object + .get("image_url") + .and_then(Value::as_str) + .map(str::to_string) + { + if let Some((header, data)) = url.split_once(",") { + if header.ends_with(";base64") { + if let Some((preview, mime_type)) = + crate::agent::compact_mcp_image_data(data) + { + if preview.len() > *remaining_bytes { + omit_image_block(object, "input_text"); + } else { + *remaining_bytes -= preview.len(); + object.insert( + "image_url".to_string(), + Value::String(format!("data:{mime_type};base64,{preview}")), + ); + } + } + } + } + } + } + object + .values_mut() + .for_each(|value| compact_history_images(value, remaining_bytes)); + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + 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 mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES; let items = canonical_items .iter() .map(|item| { - direct_codex_user_item_to_response_item(history_root, item) - .map_err(platform_llm::LlmError::InvalidRequest) + let mut projected = direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + compact_history_images(&mut projected, &mut remaining_image_bytes); + Ok(projected) }) .collect::, _>>()?; let params = serde_json::json!({"threadId": thread_id, "items": items}); @@ -30,3 +102,27 @@ pub(super) fn build_direct_project_history_injection_params( } Ok(params) } + +#[cfg(test)] +mod tests { + use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT}; + use serde_json::json; + + #[test] + fn history_image_budget_omits_only_wire_preview_when_exhausted() { + let mut item = json!({ + "type": "function_call_output", + "output": {"content": [{ + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "mimeType": "image/png" + }]} + }); + let mut remaining = 1; + compact_history_images(&mut item, &mut remaining); + let block = &item["output"]["content"][0]; + assert_eq!(block["type"], "text"); + assert_eq!(block["text"], DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT); + assert_eq!(remaining, 1); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 92a7adf14..08c7f56be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2061,6 +2061,84 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool { .any(|marker| error.contains(marker)) } +/// DirectProject 的工具 / 构建 / 试玩失败应作为下一轮 LLM 的调试上下文继续处理, +/// 而不是在 app-server 把本轮标成 failed 后立即把错误交给用户。基础设施、身份和 +/// 历史一致性错误没有安全的自动修复路径,必须保持终止语义。 +const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3; + +fn direct_codex_error_should_feedback(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + let terminal_markers = [ + "authentication-required", + "401", + "403", + "泥点余额不足", + "insufficient_mud_points", + "身份不唯一", + "身份不匹配", + "合同发生变化", + "历史记录类型无效", + "历史记录缺少 payload", + "历史注入载荷超过单行上限", + "工具参数", + "transport closed", + "连接已关闭", + "连接上游失败", + "硬上限", + "超时", + "取消", + "凭据", + "credential", + "context-window-exceeded", + "request-too-large", + "session-budget-exceeded", + "usage-limit-exceeded", + "stream-required", + "cyber-policy", + "sandbox-error", + "thread-rollback-failed", + "bad-request", + ]; + if terminal_markers.iter().any(|marker| { + if marker.chars().any(|character| character.is_uppercase()) { + error.contains(marker) + } else { + normalized.contains(marker) + } + }) { + return false; + } + let repairable_markers = [ + "工具", + "tool", + "构建", + "build", + "编译", + "验证", + "verify", + "试玩", + "playtest", + "console", + "exception", + "未通过", + "失败", + "error", + ]; + repairable_markers.iter().any(|marker| { + if marker.chars().any(|character| character.is_uppercase()) { + error.contains(marker) + } else { + normalized.contains(marker) + } + }) +} + +fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { + format!( + "上一轮 AGC 工具、构建或试玩执行失败。不要直接结束本轮,请把下面的错误当作新的调试信息:读取当前项目和相关输出,定位原因,修改实际项目文件后重新执行必要的失败步骤;只有确认属于鉴权、余额、项目身份、历史损坏、传输断开或操作状态不确定时才停止。不要伪造成功,也不要只复述错误。\n\n错误信息(客户端已脱敏):\n{error}\n\n这是第 {attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS} 次错误反馈。", + ) +} + /// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果, /// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。 const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[ @@ -4273,7 +4351,7 @@ fn build_direct_codex_system_prompt_with_search( DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), - "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), + "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), format!("提示词与技能:{skill_index}"), ]; if controlled_web_search { @@ -4765,16 +4843,40 @@ async fn run_direct_game_creator_turn_inner( } } }; - let reply_result = direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt, - prompt.to_string(), - Some(&client_turn_id), - Some(&mut observer), - audit, - direct_user_item.clone(), - ) - .await; + let mut feedback_prompt = prompt.to_string(); + let mut audit = audit; + let mut attempt = 1; + let reply_result = loop { + match direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + Some(&client_turn_id), + Some(&mut observer), + audit.as_deref_mut(), + direct_user_item.clone(), + ) + .await + { + Ok(value) => break Ok(value), + Err(error) + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && direct_codex_error_should_feedback(&error) => + { + let detail = redact_agent_runtime_error(root, &error, 1800); + emitter.emit( + "running", + Some("error-feedback"), + Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), + None, + ); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); + } + // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 + Err(error) => break Err(error), + } + }; drop(observer); if let Some(item) = stream_writer.take_pending_snapshot() { stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item)); @@ -4788,16 +4890,41 @@ async fn run_direct_game_creator_turn_inner( } reply_result } else { - direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt, - prompt.to_string(), - None, - None, - audit, - direct_user_item.clone(), - ) - .await + let mut feedback_prompt = prompt.to_string(); + let mut audit = audit; + let mut response = None; + for attempt in 1..=DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS { + match direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + None, + None, + audit.as_deref_mut(), + direct_user_item.clone(), + ) + .await + { + Ok(value) => { + response = Some(value); + break; + } + Err(error) + if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && direct_codex_error_should_feedback(&error) => + { + let detail = redact_agent_runtime_error(root, &error, 1800); + feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt + 1); + } + Err(error) => { + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::CodeGeneration, + error, + )); + } + } + } + response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string()) } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 @@ -5172,6 +5299,33 @@ fn persist_direct_codex_assistant_reply_at( mod tests { use super::*; + #[test] + fn direct_tool_and_playtest_errors_are_feedbackable_but_transport_and_identity_errors_stop() { + assert!(direct_codex_error_should_feedback( + "agc_browser_playtest 失败:页面抛出异常" + )); + assert!(direct_codex_error_should_feedback("npm run build 编译失败")); + assert!(!direct_codex_error_should_feedback( + "authentication-required: HTTP 401" + )); + assert!(!direct_codex_error_should_feedback( + "Codex app-server 连接已关闭" + )); + assert!(!direct_codex_error_should_feedback("项目身份不匹配")); + assert!(!direct_codex_error_should_feedback( + "工具参数 attempt 必须是 1 到 3 的整数" + )); + } + + #[test] + fn direct_error_feedback_prompt_requires_real_repair_and_is_bounded() { + let prompt = direct_codex_error_feedback_prompt("npm run build 失败:入口不存在", 2); + assert!(prompt.contains("读取当前项目和相关输出")); + assert!(prompt.contains("不要伪造成功")); + assert!(prompt.contains("第 2/3 次错误反馈")); + assert!(prompt.contains("入口不存在")); + } + fn direct_test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 059fd8481..2e3889f46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -19,6 +19,8 @@ const DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOL_BRIDGE_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024; +const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES: usize = 256 * 1024; +const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5; const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss"; @@ -694,14 +696,44 @@ fn direct_tool_bridge_state_with_search( }) } +/// 将 MCP 图片 block 限制为可安全回显和持久化的预览。 +/// +/// 工具结果会被 Codex 原样写入 DirectProject 历史;这里保留小图的原始 +/// PNG,大图则缩放并转成 JPEG。项目文件中的原图不受影响,历史恢复仍有 +/// 可见证据,但不会把多张几 MiB 的截图永久复制进上下文。 +pub(crate) fn compact_mcp_image_data(data: &str) -> Option<(String, &'static str)> { + let bytes = BASE64_STANDARD.decode(data).ok()?; + if bytes.is_empty() { + return None; + } + if bytes.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES { + return Some((data.to_string(), "image/png")); + } + + let image = image::load_from_memory(&bytes).ok()?; + let mut preview = image.thumbnail( + DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION, + DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION, + ); + for (dimension, quality) in [(1024, 78), (768, 70), (512, 60), (384, 50)] { + if preview.width() > dimension || preview.height() > dimension { + preview = image.thumbnail(dimension, dimension); + } + let mut encoded = Vec::new(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, quality); + preview.write_with_encoder(encoder).ok()?; + if encoded.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES { + return Some((BASE64_STANDARD.encode(encoded), "image/jpeg")); + } + } + None +} + fn bridge_tool_result(text: String, images: Vec, is_error: bool) -> Value { let mut content = vec![json!({ "type": "text", "text": text })]; - content.extend(images.into_iter().map(|data| { - json!({ - "type": "image", - "data": data, - "mimeType": "image/png" - }) + content.extend(images.into_iter().filter_map(|data| { + let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png")); + Some(json!({ "type": "image", "data": data, "mimeType": mime_type })) })); json!({ "content": content, "isError": is_error }) } @@ -2672,7 +2704,7 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { use super::*; - use std::io::{Read, Write}; + use std::io::{Cursor, Read, Write}; #[tokio::test] async fn controlled_search_client_omits_agc_marker() { @@ -2766,6 +2798,35 @@ mod tests { assert!(bridge_search_max_results(&json!({ "maxResults": 6 })).is_err()); } + #[test] + fn large_mcp_images_are_reduced_to_bounded_jpeg_previews() { + let image = image::RgbaImage::from_fn(1600, 1200, |x, y| { + image::Rgba([ + (x % 251) as u8, + (y % 251) as u8, + ((x.wrapping_mul(31) + y.wrapping_mul(17)) % 251) as u8, + u8::MAX, + ]) + }); + let mut png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut png, image::ImageFormat::Png) + .expect("encode image fixture"); + assert!(png.get_ref().len() > DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES); + + let (preview, mime_type) = + compact_mcp_image_data(&BASE64_STANDARD.encode(png.into_inner())) + .expect("large valid image should produce preview"); + assert_eq!(mime_type, "image/jpeg"); + assert!( + BASE64_STANDARD + .decode(preview) + .expect("preview base64") + .len() + <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES + ); + } + #[test] fn search_parser_accepts_only_bounded_public_https_results() { let body = r#"Tauri & Rusthttps://tauri.app/<b>Cross-platform apps</b>Privatehttp://127.0.0.1:8082/privateprivateCredentialshttps://user:pass@example.test/pathprivateLoopback hosthttps://localhost/privateprivateLocal hosthttps://service.internal/privateprivate"#; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 51b826f08..ca97077f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5193,7 +5193,12 @@ pub(crate) fn create_game_creator_agent_session( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; + // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 + // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f9803ec0c..2438d17f8 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -7018,8 +7018,15 @@ export function App({ // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { const directInvoke = resolveTauriInvoke(); - const directProjectPath = resolveChatProjectPath(localProject); - if (directProjectPath && directInvoke) { + // Capture the project snapshot before any asynchronous policy/session work. + // `resolveChatProjectPath` only returns a path and TypeScript cannot infer + // that the source project is still non-null after an await; keeping the + // immutable snapshot also prevents a project switch from changing the + // projectId used by this turn halfway through submission. + const directProject = localProject; + const directProjectPath = resolveChatProjectPath(directProject); + const directProjectId = directProject?.manifest.projectId; + if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); const effectiveUserItem = diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index a9c5e2f29..70f9eb496 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -3,10 +3,12 @@ import { Copy, Minus, Square, X } from 'lucide-react'; import { type ReactNode, useCallback, useEffect, useState } from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel'; import { subscribeTauriEvent } from '../services/tauriEventSubscription'; import { AppUpdateNotice } from './AppUpdateNotice'; import { WINDOW_CHROME_DEFAULT_TITLE, + type WindowChromeActiveProjectRuns, WindowChromeContext, type WindowChromeContextValue, } from './windowChromeContext'; @@ -39,6 +41,8 @@ function getNativeWindow() { export function WindowChrome({ children }: WindowChromeProps) { const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE); const [walletSlot, setWalletSlot] = useState(null); + const [activeProjectRuns, setActiveProjectRuns] = + useState(null); const setTitle = useCallback((nextTitle: string | null | undefined) => { const normalizedTitle = nextTitle?.trim(); @@ -50,6 +54,8 @@ export function WindowChrome({ children }: WindowChromeProps) { title, setTitle, walletSlot, + activeProjectRuns, + setActiveProjectRuns, }; const [isMaximized, setIsMaximized] = useState(false); @@ -142,19 +148,33 @@ export function WindowChrome({ children }: WindowChromeProps) {
- - +
+ {activeProjectRuns && + (activeProjectRuns.activeTurns.length > 0 || + activeProjectRuns.readFailed) ? ( + + ) : ( + <> +
diff --git a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts index e84a79830..5e9141404 100644 --- a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts +++ b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts @@ -1,5 +1,7 @@ import { createContext, useContext } from 'react'; +import type { GameCreatorDirectActiveTurn } from '../app/types'; + export const WINDOW_CHROME_DEFAULT_TITLE = '创作工作台'; export type WindowChromeContextValue = { @@ -7,6 +9,17 @@ export type WindowChromeContextValue = { title: string; setTitle: (title: string | null | undefined) => void; walletSlot: HTMLElement | null; + activeProjectRuns: WindowChromeActiveProjectRuns | null; + setActiveProjectRuns: ( + activeProjectRuns: WindowChromeActiveProjectRuns | null, + ) => void; +}; + +export type WindowChromeActiveProjectRuns = { + activeTurns: GameCreatorDirectActiveTurn[]; + currentProjectPath?: string | null; + readFailed?: boolean; + onOpenProject?: (projectPath: string) => void; }; export const WindowChromeContext = createContext({ @@ -14,6 +27,8 @@ export const WindowChromeContext = createContext({ title: WINDOW_CHROME_DEFAULT_TITLE, setTitle: () => undefined, walletSlot: null, + activeProjectRuns: null, + setActiveProjectRuns: () => undefined, }); export function useWindowChrome() { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index 3a9933491..6545cd169 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -1,6 +1,7 @@ import { PlatformMudPointWalletEntry } from '../../../../../packages/shared/src/components/PlatformMudPointWalletEntry'; import { PlatformProfileRechargeModal } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; import { PlatformProfileWalletLedgerModal } from '../../../../../packages/shared/src/components/PlatformProfileWalletLedgerModal'; +import { ThemedModal } from '../../components/modal/ThemedModal'; import type { AccountWalletController } from './useAccountWallet'; export function AccountWalletBar({ @@ -21,6 +22,7 @@ export function AccountWalletBar({ onRequestDetails={() => void controller.onWalletBalanceMayHaveChanged()} onRecharge={controller.openRecharge} onOpenLedger={controller.openWalletLedger} + onRedeemCode={controller.openRedeemCode} />
); @@ -63,6 +65,54 @@ export function AccountWalletDialogs({ onRetry={() => void controller.loadWalletLedger()} /> ) : null} + +
+ 兑换码 + +
+
{ + event.preventDefault(); + void controller.redeemCode(); + }} + > + + controller.setRedeemCodeInput(event.target.value) + } + placeholder="输入兑换码" + aria-label="兑换码" + autoFocus + /> + {controller.redeemCodeError ? ( +

{controller.redeemCodeError}

+ ) : null} + {controller.redeemCodeSuccess ? ( +

{controller.redeemCodeSuccess}

+ ) : null} + +
+
); } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 4147abb9b..1df87f2bb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -1,9 +1,12 @@ +import { ChevronDown } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + import type { GameCreatorDirectActiveTurn } from '../../app/types'; import { projectNameFromPath } from '../agent-runtime'; import { projectPathsMatchForInvalidation } from '../project-summary/projectPath'; /** - * 左上角的"正在运行的项目"面板。 + * 窗口标题栏的"正在运行的项目"入口,也保留面板布局供独立组件测试和复用。 * * 数据来自 Rust 的活动回合注册表(同一个只读快照也用于重新进入项目时的进度重连), * 面板只负责呈现:项目名、阶段、已运行时长,以及点击进入该项目。没有在跑回合时 @@ -14,6 +17,7 @@ export type ActiveProjectRunsPanelProps = { currentProjectPath?: string | null; readFailed?: boolean; onOpenProject?: (projectPath: string) => void; + placement?: 'panel' | 'titlebar'; }; const ACTIVE_TURN_STATUS_LABELS: Record = { @@ -56,11 +60,47 @@ export function ActiveProjectRunsPanel({ currentProjectPath = null, readFailed = false, onOpenProject, + placement = 'panel', }: ActiveProjectRunsPanelProps) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!open || placement !== 'titlebar') { + return; + } + const handlePointerDown = (event: PointerEvent) => { + if (!menuRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('pointerdown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, placement]); + if (activeTurns.length === 0) { if (!readFailed) { return null; } + if (placement === 'titlebar') { + return ( + + 正在运行的项目读取失败 + + ); + } // 三次都没读到快照:只说"没读到",不改写成业务、权限或审批结论。 return (