From c7ba007463bfe0d08cdcb4d1a5a1deafafbf3a6e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:16:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E9=95=BF?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=89=B4=E6=9D=83=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 DirectProject 长回合平台会话保活与 401 分类 允许已登记普通平台图片通过运行时素材完成门禁 兼容预览 UUID 图片路径并补充技术方案说明 --- .../src-tauri/src/agent/direct_runtime.rs | 85 +++++++++++++++++-- .../src-tauri/src/agent/direct_tool_bridge.rs | 3 + .../src/agent/generation/canvas_generation.rs | 16 ++++ apps/ai-game-creator-shell/src/App.tsx | 18 ++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 ++ 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 64343f79f..5d358b640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2353,6 +2353,12 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .map(|asset| asset.local_path), ); } + // Ordinary platform images generated by `agc_generate_image` are valid + // runtime art even when the project does not contain the canonical + // art-spec/background/spritesheet package. The old check only admitted + // those canonical paths, so a game using registered images such as + // `assets/neon-mine.png` was forced through repeated repair turns forever. + available_paths.extend(direct_registered_taonier_runtime_image_paths(root)); available_paths.sort(); available_paths.dedup(); available_paths @@ -2361,6 +2367,34 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { + let Ok(manifest) = read_manifest_for_project(root) else { + return Vec::new(); + }; + manifest + .assets + .iter() + .filter(|asset| { + asset.media_type.starts_with("image/") + && asset.kind != "icon-spec" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && asset + .source + .generation_route + .as_deref() + .is_some_and(|route| route.starts_with("/api/external/v1/editor/")) + }) + .filter_map(|asset| { + let path = direct_normalized_project_asset_path(&asset.local_path)?; + let bytes = std::fs::read(root.join(&path)).ok()?; + let validated = + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {path}")) + .ok()?; + validated.has_visible_pixels.then_some(path) + }) + .collect() +} + fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { !direct_game_sources_referenced_taonier_assets(root).is_empty() } @@ -2416,11 +2450,21 @@ fn direct_browser_evidence_needs_art_repair( let Some(evidence) = evidence else { return false; }; + let referenced_count = direct_game_sources_referenced_taonier_assets(root).len(); evidence.passed - && evidence - .viewport_results - .iter() - .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty()) + && evidence.viewport_results.iter().any(|viewport| { + let exact_matches = direct_rendered_taonier_assets_in_viewport(root, viewport); + if !exact_matches.is_empty() { + return false; + } + // The preview server rewrites local asset URLs to opaque UUID + // routes. When the browser probe cannot map those routes back to + // project-relative paths, use its count as a bounded observation: + // every referenced platform image must have a corresponding + // rendered local-image route in this viewport. + let rendered_count = direct_browser_rendered_image_paths(viewport).len(); + referenced_count == 0 || rendered_count < referenced_count + }) } /// The only output-shape proof the direct Runtime owns. It deliberately does @@ -3790,7 +3834,16 @@ fn render_direct_browser_acceptance_report( }; let assets = direct_rendered_taonier_assets_in_viewport(root, viewport); if assets.is_empty() { - format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + let rendered_count = direct_browser_rendered_image_paths(viewport).len(); + if rendered_count > 0 + && !direct_game_sources_referenced_taonier_assets(root).is_empty() + { + format!( + "{name}: 已观察到 {rendered_count} 个本地图片资源进入 Canvas/WebGL 渲染" + ) + } else { + format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + } } else { format!("{name}: {}", assets.join("、")) } @@ -7854,6 +7907,28 @@ mod tests { .is_some_and(|error| error.contains("未在源码中引用任何已登记的陶泥儿平台图片"))); } + #[test] + fn direct_completion_accepts_an_independent_registered_platform_image() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片") + .expect("init project"); + std::fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image"); + std::fs::write(root.path().join("game/index.html"), "").expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const mine = new Image(); mine.src = '/assets/neon-mine.png';", + ) + .expect("script"); + + assert_eq!( + direct_game_sources_referenced_taonier_assets(root.path()), + vec!["assets/neon-mine.png".to_string()] + ); + assert!(direct_game_output_completion_error(root.path()).is_none()); + } + #[test] fn direct_completion_accepts_a_registered_independent_slice_reference() { let root = tempfile::tempdir().expect("temp dir"); 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 12c6f8d27..059fd8481 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 @@ -1914,6 +1914,9 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .await .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; if !status.is_success() { + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string()); + } return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); } let queue_state = external_editor_response_data(&payload).clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f9bbd95a8..693aa2dc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -868,6 +868,12 @@ pub(crate) async fn external_editor_json_request( /// entire response body (which may contain URLs, ids, paths or credentials). fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String { let detail = summarize_external_http_error_body(body); + if status == reqwest::StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => format!("authentication-required: {action}失败:HTTP 401:{detail}"), + None => format!("authentication-required: {action}失败:HTTP 401"), + }; + } match detail { Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()), None => format!("{action}失败:HTTP {}", status.as_u16()), @@ -7930,6 +7936,16 @@ mod canvas_generation_tests { assert!(!summary.contains("C:\\Users\\private"), "{summary}"); } + #[test] + fn external_http_401_is_classified_as_authentication_required() { + let error = + format_external_http_error("提交平台图片生成", reqwest::StatusCode::UNAUTHORIZED, ""); + assert_eq!( + error, + "authentication-required: 提交平台图片生成失败:HTTP 401" + ); + } + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream .set_nonblocking(false) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 723c0429c..fee3d987f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -286,6 +286,11 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +// Platform access tokens are short lived. DirectProject can spend several +// minutes in image generation, build and browser validation, so keep the +// client-owned native session current while a turn is running. The singleflight +// refresh in platformSession.ts coalesces this with any 401-triggered refresh. +const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000; function isDirectCodexAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -1881,6 +1886,19 @@ export function App({ }; }, [directCodexProductRuntime]); + useEffect(() => { + if (!directCodexProductRuntime || !chatAgentBusy) { + return; + } + const timer = window.setInterval(() => { + void requestPlatformSessionRefresh().catch(() => { + // The active DirectProject turn will surface the original auth error; + // keepalive must not replace it with an unrelated background error. + }); + }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); + return () => window.clearInterval(timer); + }, [chatAgentBusy, directCodexProductRuntime]); + useEffect(() => { if (projectSupervisorOnly && !directCodexProductRuntime) { return; diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3f091c118..a232c7612 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-15 DirectProject 长回合平台会话保活 + +DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。 + +完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。 + ## 2026-09-12 已有项目打开响应性 DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。 From 7015f41a8e2694c1b7358ff4583bc5378978ecdc Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 22:19:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Skill=20?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E6=8C=87=E7=BA=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 taonier-art-assets 内容指纹 递增 Skill pack manifest 版本 --- .../src-tauri/resources/agc-skills/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 76de3bbc3..19833fb4f 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.14", + "version": "2026-08-26.15", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37" + "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec" }, { "name": "agc-web-game-development", From 2ef9238aa9b63e2756c9f4170901a12e82325c48 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 15 Sep 2026 22:50:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=A4=87=E4=BB=BD=E7=8A=B6=E6=80=81=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 备份锁冲突时写入失败状态 JSON 发布脚本安全解析异步备份状态并保留异常文件 生产发布临时目录统一使用 ~/data/tmp --- scripts/database-backup-to-oss.mjs | 15 ++++++++++++ scripts/deploy/production-stdb-publish.sh | 30 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index c64e8657f..148267ac7 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -4517,6 +4517,21 @@ if ( for (const line of describeError(error)) { console.error(`[database-backup] ${line}`); } + // 即使初始化失败(例如已有进程持有锁),也保持 --result-file 可被机器读取。 + // 调用方可以检查错误载荷并跳过延后上传,避免对空的临时文件执行 JSON.parse。 + try { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.resultFile) { + atomicWriteJson(resolvePath(parsed.resultFile), { + uploadStatus: 'failed', + error: error instanceof Error ? error.message : String(error), + archivePath: '', + manifestPath: '', + }); + } + } catch { + // 参数解析或状态文件写入失败时,不能掩盖原始备份错误。 + } process.exit(1); }); } diff --git a/scripts/deploy/production-stdb-publish.sh b/scripts/deploy/production-stdb-publish.sh index f34492476..c102e45ec 100644 --- a/scripts/deploy/production-stdb-publish.sh +++ b/scripts/deploy/production-stdb-publish.sh @@ -519,7 +519,9 @@ prepare_async_backup() { restart_service_args+=(--restart-service-after genarrative-api.service) fi - ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)" + task_tmp_dir="${HOME}/data/tmp" + mkdir -p "${task_tmp_dir}" + ASYNC_BACKUP_STATUS_FILE="$(mktemp "${task_tmp_dir}/genarrative-stdb-backup-status.XXXXXX.json")" echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS" node -- "${ASYNC_BACKUP_SCRIPT}" \ --env-file /etc/genarrative/api-server.env \ @@ -536,14 +538,32 @@ start_async_backup_upload() { local node_binary="" local unit_name="" local unit_suffix="" + local backup_paths="" if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2 return 0 fi - ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" - ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")" + # 备份进程可能因数据库锁冲突在写入状态文件前退出,或只留下空/截断文件。 + # 解析失败时保留状态文件并让调用方继续处理发布失败,不能再让 node JSON.parse + # 的堆栈噪声覆盖原始备份错误。 + if ! backup_paths="$(node -e ' + const fs = require("node:fs"); + const p = process.argv[1]; + let o; + try { + o = JSON.parse(fs.readFileSync(p, "utf8")); + } catch { + process.exit(2); + } + process.stdout.write(`${o.archivePath || ""}\n${o.manifestPath || ""}`); + ' "${ASYNC_BACKUP_STATUS_FILE}" 2>/dev/null)"; then + echo "[production-stdb-publish] 警告:备份状态文件为空或不是有效 JSON,跳过异步上传并保留状态文件: ${ASYNC_BACKUP_STATUS_FILE}" >&2 + return 1 + fi + ASYNC_BACKUP_ARCHIVE="${backup_paths%%$'\n'*}" + ASYNC_BACKUP_MANIFEST="${backup_paths#*$'\n'}" if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2 return 0 @@ -727,7 +747,9 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2 exit 1 fi - PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)" + task_tmp_dir="${HOME}/data/tmp" + mkdir -p "${task_tmp_dir}" + PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/genarrative-stdb-publish.XXXXXX")" install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm" chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}" PUBLISH_ARGS=(