diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index f629f6251..5088f80b6 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -7,6 +7,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: project-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.husky/pre-commit b/.husky/pre-commit index 78fe78bb9..65c4d6e28 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,4 @@ +# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd; +# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES npm run format:staged diff --git a/.husky/pre-push b/.husky/pre-push index fdb72ecc2..2044c66f1 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1 +1,4 @@ +# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd; +# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES npm run check:pre-push-master -- "$@" diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index f262b8254..0feeb767d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({ interface MockIntersectionObserverController { enter: (target: Element) => void; + enterAll: (targets: Element[]) => void; isObserved: (target: Element) => boolean; } @@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController { ); }); }, + enterAll(targets) { + act(() => { + for (const target of targets) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + } + }); + }, isObserved(target) { return observed.has(target); }, @@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as const thumbnails = entries.map((entry) => thumbnailElementForLabel(entry.label), ); - thumbnails.forEach((thumbnail) => { + for (const thumbnail of thumbnails) { expect(observer.isObserved(thumbnail)).toBe(true); - observer.enter(thumbnail); - }); + } + observer.enterAll(thumbnails); await act(async () => { await Promise.resolve(); }); @@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as await vi.advanceTimersByTimeAsync(200); }); expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); -}); +}, 10_000); test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { const observer = installIntersectionObserverMock(); diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 91e5d01a0..b6193445f 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -2,6 +2,8 @@ "schemaVersion": "game-creator-config.v2", "agentMode": "codex_app_server", "llm": { + "customEnabled": false, + "visibleModels": [], "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", "model": "gpt-6-astra", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c08cb6ac9..ce2317900 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.29", + "version": "0.1.47", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", @@ -57,6 +57,7 @@ "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "vite": "^6.2.0", "zustand": "^5.0.14" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 7e07a3c9c..7dd55a0f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.29" +version = "0.1.47" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 4fe37d25a..b5cb1f2d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.29" +version = "0.1.47" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index c9c1bd6cf..ce086275f 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, 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-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 07cbe9c1b..4d4862554 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. -6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 15006410b..87cba6b1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. -`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. +`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. 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..c57ea3638 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.17", "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", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" + "sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1" } ] } 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 1a9a984f2..e64b321dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -22,7 +22,9 @@ mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; mod direct_tool_bridge; +mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_stream; mod generation; mod interaction; mod prompt; @@ -36,8 +38,9 @@ mod runtime_tools; mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ + cancel_direct_codex_turn_at, 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, + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -53,7 +56,9 @@ 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_tool_calls::*; pub(crate) use direct_tools_mcp::*; +pub(crate) use direct_turn_stream::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; 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/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7377f445c..457185fc0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -131,7 +131,6 @@ impl CodexAppServerCredential { ) -> Option<(&'a str, &'a str)> { match self { Self::PlatformSession { .. } => None, - #[cfg(test)] Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), #[cfg(test)] @@ -139,7 +138,7 @@ impl CodexAppServerCredential { .as_deref() .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), #[cfg(not(test))] - Self::AppDataKey { .. } | Self::AuthBridge { .. } => None, + Self::AuthBridge { .. } => None, } } } @@ -196,6 +195,20 @@ impl CodexAppServerStderrSummary { } } +/// 派发 app-server 的收尾与中断任务。 +/// +/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn` +/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程); +/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。 +/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor +/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409` +/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER +/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast +/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。 +fn spawn_codex_app_server_task(task: impl std::future::Future + Send + 'static) { + tauri::async_runtime::spawn(task); +} + struct CodexTurnStartCancellation { inner: Weak, thread_id: String, @@ -222,6 +235,12 @@ impl CodexTurnStartCancellation { self.maybe_interrupt(); } + /// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须 + /// 明确报错,而不是静默成功让界面以为回合已经停了。 + fn app_server_alive(&self) -> bool { + self.inner.strong_count() > 0 + } + fn cancel(&self) { self.cancelled.store(true, Ordering::Release); self.maybe_interrupt(); @@ -251,7 +270,7 @@ impl CodexTurnStartCancellation { }; let connection = CodexAppServerConnection { inner }; let thread_id = self.thread_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { let _ = connection .request( "turn/interrupt", @@ -576,8 +595,21 @@ enum CodexTurnEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), + /// 一个 assistant 文本段的当前累计全文。 + /// + /// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**, + /// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。 + AgentMessageSegment { + item_id: String, + accumulated_text: String, + completed: bool, + }, IntermediateText(String), + /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 + Reasoning(String), Activity(&'static str), + /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 + ToolCall(crate::DirectToolCall), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -862,6 +894,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String { /// (with the concrete command/tool/path) while tools run; it does not push /// plan/reasoning text deltas. Showing what the agent is actually doing is /// the only reliable way to make the execution phase feel alive. +/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。 +/// +/// Codex 的 reasoning item 形如 +/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`, +/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。 +fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option { + if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") { + return None; + } + let collect = |key: &str| -> Option { + let parts = item + .get(key)? + .as_array()? + .iter() + .filter_map(|entry| { + entry + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + }; + collect("summary").or_else(|| collect("content")) +} + fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { const MAX_ITEM_TEXT_CHARS: usize = 240; let item_type = item @@ -1968,7 +2031,13 @@ impl CodexAppServerConnection { let codex_cli_version = game_creator_codex_cli_version_identity() .map_err(platform_llm::LlmError::InvalidConfig)?; let mut effective_llm = llm.clone(); - let credential = if game_creator_official_llm_route_locked() { + let credential = if llm.custom_enabled { + crate::config::validate_custom_llm_connection(llm) + .map_err(platform_llm::LlmError::InvalidConfig)?; + CodexAppServerCredential::AppDataKey { + fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + } + } else if game_creator_official_llm_route_locked() { let session = current_platform_session().ok_or_else(|| { platform_llm::LlmError::InvalidConfig( "authentication-required: 请先登录陶泥儿账号".to_string(), @@ -2136,7 +2205,8 @@ impl CodexAppServerConnection { true, ), _ => ( - (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + (llm.custom_enabled + || workspace_mode == CodexAppServerWorkspaceMode::DirectProject) .then(|| credential.direct_provider_route(llm)) .flatten() .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), @@ -2734,6 +2804,12 @@ impl CodexAppServerConnection { let _turn_guard = self.inner.turn_gate.lock().await; let mut request = request; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), + // 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。 + let direct_tool_call_turn_id: Option = direct_client_turn_id + .map(str::trim) + .filter(|turn_id| !turn_id.is_empty()) + .map(str::to_string); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2821,6 +2897,17 @@ impl CodexAppServerConnection { } let turn_start_cancellation = Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); + // Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。 + let _active_turn_guard = direct_tool_call_turn_id + .as_deref() + .filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .map(|turn_id| { + register_active_direct_codex_turn( + direct_codex_active_turn_key(history_root), + turn_id, + Arc::clone(&turn_start_cancellation), + ) + }); let mut turn_start_guard = CodexTurnStartGuard { cancellation: Arc::clone(&turn_start_cancellation), armed: true, @@ -2938,6 +3025,18 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::AccumulatedText( streamed_text.clone(), )); + // 同一个 assistant item 的当前累计全文:回合流按 item 分段, + // 段内只追加、段间才换行,不能拿"整轮累计"当一段。 + let segment_text = direct_project_history + .accumulated_text_for(&item_id) + .unwrap_or_else(|| delta.clone()); + if !segment_text.trim().is_empty() { + observer(DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.clone(), + accumulated_text: segment_text, + completed: false, + }); + } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { callback(&platform_llm::LlmStreamDelta { @@ -3027,6 +3126,10 @@ impl CodexAppServerConnection { // 让执行期间聊天窗口显示“正在做什么”,而不是只 // 有活动状态来回跳动。completed 事件不再重复。 if !completed { + if let Some(reasoning) = direct_codex_item_reasoning_text(item) + { + observer(DirectCodexTurnObservation::Reasoning(reasoning)); + } if let Some(text) = direct_codex_item_intermediate_text(item) { observer(DirectCodexTurnObservation::IntermediateText( text, @@ -3042,6 +3145,24 @@ impl CodexAppServerConnection { completed, ¶ms, ); + // 工具调用卡片:item/started 与 item/completed 各采一次, + // 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id / + // 非工具类 item)就静默跳过,不影响这一轮的其它投影。 + if let Some(turn_id) = direct_tool_call_turn_id.as_deref() { + if let Some(tool_call) = direct_tool_call_from_item( + history_root, + item, + turn_id, + completed, + direct_tool_call_now_ms(), + ) { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::ToolCall( + tool_call, + )); + } + } + } if completed { if let Some(audit) = audit.as_mut() { audit.observe_item(¶ms); @@ -3049,6 +3170,27 @@ impl CodexAppServerConnection { } } if item_type == "agentMessage" { + // 某些 app-server 实现会在工具开始后停止发送 agentMessage delta, + // 但会在 item/completed 携带完整文本。把这份最终快照补进回合流, + // 让流中的文本段不会停在工具前的短前缀。 + if completed { + if let (Some(item_id), Some(text)) = ( + item.get("id").and_then(serde_json::Value::as_str), + item.get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()), + ) { + if let Some(observer) = direct_observer.as_deref_mut() { + observer( + DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.to_string(), + accumulated_text: text.to_string(), + completed: true, + }, + ); + } + } + } if let Some(text) = item .get("text") .and_then(serde_json::Value::as_str) @@ -3086,17 +3228,32 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::Terminal(params)) => { let turn = params.get("turn").unwrap_or(¶ms); - if final_text.is_none() { - final_text = turn - .get("items") - .and_then(serde_json::Value::as_array) - .and_then(|items| { - items.iter().rev().find_map(|item| { - (item.get("type")?.as_str()? == "agentMessage") - .then(|| item.get("text")?.as_str().map(str::to_string)) - .flatten() - }) - }); + if let Some(items) = turn.get("items").and_then(serde_json::Value::as_array) + { + for item in items { + if item.get("type").and_then(serde_json::Value::as_str) + != Some("agentMessage") + { + continue; + } + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|text| !text.trim().is_empty()) + { + final_text = Some(text.to_string()); + if let (Some(item_id), Some(observer)) = ( + item.get("id").and_then(serde_json::Value::as_str), + direct_observer.as_deref_mut(), + ) { + observer(DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.to_string(), + accumulated_text: text.to_string(), + completed: true, + }); + } + } + } } let status = turn .get("status") @@ -3195,6 +3352,223 @@ impl Drop for CodexTurnStartGuard { } } +/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。 +struct DirectCodexActiveTurnTable { + entries: HashMap, +} + +impl DirectCodexActiveTurnTable { + fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) { + self.entries + .insert(key, (client_turn_id.to_string(), value)); + } + + /// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。 + fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) { + if self + .entries + .get(key) + .is_some_and(|(_, value)| is_same(value)) + { + self.entries.remove(key); + } + } + + /// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回 + /// 可读原因,绝不误伤另一个回合。 + fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> { + let active = self + .entries + .get(key) + .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; + if let Some(expected) = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if active.0 != expected { + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); + } + } + Ok(active) + } + + /// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。 + fn registered_client_turn_id(&self, key: &Path) -> Option<&str> { + self.entries + .get(key) + .map(|(client_turn_id, _)| client_turn_id.as_str()) + } +} + +/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。 +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止"; + +/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。 +/// +/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 +/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由 +/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 +static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); + +fn direct_codex_active_turns( +) -> &'static std::sync::Mutex>> { + GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS + .get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new())) +} + +/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀), +/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。 +fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf { + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + match canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + { + Some(stripped) => std::path::PathBuf::from(stripped), + None => canonical, + } +} + +struct DirectCodexActiveTurnGuard { + key: std::path::PathBuf, + cancellation: Arc, +} + +impl Drop for DirectCodexActiveTurnGuard { + fn drop(&mut self) { + let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else { + return; + }; + let Ok(mut entries) = active_turns.lock() else { + return; + }; + let cancellation = Arc::clone(&self.cancellation); + entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation)); + } +} + +/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。 +fn register_active_direct_codex_turn( + key: std::path::PathBuf, + client_turn_id: &str, + cancellation: Arc, +) -> DirectCodexActiveTurnGuard { + if let Ok(mut entries) = direct_codex_active_turns().lock() { + entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation)); + } + DirectCodexActiveTurnGuard { key, cancellation } +} + +/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; +/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; + +/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectTurnCancelView { + /// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或 + /// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。 + pub(crate) outcome: String, + /// 给用户看的可读结果。 + pub(crate) message: String, + /// 被终止 / 被释放的 clientTurnId。 + pub(crate) client_turn_id: String, +} + +/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +enum DirectCodexTurnCancelTarget { + /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 + Interrupt(Arc), + /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 + Stale(DirectTaonierStaleGuardReason), +} + +/// 终止当前项目正在运行的 Direct 回合。 +/// +/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回 +/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回), +/// 不动任何既有事件或命令语义。 +/// +/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 +/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 +/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 +/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 +/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 +/// 保持原拒绝语义,什么都不释放。 +pub(crate) fn cancel_direct_codex_turn_at( + root: &Path, + client_turn_id: Option<&str>, +) -> Result { + let key = direct_codex_active_turn_key(root); + let expected = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()); + { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + if let (Some(registered), Some(expected)) = + (entries.registered_client_turn_id(&key), expected) + { + if registered != expected { + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); + } + } + } + let target = { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + match entries.select(&key, client_turn_id) { + Ok((_, cancellation)) if cancellation.app_server_alive() => { + DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) + } + Ok(_) => { + DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + } + Err(_) => DirectCodexTurnCancelTarget::Stale( + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ), + } + }; + match target { + DirectCodexTurnCancelTarget::Interrupt(cancellation) => { + cancellation.cancel(); + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), + message: "已向正在运行的回合发出终止".to_string(), + client_turn_id: client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(), + }) + } + DirectCodexTurnCancelTarget::Stale(reason) => { + let released = + release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), + message: format!( + "{},已释放这一轮的占用,可以直接重新发送消息", + reason.message() + ), + client_turn_id: released, + }) + } + } +} + struct CodexThreadLease { connection: CodexAppServerConnection, key: CodexNodeThreadKey, @@ -3206,7 +3580,7 @@ impl Drop for CodexThreadLease { let connection = self.connection.clone(); let key = self.key.clone(); let thread_id = self.thread_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { let mut threads = connection.inner.threads.lock().await; if let Some(entry) = threads.get_mut(&key) { if entry.thread_id == thread_id { @@ -3233,7 +3607,7 @@ impl Drop for CodexTurnGuard { let connection = self.connection.clone(); let thread_id = self.thread_id.clone(); let turn_id = self.turn_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { connection.inner.turns.lock().await.remove(&turn_id); connection.inner.turn_backlog.lock().await.remove(&turn_id); let _ = connection @@ -4100,6 +4474,65 @@ pub(crate) fn build_direct_codex_history_prompt( mod tests { use super::*; + /// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要 + /// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。 + #[test] + fn direct_codex_active_turn_table_selects_only_the_running_turn() { + let mut table: DirectCodexActiveTurnTable = DirectCodexActiveTurnTable::new(); + let key = std::path::PathBuf::from("C:/projects/direct-turn-demo"); + assert_eq!( + table.select(&key, None).expect_err("no active turn"), + "当前项目没有正在运行的陶泥儿回合,无法终止" + ); + + table.register(key.clone(), "turn-a", 1); + assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a"); + assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1); + assert_eq!( + table + .select(&key, Some("turn-b")) + .expect_err("another running turn"), + "正在运行的是另一个陶泥儿回合,已拒绝终止" + ); + + // 句柄已被后来的回合替换:旧回合收尾不得注销新回合。 + table.register(key.clone(), "turn-b", 2); + table.unregister(&key, |value| *value == 1); + assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b"); + table.unregister(&key, |value| *value == 2); + assert!(table.select(&key, None).is_err()); + } + + /// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio + /// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个 + /// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime + /// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。 + #[test] + fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() { + let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {})); + assert!( + joined.join().is_ok(), + "没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务" + ); + } + + /// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上 + /// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。 + #[test] + fn direct_codex_active_turn_key_normalizes_windows_prefix() { + let root = tempfile::tempdir().expect("temp dir"); + let canonical = std::fs::canonicalize(root.path()).expect("canonical root"); + let expected = canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + .map(std::path::PathBuf::from) + .unwrap_or(canonical); + let key = direct_codex_active_turn_key(root.path()); + assert_eq!(key, expected); + // 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。 + assert!(!key.to_string_lossy().starts_with("\\\\?\\")); + } + #[test] fn direct_thread_item_projection_drops_full_app_server_payload() { let item = serde_json::json!({ @@ -4343,6 +4776,8 @@ mod tests { fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), @@ -5128,6 +5563,59 @@ mod tests { assert_ne!(command_token, provider_key); } + #[tokio::test] + async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() { + let mut llm = test_llm(); + llm.custom_enabled = true; + llm.api_key = "custom-upstream-fixture-secret".into(); + llm.base_url = "http://127.0.0.1:9/v1".into(); + llm.model = "vendor/model.v1:latest".into(); + llm.visible_models = vec![llm.model.clone()]; + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "custom-fixture".into(), + }; + let (base, key) = credential + .direct_provider_route(&llm) + .expect("custom route"); + assert_eq!(base, llm.base_url); + assert_eq!(key, llm.api_key); + let proxy = start_codex_provider_proxy(base, key, false).await.unwrap(); + for mode in [ + CodexAppServerWorkspaceMode::DirectProject, + CodexAppServerWorkspaceMode::ToolHost, + ] { + let mut command = tokio::process::Command::new("fixture"); + configure_game_creator_codex_app_server_command_for_mode( + &mut command, + &llm, + mode, + Some(&proxy), + None, + true, + ) + .unwrap(); + let arguments = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join("\n"); + let params = codex_app_server_thread_start_params( + &llm.model, + std::path::Path::new("fixture-workspace"), + mode, + String::new(), + true, + ); + assert_eq!(params["model"], "vendor/model.v1:latest"); + assert!(!arguments.contains(&llm.api_key)); + assert!(!arguments.contains("/api/llm")); + for (_, value) in command.as_std().get_envs() { + assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key))); + } + } + } + #[cfg(unix)] #[tokio::test] async fn direct_project_spawn_restores_broker_token_after_environment_isolation() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index e99e25899..ced1b7273 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -527,10 +527,6 @@ fn process_design_batch( let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "design.tool", - )?; execute_design_tool(root, resources, session, &call) }; let error = result @@ -1021,6 +1017,15 @@ pub(crate) async fn continue_design_agent_at( finish_design_command(root, resources, session, active, run, emit).await } +async fn recover_uncertain_design_batch( + root: &Path, + resources: &DesignResources, + session: DesignSession, + active: File, +) -> Result { + finish_design_command(root, resources, session, active, true, |_| {}).await +} + pub(crate) async fn decide_design_phase_at( root: &Path, resources: &DesignResources, @@ -1053,7 +1058,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { } #[tauri::command] -pub(crate) fn hydrate_design_agent_session( +pub(crate) async fn hydrate_design_agent_session( + app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); @@ -1079,8 +1085,33 @@ pub(crate) fn hydrate_design_agent_session( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; - Ok(Some(design_view(&session, active.is_none()))) + let Some(active) = + try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + else { + return Ok(Some(design_view(&session, true))); + }; + if design_session_has_uncertain_batch(&session) { + let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let view = recover_uncertain_design_batch(root, &resources, session, active).await?; + return Ok(Some(view)); + } + drop(active); + Ok(Some(design_view(&session, false))) +} + +fn design_session_has_uncertain_batch(session: &DesignSession) -> bool { + let Some(batch) = session.pending_batch.as_ref() else { + return false; + }; + if !batch.executing || batch.cursor >= batch.calls.len() { + return false; + } + let call_id = batch.calls[batch.cursor].id.as_str(); + session.turn.as_ref().is_some_and(|turn| turn.pending) + && !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) } fn design_session_error_is_recoverable(error: &str) -> bool { @@ -1953,4 +1984,94 @@ mod tests { .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } + + #[tokio::test(flavor = "current_thread")] + async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { + let (_temp, root, resources) = init_design_project(); + execute_design_file_tool( + &root, + "write_file", + &json!({"path":"project/00_concept/design.md","content":"概念"}), + ) + .expect("write concept"); + let mut session = new_design_session("design-fake", "quality"); + let call = platform_llm::LlmToolCall { + id: "interrupted-call".into(), + name: "patch_file".into(), + arguments: json!({ + "path":"project/00_concept/design.md", + "old_text":"概念", + "new_text":"概念设计" + }) + .to_string(), + }; + session.history.push(json!({ + "type":"function_call", + "call_id":call.id, + "name":call.name, + "arguments":call.arguments, + })); + session.messages = vec![DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "继续".into(), + }]; + session.turn = Some(DesignTurn { + id: "turn-recovery".into(), + pending: true, + request_index: 0, + attempt: 0, + }); + session.pending_batch = Some(DesignToolBatch { + calls: vec![call], + cursor: 0, + executing: true, + }); + assert!(design_session_has_uncertain_batch(&session)); + write_design_session(&root, &session).expect("write interrupted session"); + + let _fake = fake_provider::install( + vec![Ok(fake_response( + "recovered-after-uncertain-tool", + "已读取文件并确认。", + Vec::new(), + ))], + 0, + ); + let view = recover_uncertain_design_batch(&root, &resources, session, { + try_open_game_creator_agent_runtime_task_lock_file( + &root, + ".agent/design-agent/active.lock", + ) + .expect("open active lock") + .expect("active lock is free") + }) + .await + .expect("recover uncertain batch"); + + assert!(!view.running); + assert!(view.session.last_error.is_none()); + let restored = read_design_session(&root) + .expect("read restored") + .expect("session"); + assert!(restored.pending_batch.is_none()); + assert!(!restored.turn.expect("turn").pending); + assert!(restored.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("interrupted-call") + && item + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("执行结果未保存")) + })); + assert!(restored.history.iter().any(|item| { + item.get("role").and_then(Value::as_str) == Some("assistant") + && item.get("content").is_some() + })); + assert!( + fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md")) + .expect("read target") + == "概念" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5221030b1..730d68004 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool( }) .collect::>(); let mut matches = Vec::new(); + let mut edit_errors = Vec::new(); + let mut valid_edits = 0; for (index, (old, new)) in normalized.iter().enumerate() { + if old == new { + edit_errors.push(format!( + "edits[{index}] new_text 与 old_text 相同,不会产生修改" + )); + continue; + } let count = content.matches(old).count(); if count == 0 { - return Err(format!("edits[{index}] 原文未找到:{display}")); + edit_errors.push(format!( + "edits[{index}] 原文未找到:{}{}", + display, + design_patch_location_hint(&content, old) + )); + continue; } if count != 1 { - return Err(format!( - "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + let start = content.find(old).expect("count checked"); + let line = design_patch_line_number(&content, start); + edit_errors.push(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行" )); + continue; } let start = content.find(old).expect("count checked"); let end = start + old.len(); @@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool( .iter() .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) { - return Err(format!( - "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + edit_errors.push(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" )); + continue; } matches.push((index, start, end)); + valid_edits += 1; let _ = new; } + if !edit_errors.is_empty() { + let shown = edit_errors.len().min(4); + let mut details = edit_errors[..shown].to_vec(); + if shown < edit_errors.len() { + details.push(format!( + "另有 {} 个 edit 校验失败(详情省略)", + edit_errors.len() - shown + )); + } + if valid_edits > 0 { + details.push(format!( + "其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件" + )); + } else { + details.push("本次未写入文件".to_string()); + } + return Err(details.join("\n")); + } let mut updated = content.clone(); for (index, start, end) in matches.into_iter().rev() { let (_, new) = &normalized[index]; @@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool( } } +fn design_patch_line_number(content: &str, start: usize) -> usize { + 1 + content[..start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() +} + +fn design_patch_visible_line(line: &str) -> String { + line.replace('\t', "\\t").chars().take(180).collect() +} + +fn design_patch_location_hint(content: &str, old: &str) -> String { + let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else { + return String::new(); + }; + + let mut candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == anchor) + .map(|(index, line)| (index + 1, line)) + .collect::>(); + if candidates.is_empty() { + let token = anchor.split_whitespace().find(|token| token.len() >= 3); + if let Some(token) = token { + candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim().contains(token)) + .map(|(index, line)| (index + 1, line)) + .collect(); + } + } + if candidates.is_empty() { + return format!( + ";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)", + content.lines().count() + ); + } + + let details = candidates + .iter() + .take(2) + .map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text))) + .collect::>() + .join(";"); + let suffix = if candidates.len() > 2 { + format!("等 {} 处", candidates.len()) + } else { + String::new() + }; + format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)") +} + pub(crate) fn list_design_workspace_files( root: &Path, ) -> Result, String> { @@ -693,6 +783,22 @@ mod tests { ) .expect_err("escape"); assert!(escaped.contains("路径")); + let mismatch = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":" 游戏设计","new_text":"游戏概念"}, + {"old_text":"设计","new_text":"方案"} + ] + }), + ) + .expect_err("report all patch failures"); + assert!(mismatch.contains("edits[0] 原文未找到")); + assert!(mismatch.contains("第 1 行:游戏设计")); + assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配")); + assert!(mismatch.contains("本次未写入文件")); let patched = execute_design_file_tool( root, "patch_file", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 24b3aed74..130c01549 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -5,6 +5,7 @@ use crate::project::{ }; use crate::{LocalConversationMessageRecord, LocalConversationResult}; use serde_json::Value; +use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -91,6 +92,10 @@ fn record(item: &Value) -> Result { serde_json::to_string(&serde_json::json!({ "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, "payload": item, + "recordedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0), })) .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) } @@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st } pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + Ok(read_direct_project_history_entries_at(root)? + .into_iter() + .map(|(item, _)| item) + .collect()) +} + +fn read_direct_project_history_entries_at(root: &Path) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { return Ok(Vec::new()); @@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, limit: usize, -) -> Result<(Vec, bool), String> { - let items = read_direct_project_history_items_at(root)?; +) -> Result<(Vec, bool, BTreeMap), String> { + let items = read_direct_project_history_entries_at(root)?; let end = match before_item_id { Some(item_id) => items .iter() - .position(|item| item.get("id").and_then(Value::as_str) == Some(item_id)) + .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, None => items.len(), }; let bounded_limit = limit.clamp(1, 200); let start = end.saturating_sub(bounded_limit); - Ok((items[start..end].to_vec(), start > 0)) + let slice = &items[start..end]; + let timestamps = slice + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + slice.iter().map(|(item, _)| item.clone()).collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { @@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at( root: &Path, ) -> Result { let path = history_path(root); - let items = read_direct_project_history_items_at(root)?; + let items = read_direct_project_history_entries_at(root)?; let messages = items .into_iter() - .filter_map(|item| { + .filter_map(|(item, recorded_at)| { let role = item.get("role").and_then(Value::as_str)?; if !matches!(role, "user" | "assistant") { return None; @@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at( content, agent_id: None, message_id: item.get("id").and_then(Value::as_str).map(str::to_string), - updated_at: 0, + updated_at: recorded_at, }) }) .collect(); @@ -610,6 +640,40 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + #[test] + fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { + let root = init_history_project("history-time"); + let item = json!({ + "type": "message", "role": "user", "id": "sent-message", + "content": [{"type": "input_text", "text": "修改游戏"}], + }); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (items, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(items, vec![item.clone()]); + assert!(timestamps["sent-message"] > 0); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (_, _, reloaded) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(timestamps, reloaded); + } + + #[test] + fn old_history_without_envelope_time_stays_unknown() { + let root = init_history_project("history-unknown-time"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + let (_, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(timestamps.is_empty()); + assert_eq!( + read_direct_project_chat_history_at(root.path()) + .unwrap() + .messages[0] + .updated_at, + 0 + ); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c2c317692..c41b19de0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -22,6 +22,14 @@ impl DirectProjectHistoryAccumulator { } } + /// 某个 assistant item 目前累计到的全文。 + /// + /// 回合流按 item 分段:同一个 item 的后续 delta 是同一段的增长,item 变了才是新的一段。 + /// 没有这条 item(非 DirectProject 工作区、或已经 complete)时返回 `None`。 + pub(crate) fn accumulated_text_for(&self, item_id: &str) -> Option { + self.text_by_item_id.get(item_id).cloned() + } + fn take_partial_items(&mut self) -> impl Iterator + '_ { std::mem::take(&mut self.text_by_item_id) .into_iter() 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 01a63a6db..619fd4162 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 @@ -1,5 +1,6 @@ use super::*; use base64::Engine as _; +use std::collections::BTreeMap; use std::collections::HashMap; use std::future::Future; use std::io::Write; @@ -14,9 +15,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; +const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -122,6 +124,204 @@ fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { .any(|marker| prompt.contains(marker)) } +/// 三维/引擎意图的确定性识别。 +/// +/// 三维请求不再绑定 Phaser,也不要求先澄清引擎:识别结果只用来给本回合注入 +/// "自选三维技术栈"的执行合同。识别只读用户原文,既不改写用户消息,也不触发生成。 +const DIRECT_ENGINE_3D_MARKERS: [&str; 2] = ["3d", "三维"]; + +/// 明确要求平面化的说法:这些词被移除后才判断三维意图,避免把用户主动选择的 +/// "伪 3D / 等轴 / 2.5D" 表现误判成三维选型请求。 +const DIRECT_ENGINE_FLAT_MARKERS: [&str; 5] = ["伪3d", "伪 3d", "pseudo-3d", "2.5d", "等轴"]; + +/// 三维需求必须落在游戏创作语义里,避免把 "三维数组" 之类的代码话题当成建游戏。 +const DIRECT_ENGINE_3D_CONTEXT_MARKERS: [&str; 24] = [ + "游戏", "玩法", "关卡", "角色", "场景", "画面", "引擎", "视角", "建模", "模型", "世界", "地图", + "城市", "射击", "冒险", "模拟", "经营", "塔防", "game", "level", "scene", "world", "model", + "fps", +]; + +const DIRECT_ENGINE_NAME_MARKERS: [(&str, DirectNamedEngine); 12] = [ + ("cocos", DirectNamedEngine::Cocos), + ("unity", DirectNamedEngine::Unity), + ("unreal", DirectNamedEngine::Unreal), + ("ue4", DirectNamedEngine::Unreal), + ("ue5", DirectNamedEngine::Unreal), + ("godot", DirectNamedEngine::Godot), + ("three.js", DirectNamedEngine::ThreeJs), + ("threejs", DirectNamedEngine::ThreeJs), + ("three js", DirectNamedEngine::ThreeJs), + ("babylon", DirectNamedEngine::Babylon), + ("phaser", DirectNamedEngine::Phaser), + ("虚幻", DirectNamedEngine::Unreal), +]; + +/// 用户消息里表达的目标引擎:点名引擎时不再由客户端裁定用法,只用于区分"用户已经 +/// 选了栈"和"只说三维、由 Codex 自己选栈"。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectEngineIntent { + /// 只说了三维,没有点名引擎。 + ThreeDimensional, + /// 点名了具体引擎或引擎家族。 + Named(DirectNamedEngine), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectNamedEngine { + Phaser, + ThreeJs, + Babylon, + Cocos, + Unity, + Godot, + Unreal, +} + +/// 当前项目根的引擎归属。只读工程结构标记,不读取用户数据。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectProjectEngine { + CocosCreator, + Unity, + Godot, + Unreal, + WebGame, + Unknown, +} + +impl DirectProjectEngine { + fn label(self) -> &'static str { + match self { + Self::CocosCreator => "Cocos Creator", + Self::Unity => "Unity", + Self::Godot => "Godot", + Self::Unreal => "Unreal", + Self::WebGame => "Web(Phaser/Vite)工程", + Self::Unknown => "未识别引擎的工程", + } + } +} + +fn direct_normalize_prompt_text(prompt: &str) -> String { + prompt + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// 标记必须独立成词,避免把 "value4" 里的 `ue4` 之类片段当成引擎名。 +fn direct_prompt_contains_marker(prompt: &str, marker: &str) -> bool { + let boundary = |character: Option| { + character.is_none_or(|character| !character.is_ascii_alphanumeric()) + }; + prompt.match_indices(marker).any(|(index, _)| { + boundary(prompt[..index].chars().next_back()) + && boundary(prompt[index + marker.len()..].chars().next()) + }) +} + +/// 用户原文里的三维/引擎意图。点名的引擎优先,避免 "用 Unity 做" 这类没有 3D +/// 字样的请求漏判。 +pub(crate) fn direct_engine_intent_from_prompt(prompt: &str) -> Option { + let normalized = direct_normalize_prompt_text(prompt); + if let Some((_, engine)) = DIRECT_ENGINE_NAME_MARKERS + .iter() + .find(|(marker, _)| direct_prompt_contains_marker(&normalized, marker)) + { + return Some(DirectEngineIntent::Named(*engine)); + } + let mut remaining = normalized.clone(); + for marker in DIRECT_ENGINE_FLAT_MARKERS { + remaining = remaining.replace(marker, " "); + } + if !DIRECT_ENGINE_3D_MARKERS + .iter() + .any(|marker| direct_prompt_contains_marker(&remaining, marker)) + { + return None; + } + let has_game_context = DIRECT_ENGINE_3D_CONTEXT_MARKERS + .iter() + .any(|marker| remaining.contains(marker)); + if !has_game_context { + return None; + } + Some(DirectEngineIntent::ThreeDimensional) +} + +fn direct_project_has_unreal_project_file(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .path() + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("uproject")) + }) +} + +/// 当前项目根的引擎归属。判定失败按 Unknown 处理,不因为探测错误阻断回合。 +pub(crate) fn direct_project_engine(root: &Path) -> DirectProjectEngine { + if crate::project::discover_local_godot_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::Godot; + } + if root.join("ProjectSettings/ProjectVersion.txt").is_file() { + return DirectProjectEngine::Unity; + } + if direct_project_has_unreal_project_file(root) { + return DirectProjectEngine::Unreal; + } + if crate::project::discover_local_cocos_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::CocosCreator; + } + if root.join("game/index.html").is_file() || root.join("index.html").is_file() { + return DirectProjectEngine::WebGame; + } + DirectProjectEngine::Unknown +} + +/// 三维请求的执行合同。 +/// +/// 用户要做三维游戏时,"新 Web 游戏固定 Phaser 4.2.1" 的约束让位:由 Codex 自己 +/// 选三维技术栈(Three.js / Babylon.js 等 npm 运行时,或当前工程自带的引擎)。客户 +/// 端不要求先澄清引擎、不阻断工具、不拒绝登记产出;唯一保留的红线是不能用等轴伪 3D +/// 冒充三维交付而不说明。识别只读用户原文,不改写用户消息。 +pub(crate) fn direct_engine_three_dimensional_contract( + root: &Path, + prompt: &str, +) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => { + let project = direct_project_engine(root); + Some(format!( + "三维请求执行合同(本回合):用户要求做三维(3D)游戏,本回合不受“新 Web 游戏固定 Phaser 4.2.1”的约束。由你自行选择合适的三维技术栈——例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎(当前工程识别:{})——可以按需新增 npm 依赖,沿用现有 npm + Vite 与 game/ 目录约定,也可以按需调整工程结构;不必先向用户确认引擎,直接按你的判断推进并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;如果评估后只能用二维表现,就在回复里说明限制与原因。其它边界不变:只改当前工程、构建通过后再试玩、不伪造成功、不读取或输出凭据。", + project.label() + )) + } + } +} + +/// 首页回合的三维提示:允许按既有规则创建项目,但提醒默认模板不是三维引擎。 +fn direct_engine_three_dimensional_home_note(prompt: &str) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => Some( + "三维请求说明(首页):用户要做三维游戏。可以按既有规则创建项目;创建后由你自行选择三维技术栈(例如 Three.js / Babylon.js),不要因为默认模板是二维 Phaser 就只做等轴伪 3D。".to_string(), + ), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { project_id: String, @@ -292,6 +492,16 @@ static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< Mutex>, > = OnceLock::new(); +/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 +/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 +/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 +/// 不可能再进入执行器,守卫是残留。 +const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; + +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 +} + #[derive(Debug)] pub(crate) struct DirectTaonierActiveInvocationGuard { root: PathBuf, @@ -314,8 +524,9 @@ impl DirectTaonierActiveInvocationGuard { "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" ) } else { - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份" - .to_string() + format!( + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) }); } None => { @@ -432,6 +643,106 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())? + .get(&root) + .map(|active| DirectActiveTurnView { + client_turn_id: active.invocation_id.clone(), + started_at: active.started_at, + })) +} + +/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectTaonierStaleGuardReason { + /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 + ExecutorExited, + /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 + NeverReachedExecutor, +} + +impl DirectTaonierStaleGuardReason { + pub(crate) fn message(self) -> &'static str { + match self { + Self::ExecutorExited => "陶泥儿执行进程已退出", + Self::NeverReachedExecutor => "这一轮 Direct 回合没有进入执行器", + } + } +} + +/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// +/// 释放条件(四条必须同时成立,这段注释就是契约): +/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; +/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; +/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 +/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" +/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// +/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; +/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 +pub(crate) fn release_stale_direct_taonier_active_invocation( + root: &Path, + expected_client_turn_id: Option<&str>, + reason: DirectTaonierStaleGuardReason, +) -> Result { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; + let Some(existing) = active.get(&root) else { + return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); + }; + if let Some(expected) = expected_client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if existing.invocation_id != expected { + return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + } + } + if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { + let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); + if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { + return Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )); + } + } + let released = existing.invocation_id.clone(); + active.remove(&root); + Ok(released) +} + fn direct_taonier_regeneration_project_id(root: &Path) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -713,7 +1024,9 @@ fn prepare_direct_taonier_regeneration_workflow_at( "direct-codex.taonier-package-workflow-prepare", )?; match read_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - format!("{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}") + format!( + "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}" + ) })? { Some(existing) => match existing.state { DirectTaonierRegenerationWorkflowState::Resetting => { @@ -789,9 +1102,7 @@ fn prepare_direct_taonier_regeneration_workflow_at( "{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 整包重生成补偿状态缺少 durable rollback journal" ) })?; - DirectTaonierRegenerationWorkflowPreparation::Compensate { - rollback, - } + DirectTaonierRegenerationWorkflowPreparation::Compensate { rollback } } DirectTaonierRegenerationWorkflowState::Completed => { if existing.invocation_sha256 == invocation_sha256 { @@ -1941,6 +2252,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] = &[ @@ -2477,6 +2866,15 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { .generation_route .as_deref() .is_some_and(|route| route.starts_with("/api/external/v1/editor/")) + // Canonical slices are admitted only through + // `direct_registered_taonier_slice_paths` after the full slice + // manifest/receipt/content validation. This generic fallback + // must not re-admit a slice whose bytes no longer match the + // registered receipt. + && !(asset.kind == "art-spritesheet-slice" + && asset + .local_path + .starts_with("assets/art-spritesheet-slices/")) }) .filter_map(|asset| { let path = direct_normalized_project_asset_path(&asset.local_path)?; @@ -2854,14 +3252,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( )?; let _platform_session_lease = access .frozen_platform_session() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; // The network phase deliberately runs without the project write lock. Capture rollback state // only after acquiring the lock and revalidating the source identity, otherwise a failure can @@ -3325,8 +3716,8 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( Some(rollback), format!( "{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 无法锚定本轮新背景图,已停止整包重生成:{error}" - ), - )); + ), + )); } if let Some(workflow) = regeneration_workflow.as_mut() { if let Err(error) = @@ -4065,7 +4456,9 @@ fn sync_direct_codex_project_outputs_at( /// Project Codex text for the user-visible DirectProject stream and reply. /// Reasoning wrappers are still removed because they are not reply text, but /// the user owns the project and the resulting reply is not redacted here. -fn project_direct_codex_visible_text(value: &str) -> Option { +/// +/// `pub(crate)`:回合流(`direct_turn_stream`)落最终回复前复用同一套可见性投影。 +pub(crate) fn project_direct_codex_visible_text(value: &str) -> Option { let stripped = strip_incomplete_direct_thinking_marker(&strip_llm_thinking_blocks(value)); if stripped.trim().is_empty() { return None; @@ -4149,9 +4542,10 @@ fn build_direct_codex_system_prompt_with_search( "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), + DIRECT_ENGINE_FREEDOM_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 { @@ -4246,7 +4640,14 @@ pub(crate) async fn run_direct_game_creator_home_turn( attachments: &[DirectCodexTurnAttachment], ) -> Result { let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; - direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) + // 首页也只有这一轮对话:三维请求直接放行创建,但要提醒默认模板不是三维引擎。 + let engine_note = direct_engine_three_dimensional_home_note(prompt); + let base_system_prompt = build_direct_codex_home_system_prompt(); + let system_prompt = match engine_note.as_deref() { + Some(note) => format!("{note}\n{base_system_prompt}"), + None => base_system_prompt, + }; + direct_game_creator_home_codex_chat(system_prompt, user_prompt) .await .map(parse_direct_codex_home_reply) .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) @@ -4301,7 +4702,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { - emitter.emit("accepted", Some("request-accepted"), None); + emitter.emit("accepted", Some("request-accepted"), None, None); } match run_direct_game_creator_turn_inner( root, @@ -4327,13 +4728,182 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); } if let Some(emitter) = turn_emitter { - emitter.emit("failed", Some("none"), None); + // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, + // 这样"流里已经是完整内容"这一点对失败回合同样成立。 + let failure_item = append_direct_turn_stream_text_at( + root, + emitter.turn_id(), + DIRECT_TURN_STREAM_FAILURE_ITEM_ID, + &error, + ) + .ok() + .flatten() + .into_iter() + .collect::>(); + emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); } Err(error) } } } +/// 本回合累积的工具调用条目(观察者写、回合末读)。 +type DirectToolCallCollector = std::sync::Arc>>; + +fn lock_direct_tool_call_collector( + collector: &DirectToolCallCollector, +) -> std::sync::MutexGuard<'_, Vec> { + collector + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 单条工具调用落盘:走阻塞线程池(写文件要拿项目锁,不能在 async 运行时上直接跑)。 +/// 失败只返回错误交给调用方忽略,不打断回合。 +fn spawn_persist_direct_tool_call(root: &Path, call: &DirectToolCall) { + let root = root.to_path_buf(); + let call = call.clone(); + tauri::async_runtime::spawn_blocking(move || persist_direct_tool_call_at(&root, &call)); +} + +/// 回合结束整批落盘;失败时退回逐条 upsert,尽量把能写的写进去。 +fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCollector) { + let calls = { + let collected = lock_direct_tool_call_collector(collector); + collected.clone() + }; + if calls.is_empty() { + return; + } + if persist_direct_tool_calls_at(root, &calls).is_ok() { + return; + } + for call in &calls { + let _ = persist_direct_tool_call_at(root, call); + } +} + +/// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。 +fn spawn_persist_direct_turn_stream_item( + root: &Path, + item: &DirectTurnStreamItem, +) -> tauri::async_runtime::JoinHandle> { + let root = root.to_path_buf(); + let item = item.clone(); + tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item)) +} + +/// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。 +const DIRECT_TURN_STREAM_TEXT_THROTTLE_MS: u128 = 300; + +/// 正在增长的那一段文本。 +struct DirectTurnStreamPendingText { + /// 这一段对应的 Codex assistant item id(段身份)。 + item_id: String, + item: DirectTurnStreamItem, + last_flush: std::time::Instant, +} + +/// 回合流的写入与下发状态(观察者持有)。 +/// +/// `seq_by_id` 是**顺序真相的本体**:条目 id 第一次出现时分配序号,之后所有更新都带同一个 +/// 序号,所以并发落盘的先后不会改变渲染顺序(不会出现"新工具插到旧文本前面")。 +struct DirectTurnStreamWriter { + turn_id: String, + seq_by_id: BTreeMap, + next_seq: u64, + last_updated_at: u64, + pending_text: Option, +} + +impl DirectTurnStreamWriter { + fn new(turn_id: String) -> Self { + Self { + turn_id, + seq_by_id: BTreeMap::new(), + next_seq: 0, + last_updated_at: 0, + pending_text: None, + } + } + + /// 条目 id 对应的固定序号:首次出现时分配,之后永远不变。 + fn seq_for(&mut self, id: &str) -> u64 { + if let Some(seq) = self.seq_by_id.get(id) { + return *seq; + } + self.next_seq += 1; + self.seq_by_id.insert(id.to_string(), self.next_seq); + self.next_seq + } + + /// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照。 + fn push_text( + &mut self, + root: &Path, + item_id: &str, + visible_text: &str, + now_ms: u64, + completed: bool, + ) -> Vec { + let now = std::time::Instant::now(); + let mut snapshots = Vec::new(); + self.last_updated_at = now_ms.max(self.last_updated_at.saturating_add(1)); + if self + .pending_text + .as_ref() + .is_some_and(|pending| pending.item_id != item_id) + { + snapshots.extend(self.take_pending_snapshot()); + } + if let Some(pending) = self.pending_text.as_mut() { + pending.item.text = Some(sanitize_stream_text(root, visible_text)); + pending.item.updated_at = self.last_updated_at; + if completed + || now.duration_since(pending.last_flush).as_millis() + >= DIRECT_TURN_STREAM_TEXT_THROTTLE_MS + { + pending.last_flush = now; + snapshots.push(pending.item.clone()); + } + } else { + let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id)); + let item = direct_turn_stream_text_item( + root, + &self.turn_id, + item_id, + visible_text, + seq, + now_ms, + self.last_updated_at, + ); + snapshots.push(item.clone()); + self.pending_text = Some(DirectTurnStreamPendingText { + item_id: item_id.to_string(), + item, + last_flush: now, + }); + } + snapshots + } + + /// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。 + fn take_pending_snapshot(&mut self) -> Option { + self.pending_text.take().map(|pending| pending.item) + } + + /// 工具条目:只记位置,正文仍来自 `tool-calls.jsonl`。 + fn push_tool(&mut self, call: &DirectToolCall, now_ms: u64) -> DirectTurnStreamItem { + let seq = self.seq_for(&direct_turn_stream_tool_item_id(&self.turn_id, &call.id)); + let at = if call.started_at > 0 { + call.started_at + } else { + now_ms + }; + direct_turn_stream_tool_item(&self.turn_id, call, seq, at) + } +} + async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, @@ -4344,22 +4914,40 @@ async fn run_direct_game_creator_turn_inner( ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("preparing"), None); + emitter.emit("running", Some("preparing"), None, None); } + // 本回合累积的工具调用条目:观察者增量采集,回合结束时整批落盘(幂等 upsert)。 + // 实时下发与落盘共用同一份数据,避免两处各采集一次产生口径差。 + let tool_calls: DirectToolCallCollector = Arc::new(Mutex::new(Vec::new())); let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| { DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, + // 避免被长度上限截断,也不阻断任何工具。 + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = if let Some(emitter) = turn_emitter { let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); - let mut observer = move |observation: DirectCodexTurnObservation| { + let turn_root = root.to_path_buf(); + let turn_tool_calls = Arc::clone(&tool_calls); + // 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。 + let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone()); + let mut stream_writes = Vec::new(); + let mut observer = |observation: DirectCodexTurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { @@ -4368,7 +4956,31 @@ async fn run_direct_game_creator_turn_inner( if visible_text.is_none() { return; } - emitter.emit(status, None, visible_text); + emitter.emit(status, None, visible_text, None); + } + DirectCodexTurnObservation::AgentMessageSegment { + item_id, + accumulated_text, + completed, + } => { + // 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。 + let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text) + else { + return; + }; + let items = stream_writer.push_text( + &turn_root, + &item_id, + &visible_text, + direct_tool_call_now_ms(), + completed, + ); + for item in &items { + stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); + } + if !items.is_empty() { + emitter.emit_with_stream_items(status, None, None, None, items); + } } DirectCodexTurnObservation::IntermediateText(intermediate_text) => { let visible_text = if stream_enabled @@ -4379,37 +4991,156 @@ async fn run_direct_game_creator_turn_inner( None }; if let Some(visible_text) = visible_text { - emitter.emit(status, None, Some(visible_text)); + emitter.emit(status, None, Some(visible_text), None); } } DirectCodexTurnObservation::Activity(activity) => { - emitter.emit(status, Some(activity), None); + emitter.emit(status, Some(activity), None, None); + } + DirectCodexTurnObservation::Reasoning(reasoning) => { + // 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running: + // streaming 已被"用户可见正文"占用。 + emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); + } + DirectCodexTurnObservation::ToolCall(mut tool_call) => { + // 同一工具的开始、完成和详情补全共用一份单调快照。 + { + let collected = lock_direct_tool_call_collector(&turn_tool_calls); + let existing = collected + .iter() + .find(|existing| existing.id == tool_call.id); + if existing.is_some_and(|call| { + call.status != "running" && tool_call.status == "running" + }) { + return; + } + if !super::direct_tool_calls::direct_tool_call_status_changed( + existing, &tool_call, + ) { + return; + } + if let Some(existing) = existing { + tool_call.updated_at = tool_call + .updated_at + .max(existing.updated_at.saturating_add(1)); + tool_call = super::direct_tool_calls::merge_tool_call_snapshot( + existing, &tool_call, + ); + } + } + { + let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); + collected.retain(|existing| existing.id != tool_call.id); + collected.push(tool_call.clone()); + } + // 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。 + let mut items = stream_writer + .take_pending_snapshot() + .into_iter() + .collect::>(); + items.push(stream_writer.push_tool(&tool_call, direct_tool_call_now_ms())); + for item in &items { + stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); + } + emitter.emit_with_stream_items( + status, + None, + None, + Some(vec![tool_call.clone()]), + items, + ); + // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 + spawn_persist_direct_tool_call(&turn_root, &tool_call); } } }; - 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)); + emitter.emit_with_stream_items("streaming", None, None, None, vec![item]); + } + // finalize 必须看见这一轮全部快照,不能与 fire-and-forget 写任务竞争。 + for write in stream_writes { + if !matches!(write.await, Ok(Ok(()))) { + app_log!("[turn-stream] 回合快照持久化失败"); + } + } + 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)。 + // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 + persist_collected_direct_tool_calls(root, &tool_calls); let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { DirectCodexTurnFailure::new( DirectCodexFailureStage::CodeGeneration, @@ -4417,10 +5148,19 @@ async fn run_direct_game_creator_turn_inner( ) })?; if let Some(emitter) = turn_emitter { - emitter.emit( + // 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。 + let finalized = + finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply) + .ok() + .flatten() + .into_iter() + .collect::>(); + emitter.emit_with_stream_items( "finalizing", Some("response-finalization"), Some(visible_reply.clone()), + None, + finalized, ); } if direct_codex_output_fingerprint(root) != previous_output_fingerprint { @@ -4434,6 +5174,7 @@ async fn run_direct_game_creator_turn_inner( "finalizing", Some("file-write"), Some(visible_reply.clone()), + None, ); } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) @@ -4444,6 +5185,38 @@ async fn run_direct_game_creator_turn_inner( Ok(visible_reply) } +#[cfg(test)] +mod direct_turn_stream_writer_tests { + use super::*; + + #[test] + fn item_switch_returns_previous_tail_and_next_head() { + let mut writer = DirectTurnStreamWriter::new("turn".into()); + let root = Path::new("."); + writer.push_text(root, "a", "前缀", 1000, false); + writer.push_text(root, "a", "完整正文", 1000, false); + let snapshots = writer.push_text(root, "b", "第二段", 1000, false); + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].text.as_deref(), Some("完整正文")); + assert_eq!(snapshots[0].seq, 1); + assert_eq!(snapshots[1].seq, 2); + assert!(snapshots[1].updated_at > snapshots[0].updated_at); + } + + #[test] + fn completed_snapshot_bypasses_throttle_and_keeps_item_position() { + let mut writer = DirectTurnStreamWriter::new("turn".into()); + let root = Path::new("."); + let first = writer.push_text(root, "a", "前缀", 1000, false); + let completed = writer.push_text(root, "a", "完整正文", 1000, true); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].id, first[0].id); + assert_eq!(completed[0].seq, first[0].seq); + assert!(completed[0].updated_at > first[0].updated_at); + assert_eq!(completed[0].text.as_deref(), Some("完整正文")); + } +} + /// Default product path: one user message becomes one turn on the same /// project-bound Codex app-server thread. The client does not classify the /// intent or perform hidden art, preview, repair, or another LLM workflow. If @@ -4474,10 +5247,18 @@ where { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = run_turn(system_prompt, prompt.to_string()) .await .map_err(|error| { @@ -4737,8 +5518,37 @@ 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 { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), @@ -4857,6 +5667,132 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { + let root = tempfile::tempdir().expect("active invocation root"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") + .expect("first client turn"); + let running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("running turn is visible to the read-only probe"); + assert_eq!(running.client_turn_id, "client-turn-read-1"); + assert!(running.started_at > 0, "{running:?}"); + // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 + assert_eq!( + serde_json::to_value(&running).expect("serialize view"), + serde_json::json!({ + "clientTurnId": "client-turn-read-1", + "startedAt": running.started_at, + }) + ); + // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 + let duplicate = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") + .expect_err("read-only probe must not take over the project"); + assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + + drop(first); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + } + + #[test] + fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") + .expect("first client turn"); + + // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 + let mismatch = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-2"), + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("another turn must not be released"); + assert!(mismatch.contains("另一条"), "{mismatch}"); + assert!( + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() + ); + + // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect_err("a freshly registered turn is still starting"); + assert!(young.contains("暂不能强制释放"), "{young}"); + + // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 + backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect("stale guard is released"); + assert_eq!(released, "client-turn-stale-1"); + let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") + .expect("a new turn can start once the stale guard is released"); + // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 + drop(first); + let still_running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("the newer turn survives the stale guard drop"); + assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); + drop(second); + } + + #[test] + fn stale_guard_release_after_the_executor_exited_frees_the_project() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") + .expect("client turn"); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect("executor exited: this guard is residue"); + assert_eq!(released, "client-turn-exited-1"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + let _second = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") + .expect("a new turn can start after the residue is released"); + drop(first); + + // 没有任何登记时给出可读原因,而不是静默成功。 + let empty = tempfile::tempdir().expect("empty invocation root"); + let nothing = release_stale_direct_taonier_active_invocation( + empty.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("nothing to release"); + assert!(nothing.contains("没有正在运行"), "{nothing}"); + } + + /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 + fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { + let root = root.canonicalize().expect("canonical root"); + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("active invocation lock"); + let entry = active.get_mut(&root).expect("registered invocation"); + entry.started_at = entry.started_at.saturating_sub(age_ms); + } + #[test] fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { let root = tempfile::tempdir().expect("active snapshot root"); @@ -5004,6 +5940,9 @@ mod tests { status: "streaming".to_string(), activity: None, accumulated_text: Some("partial".to_string()), + tool_calls: None, + reasoning_text: None, + stream_items: None, updated_at: 42, }) .expect("serialize direct update"); @@ -5037,6 +5976,7 @@ mod tests { assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明")); assert!(prompt.contains("用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎")); + assert!(prompt.contains("三维请求合同")); assert!(prompt.contains("Cocos 的编辑器能力来自客户端随包提供的内置插件")); assert!(prompt.contains("不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展")); assert!(prompt.contains("不得改为查项目扩展或要求用户打开 Cocos MCP 面板")); @@ -5054,6 +5994,108 @@ mod tests { ); } + #[test] + fn three_dimensional_game_request_frees_the_engine_choice() { + let root = direct_engine_test_root("web"); + let prompt = "帮我做个 3D 城市模拟游戏"; + assert_eq!( + direct_engine_intent_from_prompt(prompt), + Some(DirectEngineIntent::ThreeDimensional) + ); + let contract = + direct_engine_three_dimensional_contract(root.path(), prompt).expect("contract"); + // 三维请求不再固定 Phaser,也不要求先澄清引擎。 + assert!(contract.contains("不受")); + assert!(contract.contains("Phaser 4.2.1")); + assert!(contract.contains("不必先向用户确认引擎")); + assert!(contract.contains("Three.js")); + assert!(contract.contains("Babylon.js")); + assert!(contract.contains("Web(Phaser/Vite)工程")); + // 用户原文不被改写,也不产生任何副作用。 + assert!(!root.path().join("assets").exists()); + } + + #[test] + fn explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection() { + let root = direct_engine_test_root("web"); + for prompt in [ + "用等轴伪3D做城市表现就行", + "做成 2.5D 的,别用真 3D", + "把三维数组的这段代码重构一下", + "把 value4 这个字段改成 5", + "看看这个关卡为什么会卡", + ] { + assert!( + direct_engine_three_dimensional_contract(root.path(), prompt).is_none(), + "prompt should stay executable: {prompt}" + ); + } + } + + #[test] + fn named_engine_requests_keep_the_existing_engineering_rule() { + let root = direct_engine_test_root("web"); + // 点名引擎时不注入三维选型合同:工程不匹配的澄清规则由既有工程合同承担。 + assert!( + direct_engine_three_dimensional_contract(root.path(), "用 Unity 重做这个 3D 关卡") + .is_none() + ); + assert_eq!( + direct_engine_intent_from_prompt("用 Unity 重做这个 3D 关卡"), + Some(DirectEngineIntent::Named(DirectNamedEngine::Unity)) + ); + } + + #[test] + fn three_dimensional_contract_reports_the_current_project_engine() { + let cocos = direct_engine_test_root("cocos"); + assert_eq!( + direct_project_engine(cocos.path()), + DirectProjectEngine::CocosCreator + ); + let cocos_contract = + direct_engine_three_dimensional_contract(cocos.path(), "做个 3D 城市").expect("cocos"); + assert!(cocos_contract.contains("Cocos Creator")); + let web = direct_engine_test_root("web"); + let web_contract = + direct_engine_three_dimensional_contract(web.path(), "做个 3D 城市").expect("web"); + assert!(web_contract.contains("Web(Phaser/Vite)工程")); + } + + fn direct_engine_test_root(engine: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp dir"); + match engine { + "cocos" => { + std::fs::create_dir_all(root.path().join("assets")).expect("cocos assets"); + std::fs::write( + root.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .expect("cocos package"); + } + "godot" => { + std::fs::write(root.path().join("project.godot"), "[application]\n") + .expect("godot project"); + } + "unity" => { + std::fs::create_dir_all(root.path().join("ProjectSettings")) + .expect("unity settings"); + std::fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.0f1\n", + ) + .expect("unity version"); + } + "web" => { + std::fs::create_dir_all(root.path().join("game")).expect("web game dir"); + std::fs::write(root.path().join("game/index.html"), "") + .expect("web entry"); + } + _ => {} + } + root + } + #[test] fn system_prompt_uses_only_the_reviewed_skill_index() { let root = tempfile::tempdir().expect("temp dir"); @@ -5119,6 +6161,19 @@ mod tests { assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS); } + #[test] + fn home_three_dimensional_note_keeps_project_creation_available() { + let note = + direct_engine_three_dimensional_home_note("帮我做个 3D 城市游戏").expect("home note"); + assert!(note.contains("三维请求说明")); + assert!(note.contains("可以按既有规则创建项目")); + assert!(note.contains("Three.js")); + assert!(!note.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER)); + // 点名引擎与普通二维请求不加提示。 + assert!(direct_engine_three_dimensional_home_note("用 Unity 做 3D").is_none()); + assert!(direct_engine_three_dimensional_home_note("做个霓虹风格扫雷").is_none()); + } + #[test] fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { let prompt = build_direct_codex_home_system_prompt(); @@ -8059,11 +9114,6 @@ mod tests { "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';", ) .expect("scene"); - std::fs::write( - root.join("assets/art-spritesheet-slices/player.png"), - tiny_opaque_png(), - ) - .expect("slice"); assert_eq!( direct_game_sources_referenced_taonier_assets(&root), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index efdc044e7..aa9bbe8f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,7 +31,7 @@ pub(crate) fn normalize_direct_client_turn_id( pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, - user_item: DirectCodexUserItem, + mut user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, attachments: Option>, @@ -50,6 +50,17 @@ pub(crate) async fn chat_with_game_creator_direct_codex( attachments.as_deref().unwrap_or_default(), ); let attachments = attachments.unwrap_or_default(); + if !attachments.is_empty() { + let attachment_context = + render_direct_codex_user_prompt("", &attachments).map_err(|error| { + audit.finish(false); + error + })?; + let DirectCodexUserItem::Message(message) = &mut user_item; + message.content.push(DirectCodexUserContentPart::InputText { + text: attachment_context, + }); + } validate_direct_codex_user_item(root, &user_item).map_err(|error| { audit.finish(false); error @@ -81,6 +92,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( } }; audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 9f5adf8f9..e3bc497ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult { pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, + pub(crate) item_timestamps: std::collections::BTreeMap, } #[derive(Clone, Debug)] 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..705da3ecb 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 }) } @@ -1855,7 +1887,7 @@ async fn bridge_create_or_derive_resource( async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { - bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "asset.register")?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; @@ -1864,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; + let background_mode = arguments.get("backgroundMode").and_then(Value::as_str); + let screen_color = arguments.get("screenColor").and_then(Value::as_str); let manifest = read_existing_manifest_for_project(&state.root)?; let source_asset = manifest .assets @@ -1888,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .map_err(|_| "创建抠图服务连接失败".to_string())?; let context = prepare_external_canvas_generation_context(&state.root, &client, &access).await?; - let fingerprint = format!("{}\0{}", source_asset_id, asset_name); + let fingerprint = background_removal_request_fingerprint( + &source_asset_id, + &asset_name, + background_mode, + screen_color, + ); let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let route = "/api/external/v1/editor/images/background-removals"; + let mut request_body = json!({ + "sourceImageSrc": source_resource_id, + "projectId": manifest.project_id, + "assetKind": source_asset.kind, + "assetFolderId": context.asset_folder_id, + "assetLabel": asset_name, + "sourceResourceId": source_resource_id, + }); + if background_mode == Some("flat") { + request_body["backgroundMode"] = json!("flat"); + } + if let Some(color) = screen_color { + request_body["screenColor"] = json!(color); + } let response = crate::http_client::with_agc_main_site_marker( client .post(format!("{}{}", api_base_url, route)) .bearer_auth(api_key) .header("Idempotency-Key", idempotency_key) - .json(&json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - })), + .json(&request_body), ) .send() .await @@ -1940,6 +1986,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val } } +fn background_removal_request_fingerprint( + source: &str, + name: &str, + mode: Option<&str>, + color: Option<&str>, +) -> String { + let mode = mode.unwrap_or("complex"); + if mode == "complex" && color.is_none() { + format!("{source}\0{name}") + } else { + format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or("")) + } +} + fn bridge_safe_queue_state(value: Value) -> Value { let object = value.as_object(); json!({ @@ -2671,8 +2731,31 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { + #[test] + fn remove_background_identity_preserves_default_and_distinguishes_options() { + let legacy = "asset-1\0透明图"; + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", None, None), + legacy + ); + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None), + legacy + ); + let mut identities = std::collections::HashSet::new(); + identities.insert(legacy.to_string()); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let id = + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color); + assert_eq!( + id, + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color) + ); + assert!(identities.insert(id)); + } + } 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 +2849,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/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs new file mode 100644 index 000000000..82e9989e8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -0,0 +1,1418 @@ +//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! +//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: +//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 +//! 信息,这里把它们投影成结构化的 `DirectToolCall`,落到**独立文件** +//! `/.agent/conversations/tool-calls.jsonl`。 +//! +//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 +//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::project::{enforce_project_permission_policy, project_append_lock_for}; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 +pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; +/// 条目 schema 版本。 +pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; +/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; +/// `detail.command` / `detail.output` 的字符上限。 +const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; +/// 折叠态摘要(`summary`)的字符上限。 +const DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS: usize = 120; +/// 单条变更路径的字符上限。 +const DIRECT_TOOL_CALL_PATH_MAX_CHARS: usize = 300; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallDetail { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCall { + pub(crate) schema_version: String, + /// Codex item 的 id。同一 item 的 started/completed 共用它,落盘时按它幂等 upsert。 + pub(crate) id: String, + pub(crate) turn_id: String, + /// `command` | `file_change` | `mcp_tool` | `web_search` | `context_compaction` | `other` + pub(crate) kind: String, + /// 折叠态标题,按 kind 固定(`command` → `执行命令`、`file_change` → `编辑 N 个文件`)。 + pub(crate) title: String, + /// 折叠态标题后面的短摘要。 + pub(crate) summary: String, + /// `running` | `completed` | `failed` + pub(crate) status: String, + pub(crate) detail: DirectToolCallDetail, + pub(crate) started_at: u64, + pub(crate) updated_at: u64, +} + +impl DirectToolCall { + fn timestamp(&self) -> u64 { + if self.updated_at > 0 { + self.updated_at + } else { + self.started_at + } + } +} + +fn tool_calls_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/tool-calls.jsonl") +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +/// +/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` +/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + +/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 +/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; +/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed +/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 +/// +/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 +fn bounded_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +/// 取值的首行并按字符数截断(命令的摘要习惯)。 +fn first_line_bounded(value: &str, max_chars: usize) -> String { + let first_line = value.lines().next().unwrap_or_default().trim(); + bounded_chars(first_line, max_chars) +} + +/// 把 app-server 中可能是字符串或 JSON 对象的工具详情统一转成可读文本。 +fn direct_tool_call_value_text(value: &Value) -> Option { + match value { + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + Value::Null => None, + // 调用方先脱敏再截断,不能在这里截断掉敏感字段的语法边界。 + _ => serde_json::to_string_pretty(value).ok(), + } +} + +fn tool_call_kind(item_type: &str) -> Option<&'static str> { + match item_type { + "commandExecution" => Some("command"), + "fileChange" => Some("file_change"), + "mcpToolCall" => Some("mcp_tool"), + "webSearch" => Some("web_search"), + "contextCompaction" => Some("context_compaction"), + // todoList / reasoning / plan / agentMessage 之类不属于「工具调用」,不落卡片。 + "todoList" | "reasoning" | "plan" | "agentMessage" | "message" | "userMessage" => None, + _ => Some("other"), + } +} + +fn direct_tool_call_changes(item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectToolCallChange { + path: bounded_chars(path, DIRECT_TOOL_CALL_PATH_MAX_CHARS), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> String { + match kind { + "command" => "执行命令".to_string(), + "file_change" => { + let mut paths = changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + paths.sort_unstable(); + paths.dedup(); + if paths.is_empty() { + "编辑文件".to_string() + } else { + format!("编辑 {} 个文件", paths.len()) + } + } + "mcp_tool" => "调用工具".to_string(), + "web_search" => "搜索资料".to_string(), + "context_compaction" => "整理上下文".to_string(), + _ => "调用工具".to_string(), + } +} + +fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { + // item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为 + // `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。 + if let Some(status) = item.get("status").and_then(Value::as_str) { + match status { + "completed" => return "completed", + "failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed", + _ => {} + } + } + // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + return if exit_code == 0 { + "completed" + } else { + "failed" + }; + } + if let Some(success) = item.get("success").and_then(Value::as_bool) { + return if success { "completed" } else { "failed" }; + } + if completed { + "completed" + } else { + "running" + } +} + +/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新。 +pub(crate) fn direct_tool_call_status_changed( + existing: Option<&DirectToolCall>, + incoming: &DirectToolCall, +) -> bool { + !existing.is_some_and(|current| { + current.status == incoming.status + && current.detail == incoming.detail + && current.title == incoming.title + && current.summary == incoming.summary + }) +} + +/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 +/// +/// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, +/// 两处都没有时才用调用方给的回退值(本机毫秒时间戳)。 +pub(crate) fn direct_tool_call_from_item( + root: &Path, + item: &Value, + turn_id: &str, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let kind = tool_call_kind(item_type)?; + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty())?; + + let item_started_at = item + .get("startedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let item_completed_at = item + .get("completedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let started_at = if item_started_at > 0 { + item_started_at + } else if item_completed_at > 0 { + item_completed_at + } else { + now_ms + }; + let updated_at = if item_completed_at > 0 { + item_completed_at + } else { + started_at.max(now_ms) + }; + + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| sanitize_detail_text(root, command)) + .map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) + .or_else(|| { + item.get("arguments") + .and_then(direct_tool_call_value_text) + .map(|arguments| sanitize_detail_text(root, &arguments)) + .map(|arguments| bounded_chars(&arguments, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) + }); + let output = ["aggregatedOutput", "output", "result", "error"] + .iter() + .find_map(|key| item.get(key).and_then(direct_tool_call_value_text)) + .map(|output| sanitize_detail_text(root, &output)) + .map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); + // `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 ``, + // 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。 + let changes = direct_tool_call_changes(item) + .into_iter() + .map(|change| DirectToolCallChange { + path: sanitize_detail_text(root, &change.path), + kind: change.kind, + }) + .collect::>(); + + let tool = item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)); + // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 + let summary_source = (if kind == "mcp_tool" { + tool.as_deref() + } else { + None + }) + .or(command.as_deref()) + .or_else(|| changes.first().map(|change| change.path.as_str())) + .or(tool.as_deref()) + .unwrap_or_default(); + let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); + + Some(DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: turn_id.trim().to_string(), + kind: kind.to_string(), + title: direct_tool_call_title(kind, &changes), + summary, + status: direct_tool_call_status(item, completed).to_string(), + detail: DirectToolCallDetail { + command, + output, + changes, + }, + started_at, + updated_at, + }) +} + +fn record_line(call: &DirectToolCall) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_TOOL_CALL_RECORD_TYPE, + "payload": call, + })) + .map_err(|error| format!("序列化工具调用条目失败:{error}")) +} + +/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 +fn tool_call_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let parsed: Value = serde_json::from_str(trimmed).ok()?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TOOL_CALL_RECORD_TYPE) { + return None; + } + let payload = parsed.get("payload")?; + let mut call: DirectToolCall = serde_json::from_value(payload.clone()).ok()?; + if call.id.trim().is_empty() { + return None; + } + if call.schema_version.trim().is_empty() { + call.schema_version = DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(); + } + Some(call) +} + +fn read_tool_call_lines(path: &Path) -> Vec { + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut calls = Vec::new(); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer) { + Ok(0) => break, + // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行; + // 契约要求「单行损坏跳过该行继续」,不能把后续记录一起丢掉。 + Ok(_) => match std::str::from_utf8(&buffer) { + Ok(line) => { + if let Some(call) = tool_call_from_line(line) { + calls.push(call); + } + } + Err(_) => continue, + }, + // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 + Err(_) => break, + } + } + calls +} + +/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近 +/// `DIRECT_TOOL_CALL_LIMIT` 条。 +fn normalize_tool_calls(calls: Vec) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for call in calls { + let merged = match by_id.remove(&call.id) { + Some(previous) => merge_tool_call_snapshot(&previous, &call), + None => call, + }; + by_id.insert(merged.id.clone(), merged); + } + let mut normalized = by_id.into_values().collect::>(); + normalized.sort_by(|left, right| { + left.timestamp() + .cmp(&right.timestamp()) + .then_with(|| left.id.cmp(&right.id)) + }); + if normalized.len() > DIRECT_TOOL_CALL_LIMIT { + normalized.drain(..normalized.len() - DIRECT_TOOL_CALL_LIMIT); + } + normalized +} + +/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 +pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { + let path = tool_calls_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { + return Ok(Vec::new()); + } + Ok(normalize_tool_calls(read_tool_call_lines(&path))) +} + +/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 +fn status_certainty(status: &str) -> u8 { + match status { + "completed" | "failed" => 1, + _ => 0, + } +} + +/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。 +/// +/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`, +/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 +/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里 +/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 +/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 +/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 +pub(crate) fn merge_tool_call_snapshot( + existing: &DirectToolCall, + incoming: &DirectToolCall, +) -> DirectToolCall { + let take_incoming = incoming.updated_at > existing.updated_at + || (incoming.updated_at == existing.updated_at + && status_certainty(&incoming.status) > status_certainty(&existing.status)); + let mut merged = if take_incoming { + incoming.clone() + } else { + existing.clone() + }; + if merged.detail.command.is_none() { + merged.detail.command = existing + .detail + .command + .clone() + .or(incoming.detail.command.clone()); + } + if merged.detail.output.is_none() { + merged.detail.output = existing + .detail + .output + .clone() + .or(incoming.detail.output.clone()); + } + merged.started_at = [merged.started_at, existing.started_at, incoming.started_at] + .into_iter() + .filter(|started_at| *started_at > 0) + .min() + .unwrap_or_default(); + merged +} + +/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。 +/// +/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 +/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; +/// 文件规模由 200 条上限与 4000 字符截断兜住。 +fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<(), String> { + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut calls = read_tool_call_lines(&path); + let existing = calls + .iter() + .find(|existing| existing.id == call.id) + .cloned(); + let incoming = match existing.as_ref() { + Some(existing) => merge_tool_call_snapshot(existing, call), + None => call.clone(), + }; + calls.retain(|existing| existing.id != incoming.id); + calls.push(incoming); + let normalized = normalize_tool_calls(calls); + let mut body = String::new(); + for existing in &normalized { + body.push_str(&record_line(existing)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 落盘入口。失败不抛给调用方以外的地方——工具调用是展示数据,不能因为它把整轮判失败。 +pub(crate) fn persist_direct_tool_call_at( + root: &Path, + call: &DirectToolCall, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + upsert_direct_tool_call_once(root, call) +} + +/// 一轮结束时把本回合累积的工具调用整批落盘(一次锁、一次重写)。 +pub(crate) fn persist_direct_tool_calls_at( + root: &Path, + calls: &[DirectToolCall], +) -> Result<(), String> { + if calls.is_empty() { + return Ok(()); + } + enforce_project_permission_policy(root, "conversation.write")?; + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut existing = read_tool_call_lines(&path); + let mut incoming = calls.to_vec(); + for call in incoming.iter_mut() { + let merged = existing + .iter() + .find(|row| row.id == call.id) + .map(|previous| merge_tool_call_snapshot(previous, call)); + if let Some(merged) = merged { + *call = merged; + } + } + let ids = incoming + .iter() + .map(|call| call.id.as_str()) + .collect::>(); + existing.retain(|call| !ids.contains(&call.id.as_str())); + existing.extend(incoming); + let normalized = normalize_tool_calls(existing); + let mut body = String::new(); + for call in &normalized { + body.push_str(&record_line(call)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 本机毫秒时间戳(item 没带时间时用)。 +pub(crate) fn direct_tool_call_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +#[cfg(test)] +mod tests { + use super::{ + direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, + direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, + read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, + DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, + }; + use serde_json::json; + + /// 一行合法的落盘信封(回读用例的夹具)。 + fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { + serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": id, + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": started_at, + "updatedAt": updated_at + } + })) + .expect("serialize tool call row") + } + + fn init_tool_call_project(name: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") + .expect("init project"); + root + } + + fn command_item(id: &str, command: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "commandExecution", + "command": command, + "status": "inProgress", + "startedAtMs": 1000, + }) + } + + /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 + fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall { + DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: "turn-1".to_string(), + kind: "command".to_string(), + title: "执行命令".to_string(), + summary: "npm run build".to_string(), + status: status.to_string(), + detail: DirectToolCallDetail::default(), + started_at: 1, + updated_at, + } + } + + #[test] + fn tool_call_status_change_is_detected_only_on_real_changes() { + let running = sample_tool_call("call-1", "running", 1); + let completed = sample_tool_call("call-1", "completed", 2); + + assert!( + direct_tool_call_status_changed(None, &running), + "首次观察必须被收集" + ); + assert!( + !direct_tool_call_status_changed(Some(&running), &running), + "状态没变时不该重复下发同一份快照" + ); + assert!( + direct_tool_call_status_changed(Some(&running), &completed), + "running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)" + ); + } + + #[test] + fn explicit_declined_or_failed_status_is_not_reported_as_completed() { + for status in ["declined", "failed", "cancelled", "aborted"] { + let item = json!({ + "id": "call-1", + "type": "commandExecution", + "status": status, + }); + assert_eq!( + direct_tool_call_status(&item, true), + "failed", + "item 自带 {status} 时不能因为 completed=true 就被当成 completed" + ); + } + let completed = json!({ + "id": "call-1", + "type": "commandExecution", + "status": "completed", + }); + assert_eq!(direct_tool_call_status(&completed, true), "completed"); + } + + #[test] + fn tool_call_upsert_is_idempotent_per_item_id() { + let root = init_tool_call_project("tool-call-upsert"); + let started = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("started tool call"); + persist_direct_tool_call_at(root.path(), &started).expect("persist started"); + + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!(calls[0].status, "completed"); + assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); + assert_eq!(calls[0].updated_at, 2000); + } + + /// 判据:非 0 退出码判 failed。 + #[test] + fn tool_call_marks_failed_on_non_zero_exit_code() { + let root = init_tool_call_project("tool-call-failed"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-failed", + "type": "commandExecution", + "command": "npm test", + "exitCode": 1, + "completedAtMs": 3000, + }), + "turn-1", + true, + 3000, + ) + .expect("failed tool call"); + assert_eq!(call.status, "failed"); + } + + /// 判据:command / output 截断到 4000 字符,summary 截断到 120 字符。 + #[test] + fn tool_call_truncates_command_output_and_summary() { + let root = init_tool_call_project("tool-call-truncate"); + let long_command = "a".repeat(5000); + let long_output = "b".repeat(5000); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-long", + "type": "commandExecution", + "command": long_command, + "aggregatedOutput": long_output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("long tool call"); + let command = call.detail.command.expect("bounded command"); + let output = call.detail.output.expect("bounded output"); + assert_eq!(command.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(output.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(call.summary.chars().count(), 121, "120 字符 + 省略号"); + } + + /// 判据:脱敏后不出现 API Key / Token / 绝对用户目录。 + #[test] + fn tool_call_redacts_secrets_and_absolute_paths() { + let root = init_tool_call_project("tool-call-redact"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-secret", + "type": "commandExecution", + "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnop' https://example.com", + "aggregatedOutput": "OPENAI_API_KEY=tnr_sk_abcdefghijklmnop /home/someuser/private/notes.txt", + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("redacted tool call"); + let command = call.detail.command.as_deref().expect("command"); + let output = call.detail.output.as_deref().expect("output"); + assert!( + !command.contains("sk-abcdefghijklmnop"), + "命令里的 API Key 必须脱敏:{command}" + ); + assert!( + !output.contains("tnr_sk_abcdefghijklmnop"), + "输出里的 Token 必须脱敏:{output}" + ); + assert!( + !output.contains("/home/someuser"), + "输出里的绝对用户目录必须脱敏:{output}" + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist redacted call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains("sk-abcdefghijklmnop"), + "落盘文件里不得出现 API Key" + ); + assert!( + !raw.contains("/home/someuser"), + "落盘文件里不得出现绝对用户目录" + ); + } + + /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 + #[test] + fn tool_call_read_skips_corrupted_lines() { + let root = init_tool_call_project("tool-call-corrupt"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let good = serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": "item-good", + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": 1, + "updatedAt": 2 + } + })) + .expect("serialize good row"); + std::fs::write( + &path, + format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), + ) + .expect("write fixture"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_direct_tool_calls_at(missing.path()) + .expect("missing file is empty") + .is_empty(), + "历史文件缺失必须返回空数组" + ); + + let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); + assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); + assert_eq!(calls[0].id, "item-good"); + } + + /// 判据:回读按时间正序,且超出上限时保留最新。 + #[test] + fn tool_call_read_is_ordered_and_capped() { + let root = init_tool_call_project("tool-call-cap"); + let total = DIRECT_TOOL_CALL_LIMIT + 5; + let calls = (0..total) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-1", + false, + 1000 + index as u64, + ) + .expect("tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); + assert_eq!( + read.first().expect("first").id, + format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), + "最早被裁掉的是最旧的条目" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 + #[test] + fn tool_call_file_change_title_counts_unique_paths() { + let root = init_tool_call_project("tool-call-file-change"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-files", + "type": "fileChange", + "changes": [ + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/b.ts", "kind": "add"} + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("file change tool call"); + assert_eq!(call.kind, "file_change"); + assert_eq!(call.title, "编辑 2 个文件"); + assert_eq!(call.summary, "game/src/a.ts"); + assert_eq!(call.detail.changes.len(), 3); + } + + /// 判据:非工具类 item 不产卡片。 + #[test] + fn tool_call_skips_non_tool_items() { + let root = init_tool_call_project("tool-call-skip"); + for item_type in ["reasoning", "agentMessage", "todoList", "plan"] { + assert!( + direct_tool_call_from_item( + root.path(), + &json!({"id": "item-x", "type": item_type}), + "turn-1", + false, + direct_tool_call_now_ms(), + ) + .is_none(), + "{item_type} 不应产出工具调用卡片" + ); + } + } + /// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。 + const CREDENTIAL_CANARIES: [&str; 5] = [ + "canary-bearer-value", + "canary-cookie-value", + "canary-api-key-value", + "canary-client-secret-value", + "canary-password-value", + ]; + + /// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` / + /// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。 + #[test] + fn tool_call_redacts_extended_credential_shapes() { + let root = init_tool_call_project("tool-call-credential-shapes"); + let command = [ + "curl -H 'Authorization: Bearer canary-bearer-value' https://example.com", + "curl -b 'Cookie: session=canary-cookie-value' https://example.com", + "curl -d api_key=canary-api-key-value https://example.com", + "curl -d client_secret=canary-client-secret-value https://example.com", + "vault login --password canary-password-value --env prod", + ] + .join("\n"); + let output = [ + "Authorization: Bearer canary-output-bearer-value", + "Cookie: session=canary-output-cookie-value", + ] + .join("\n"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-credentials", + "type": "commandExecution", + "command": command, + "aggregatedOutput": output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("credential tool call"); + + let projected_command = call.detail.command.as_deref().expect("command"); + let projected_output = call.detail.output.as_deref().expect("output"); + for canary in CREDENTIAL_CANARIES { + assert!( + !projected_command.contains(canary), + "命令投影里不得出现原始凭据 {canary}:{projected_command}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !projected_output.contains(canary), + "输出投影里不得出现原始凭据 {canary}:{projected_output}" + ); + } + assert!( + !call.summary.contains("canary-bearer-value"), + "摘要取自命令首行,同样不得带原始凭据:{}", + call.summary + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist credential call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + for canary in CREDENTIAL_CANARIES { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + } + + /// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。 + #[test] + fn tool_call_redaction_keeps_normal_text_and_is_idempotent() { + let root = init_tool_call_project("tool-call-redaction-idempotent"); + let sanitize = |value: &str| sanitize_detail_text(root.path(), value); + + // 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。 + let plain = "grep -n password game/src/config.ts"; + let once = sanitize(plain); + assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏"); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // 既有前缀脱敏(sk-…)不得回退。 + let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com"; + let once = sanitize(prefixed); + assert!( + !once.contains("sk-canary-prefix-key"), + "既有前缀脱敏不得回退:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换), + // 原始值随之消失,且再次脱敏结果不变。 + let with_secret = "vault login --password canary-password-value --env prod"; + let once = sanitize(with_secret); + assert!( + !once.contains("canary-password-value"), + "`--password <值>` 不得落盘明文:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志 + // fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。 + let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod"; + let once = sanitize(placeholder); + assert_eq!( + once, "[redacted sensitive context]", + "含敏感 CLI 标志的行按既有约定整行替换" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + } + + /// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`, + /// 也不得回退 `updatedAt`;`startedAt` 仍取最早。 + #[test] + fn tool_call_persist_keeps_newest_snapshot_per_item() { + let root = init_tool_call_project("tool-call-monotonic"); + let running = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("running tool call"); + assert_eq!(running.status, "running"); + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.updated_at, 2000); + + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); + persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!( + calls[0].status, "completed", + "后到的旧快照不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt"); + assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早"); + + // 回合末整批落盘那条路径同样不得回退。 + persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) + .expect("persist stale running batch"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + assert_eq!( + calls[0].status, "completed", + "整批落盘路径同样不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); + } + + /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 + #[test] + fn tool_call_read_merges_duplicate_rows_monotonically() { + let root = init_tool_call_project("tool-call-read-monotonic"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let completed = tool_call_row("item-1", 1000, 2000); + let stale_running = tool_call_row("item-1", 1000, 1000) + .replace("\"status\":\"completed\"", "\"status\":\"running\""); + assert!(stale_running.contains("\"status\":\"running\"")); + std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); + assert_eq!(calls.len(), 1, "同 id 归并成一条"); + assert_eq!( + calls[0].status, "completed", + "磁盘上更旧的快照不得把状态打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); + assert_eq!(calls[0].started_at, 1000); + } + + /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 + #[test] + fn tool_call_paths_become_project_relative() { + let root = init_tool_call_project("tool-call-path-shape"); + let root_display = root.path().to_string_lossy().to_string(); + let inside = root + .path() + .join("game/src/x.ts") + .to_string_lossy() + .to_string(); + let outside = if cfg!(windows) { + r"C:\Windows\Temp\canary-outside.ts".to_string() + } else { + "/opt/canary/outside.ts".to_string() + }; + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-paths", + "type": "fileChange", + "changes": [ + {"path": inside, "kind": "update"}, + {"path": outside, "kind": "add"}, + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("path tool call"); + let paths = call + .detail + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!( + paths[0], "game/src/x.ts", + "项目内绝对路径必须落成项目相对路径(不能是占位符)" + ); + assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); + assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); + + persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains(&root_display), + "落盘不得残留项目根目录:{raw}" + ); + } + + /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + #[test] + fn tool_call_read_skips_invalid_utf8_line() { + let root = init_tool_call_project("tool-call-invalid-utf8"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + + // 形态一(契约原文):合法行 + 非法字节行 + 合法行 → 读回 2 条。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + assert_eq!( + calls.len(), + 2, + "非法 UTF-8 行只跳过该行,后面的合法记录必须读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-b"); + + // 形态二:损坏行缺换行(写入被截断),与紧随其后的记录黏成一行。 + // 此时被丢掉的只有黏连的那一行,其后的合法记录必须继续读回。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.push(0xff); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + assert_eq!( + calls.len(), + 2, + "损坏行缺换行时只丢黏连的那一行,其后的合法记录必须继续读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-c"); + } + + /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 + /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + #[test] + fn tool_call_cap_drops_oldest_turn_cards() { + let root = init_tool_call_project("tool-call-cap-oldest"); + let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-old", + false, + 1000 + index as u64, + ) + .expect("old turn tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); + let newest = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-newest", + "type": "commandExecution", + "command": "run newest", + "startedAtMs": 90_000, + }), + "turn-new", + false, + 90_000, + ) + .expect("newest tool call"); + persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); + assert_eq!( + read.last().expect("last").id, + "item-newest", + "最新回合的卡片必须在" + ); + assert_eq!( + read.first().expect("first").id, + "item-0001", + "最旧回合的卡片被静默丢弃(老回合卡片会消失)" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 + /// (Codex 上报的路径分隔符与盘符大小写不受我们控制)。 + #[test] + fn tool_call_paths_normalize_separators_and_case() { + let root = init_tool_call_project("tool-call-path-variants"); + let native = root.path().to_string_lossy().to_string(); + let variants = [native.replace('\\', "/"), native.to_ascii_uppercase()]; + let mut paths = Vec::new(); + for (index, variant) in variants.into_iter().enumerate() { + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-path-variant-{index}"), + "type": "fileChange", + "changes": [{"path": format!("{variant}/game/src/y.ts"), "kind": "update"}], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("variant path tool call"); + paths.push(call.detail.changes[0].path.clone()); + } + assert_eq!(paths[0], "game/src/y.ts", "`/` 写法同样要落成项目相对路径"); + assert_eq!( + paths[1], "game/src/y.ts", + "大小写变体同样要落成项目相对路径" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 1005ed813..8b0cd12a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -49,7 +49,7 @@ struct ExternalMcpHttpState { root: PathBuf, token: String, session_user_id: String, - session_generation: u64, + session_identity_generation: u64, } pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { @@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }), json!({ "name": "agc_remove_background", - "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", + "description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。", "inputSchema": { "type": "object", "properties": { @@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + }, + "backgroundMode": { + "type": "string", + "enum": ["complex", "flat"], + "description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex" + }, + "screenColor": { + "type": "string", + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测" } }, "required": ["sourceLocalAssetId", "assetName"], @@ -881,14 +891,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } } -fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { - validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; +pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { + validate_tool_object_fields( + arguments, + &[ + "sourceLocalAssetId", + "assetName", + "backgroundMode", + "screenColor", + ], + )?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string( arguments, "assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, )?; + if let Some(mode) = arguments.get("backgroundMode") { + let mode = mode + .as_str() + .ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?; + if mode != "complex" && mode != "flat" { + return Err("backgroundMode 必须是 complex 或 flat".to_string()); + } + } + if let Some(color) = arguments.get("screenColor") { + let color = color + .as_str() + .ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?; + let valid_hex = color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit()); + if color != "auto" && !valid_hex { + return Err("screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") { + return Err("complex 模式不能传 screenColor".to_string()); + } + } Ok(()) } @@ -1241,7 +1283,8 @@ fn external_mcp_session_id(root: &Path) -> String { material.push('\0'); material.push_str(&session.user_id); material.push('\0'); - material.push_str(&session.generation.to_string()); + // 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。 + material.push_str(&session.identity_generation.to_string()); } format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } @@ -1759,7 +1802,9 @@ async fn handle_external_mcp_http_request( let Some(session) = current_platform_session() else { return Err(StatusCode::UNAUTHORIZED); }; - if session.user_id != state.session_user_id || session.generation != state.session_generation { + if session.user_id != state.session_user_id + || session.identity_generation != state.session_identity_generation + { return Err(StatusCode::UNAUTHORIZED); } let response = EXTERNAL_MCP_BRIDGE_URL @@ -1794,7 +1839,7 @@ pub(crate) async fn start_external_mcp_loopback( root, token: token.clone(), session_user_id: session.user_id, - session_generation: session.generation, + session_identity_generation: session.identity_generation, }; let app = Router::new() .route(&route, post(handle_external_mcp_http_request)) @@ -1846,6 +1891,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { mod tests { use super::*; + #[test] + fn remove_background_arguments_enforce_mode_color_contract() { + for fields in [ + json!({}), + json!({"backgroundMode":"complex"}), + json!({"backgroundMode":"flat"}), + json!({"backgroundMode":"flat","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_ok(), + "{fields}" + ); + } + for fields in [ + json!({"screenColor":"auto"}), + json!({"backgroundMode":"complex","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":""}), + json!({"backgroundMode":"flat","screenColor":" auto "}), + json!({"backgroundMode":"flat","screenColor":"AUTO"}), + json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}), + json!({"backgroundMode":"flat","screenColor":null}), + json!({"backgroundMode":"flat","screenColor":12}), + json!({"backgroundMode":""}), + json!({"backgroundMode":"FLAT"}), + json!({"backgroundMode":" flat "}), + json!({"backgroundMode":null}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_err(), + "{fields}" + ); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs new file mode 100644 index 000000000..a2c8f1b30 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs @@ -0,0 +1,439 @@ +//! GameAgent 对话「回合流」的采集、持久化与回读。 +//! +//! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** +//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 +//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。 +//! +//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的 +//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到 +//! 旧文本前面"——渲染顺序只由 `seq` 决定。 +//! +//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 + +use crate::agent::sanitize_detail_text; +use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::project::{enforce_project_permission_policy, project_append_lock_for}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 +pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item"; +/// 条目 schema 版本。 +pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1"; +/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。 +pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400; +/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。 +const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000; +/// 没有流式分段时,最终回复那一段的固定 item id。 +const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final"; +/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。 +pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure"; + +/// 文本段。 +pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text"; +/// 工具调用的位置标记。 +pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectTurnStreamItem { + pub(crate) schema_version: String, + /// 幂等身份:文本段 `text::`、工具 `tool::`。 + pub(crate) id: String, + pub(crate) turn_id: String, + /// `text` | `tool` + pub(crate) kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + /// 首次出现的写入序号:**顺序真相**,同刻按它排序。 + pub(crate) seq: u64, + /// 条目首次出现的本机毫秒时刻。 + pub(crate) at: u64, + pub(crate) updated_at: u64, +} + +#[cfg(test)] +mod snapshot_tests { + use super::*; + + fn text( + turn: &str, + id: &str, + seq: u64, + at: u64, + updated: u64, + text: &str, + ) -> DirectTurnStreamItem { + direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated) + } + + #[test] + fn late_older_snapshot_cannot_undo_completed_text_or_position() { + let complete = text("turn", "item", 1, 1000, 1002, "正文"); + let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿"); + let merged = normalize_stream_items(vec![complete, late]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].text.as_deref(), Some("正文")); + assert_eq!(merged[0].seq, 1); + assert_eq!(merged[0].at, 1000); + } + + #[test] + fn retention_does_not_treat_new_turn_seq_one_as_oldest() { + let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT) + .map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, "旧")) + .collect::>(); + snapshots.push(text("new", "one", 1, 2000, 2000, "新")); + let merged = normalize_stream_items(snapshots); + assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT); + assert_eq!(merged.last().unwrap().turn_id, "new"); + } +} + +impl DirectTurnStreamItem { + fn order_key(&self) -> (u64, u64, &str) { + (self.seq, self.at, self.id.as_str()) + } +} + +fn turn_stream_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/turn-stream.jsonl") +} + +/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。 +pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String { + format!("text:{}:{}", turn_id.trim(), item_id.trim()) +} + +/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。 +pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String { + format!("tool:{}:{}", turn_id.trim(), call_id.trim()) +} + +/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。 +pub(crate) fn direct_turn_stream_text_item( + root: &Path, + turn_id: &str, + item_id: &str, + text: &str, + seq: u64, + at: u64, + updated_at: u64, +) -> DirectTurnStreamItem { + DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: direct_turn_stream_text_item_id(turn_id, item_id), + turn_id: turn_id.trim().to_string(), + kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), + text: Some(sanitize_stream_text(root, text)), + call_id: None, + seq, + at, + updated_at, + } +} + +/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。 +pub(crate) fn direct_turn_stream_tool_item( + turn_id: &str, + call: &crate::DirectToolCall, + seq: u64, + at: u64, +) -> DirectTurnStreamItem { + DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: direct_turn_stream_tool_item_id(turn_id, &call.id), + turn_id: turn_id.trim().to_string(), + kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(), + text: None, + call_id: Some(call.id.trim().to_string()), + seq, + at, + updated_at: call.updated_at, + } +} + +/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。 +pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String { + let sanitized = sanitize_detail_text(root, text); + if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS { + return sanitized; + } + let mut truncated = sanitized + .chars() + .take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS) + .collect::(); + truncated.push('…'); + truncated +} + +fn record_line(item: &DirectTurnStreamItem) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_TURN_STREAM_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化回合流条目失败:{error}")) +} + +/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 +fn stream_item_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let parsed: Value = serde_json::from_str(trimmed).ok()?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) { + return None; + } + let payload = parsed.get("payload")?; + let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?; + if item.id.trim().is_empty() || item.turn_id.trim().is_empty() { + return None; + } + if !matches!( + item.kind.as_str(), + DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL + ) { + return None; + } + if item.schema_version.trim().is_empty() { + item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(); + } + Some(item) +} + +fn read_stream_lines(path: &Path) -> Vec { + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut items = Vec::new(); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer) { + Ok(0) => break, + // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。 + Ok(_) => match std::str::from_utf8(&buffer) { + Ok(line) => { + if let Some(item) = stream_item_from_line(line) { + items.push(item); + } + } + Err(_) => continue, + }, + // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 + Err(_) => break, + } + } + items +} + +/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零, +/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。 +fn merge_stream_snapshot( + existing: &DirectTurnStreamItem, + incoming: &DirectTurnStreamItem, +) -> DirectTurnStreamItem { + let text_len = |item: &DirectTurnStreamItem| { + item.text + .as_deref() + .map(str::chars) + .map(Iterator::count) + .unwrap_or_default() + }; + // writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。 + let take_incoming = incoming.updated_at > existing.updated_at + || (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing)); + let mut merged = existing.clone(); + if take_incoming { + merged.text = incoming.text.clone(); + } + merged.updated_at = merged.updated_at.max(incoming.updated_at); + if merged.call_id.is_none() { + merged.call_id = incoming.call_id.clone(); + } + merged.seq = merged.seq.min(incoming.seq); + merged.at = [merged.at, incoming.at] + .into_iter() + .filter(|at| *at > 0) + .min() + .unwrap_or_default(); + merged +} + +/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。 +fn normalize_stream_items(items: Vec) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for item in items { + let merged = match by_id.remove(&item.id) { + Some(previous) => merge_stream_snapshot(&previous, &item), + None => item, + }; + by_id.insert(merged.id.clone(), merged); + } + let mut normalized = by_id.into_values().collect::>(); + let mut turn_starts = BTreeMap::::new(); + for item in &normalized { + turn_starts + .entry(item.turn_id.clone()) + .and_modify(|at| *at = (*at).min(item.at)) + .or_insert(item.at); + } + normalized.sort_by(|left, right| { + (turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&( + turn_starts[&right.turn_id], + &right.turn_id, + right.order_key(), + )) + }); + if normalized.len() > DIRECT_TURN_STREAM_LIMIT { + normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT); + } + normalized +} + +/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。 +/// 文件规模由 400 条上限与 8000 字符截断兜住。 +fn with_locked_stream_items( + root: &Path, + mutate: impl FnOnce(&mut Vec) -> T, +) -> Result { + let path = turn_stream_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("回合流写入")?; + let mut items = read_stream_lines(&path); + let outcome = mutate(&mut items); + let normalized = normalize_stream_items(items); + let mut body = String::new(); + for item in &normalized { + body.push_str(&record_line(item)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?; + Ok(outcome) +} + +/// 幂等 upsert 一条回合流条目。 +/// +/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置, +/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。 +pub(crate) fn upsert_direct_turn_stream_item_at( + root: &Path, + item: &DirectTurnStreamItem, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + with_locked_stream_items(root, |items| { + // normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。 + items.push(item.clone()); + }) +} + +/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。 +pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result, String> { + let path = turn_stream_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? { + return Ok(Vec::new()); + } + Ok(normalize_stream_items(read_stream_lines(&path))) +} + +/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 +/// +/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 +/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。 +pub(crate) fn append_direct_turn_stream_text_at( + root: &Path, + turn_id: &str, + item_id: &str, + text: &str, +) -> Result, String> { + let turn_id = turn_id.trim(); + let text = text.trim(); + if turn_id.is_empty() || text.is_empty() { + return Ok(None); + } + let sanitized = sanitize_stream_text(root, text); + let item_id = item_id.trim(); + enforce_project_permission_policy(root, "conversation.write")?; + let now = crate::agent::direct_tool_call_now_ms(); + with_locked_stream_items(root, |items| { + let existing_id = direct_turn_stream_text_item_id(turn_id, item_id); + if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) { + // 位置不动:只替换文本与 updatedAt。 + existing.text = Some(sanitized.clone()); + existing.updated_at = now.max(existing.updated_at); + return Some(existing.clone()); + } + // 首次出现:位置钉在末尾(当前最大 seq + 1)。 + let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1; + let item = DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: existing_id, + turn_id: turn_id.to_string(), + kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), + text: Some(sanitized), + call_id: None, + seq: next_seq, + at: now, + updated_at: now, + }; + items.push(item.clone()); + Some(item) + }) +} + +/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。 +pub(crate) fn finalize_direct_turn_stream_reply_at( + root: &Path, + turn_id: &str, + visible_reply: &str, +) -> Result, String> { + let turn_id = turn_id.trim(); + if turn_id.is_empty() || visible_reply.trim().is_empty() { + return Ok(None); + } + // 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。 + let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply) + .unwrap_or_else(|| visible_reply.trim().to_string()); + let visible_reply = visible_reply.as_str(); + enforce_project_permission_policy(root, "conversation.write")?; + let now = crate::agent::direct_tool_call_now_ms(); + with_locked_stream_items(root, |items| { + if items + .iter() + .any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT) + { + None + } else { + let next_seq = items + .iter() + .filter(|item| item.turn_id == turn_id) + .map(|item| item.seq) + .max() + .unwrap_or(0) + + 1; + let item = direct_turn_stream_text_item( + root, + turn_id, + DIRECT_TURN_STREAM_FINAL_ITEM_ID, + visible_reply, + next_seq, + now, + now, + ); + items.push(item.clone()); + Some(item) + } + }) +} 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 693aa2dc1..f545cc279 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 @@ -11,6 +11,10 @@ use super::external_generation_state::{ retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, }; use super::*; +use crate::platform_session::{ + acquire_platform_session_identity_lease, validate_platform_session_identity, + PlatformSessionIdentity, +}; use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); @@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice { #[derive(Clone)] struct PreparedPlatformSessionFence { - user_id: String, - api_base_url: String, - generation: u64, - access_token_sha256: String, + identity: PlatformSessionIdentity, } impl PreparedPlatformSessionFence { @@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence { access .frozen_platform_session() .map(|session| PreparedPlatformSessionFence { - user_id: session.user_id.clone(), - api_base_url: session.api_base_url.clone(), - generation: session.generation, - access_token_sha256: format!( - "{:x}", - Sha256::digest(session.access_token.as_bytes()) - ), + identity: session.identity(), }) } fn validate(&self) -> Result<(), String> { - let matches = current_platform_session().is_some_and(|session| { - session.user_id == self.user_id - && session.api_base_url == self.api_base_url - && session.generation == self.generation - && format!("{:x}", Sha256::digest(session.access_token.as_bytes())) - == self.access_token_sha256 - }); - if matches { - Ok(()) - } else { - Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ) - } + // 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。 + validate_platform_session_identity(&self.identity) } fn acquire_lease(&self) -> Result { - acquire_validated_platform_session_fingerprint( - &self.user_id, - &self.api_base_url, - self.generation, - &self.access_token_sha256, - ) + acquire_platform_session_identity_lease(&self.identity) } } @@ -6519,6 +6496,7 @@ impl PlatformArtSliceContractRollback { fn validate_strict_platform_art_spritesheet_contract( slices: &[PreparedPlatformArtAssetSlice], + slice_warning: Option<&str>, canvas_context: &ExternalCanvasGenerationContext, canvas_project_id: Option<&str>, resource_id: Option<&str>, @@ -6532,7 +6510,11 @@ fn validate_strict_platform_art_spritesheet_contract( has_visible_pixels: bool, ) -> Result<(), String> { if slices.is_empty() { - return Err("spritesheet 图集至少需要一个独立切片".to_string()); + return Err(slice_warning + .map(str::trim) + .filter(|warning| !warning.is_empty()) + .map(|warning| format!("spritesheet 图集至少需要一个独立切片;原始切片告警:{warning}")) + .unwrap_or_else(|| "spritesheet 图集至少需要一个独立切片".to_string())); } let resource_id = resource_id .map(str::trim) @@ -7337,6 +7319,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( if require_complete_core_slices { validate_strict_platform_art_spritesheet_contract( &slices, + slice_warning.as_deref(), &canvas_context, canvas_project_id.as_deref(), resource_id.as_deref(), @@ -9798,6 +9781,7 @@ mod canvas_generation_tests { .collect::>(); validate_strict_platform_art_spritesheet_contract( &slices, + None, &canvas_context, Some("canvas-project"), Some("spritesheet-resource"), @@ -9813,6 +9797,35 @@ mod canvas_generation_tests { .expect("valid slice identities and pixel evidence do not require a fixed layout"); } + #[test] + fn strict_spritesheet_contract_preserves_slice_warning_when_empty() { + let canvas_context = ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "empty-slice-warning".to_string(), + }; + let error = validate_strict_platform_art_spritesheet_contract( + &[], + Some("识别出的素材数量超过输出上限:86,最多允许 256 个"), + &canvas_context, + None, + None, + None, + None, + "route", + "kind", + None, + &[], + false, + false, + ) + .expect_err("empty slices must expose the original platform warning"); + + assert!(error.contains("至少需要一个独立切片")); + assert!(error.contains("原始切片告警")); + assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个")); + } + #[test] fn strict_spritesheet_contract_rejects_an_opaque_slice() { let canvas_context = ExternalCanvasGenerationContext { @@ -9850,6 +9863,7 @@ mod canvas_generation_tests { let error = validate_strict_platform_art_spritesheet_contract( &slices, + None, &canvas_context, Some("canvas-project"), Some("spritesheet-resource"), @@ -10599,7 +10613,7 @@ mod canvas_generation_tests { } drop(owner_a_access); drop(frozen_owner_a); - install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2) + install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2) .expect("switch to owner B"); let error = match request_platform_art_asset_with_runtime_options_at( @@ -10724,8 +10738,14 @@ mod canvas_generation_tests { .recv_timeout(Duration::from_secs(3)) .expect("wait for accepted response"); std::thread::sleep(Duration::from_millis(50)); - install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2) - .expect("switch platform account after accepted response"); + install_platform_session( + "post-202-user-b", + "post-202-token-b", + &switch_base_url, + 2, + 2, + ) + .expect("switch platform account after accepted response"); }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), @@ -12449,7 +12469,10 @@ mod canvas_generation_tests { .expect("init strict slice project"); let path = root.join("assets/art-spritesheet.png"); fs::write(&path, b"old-image").expect("write old spritesheet"); - let prepared = prepared_replacement(root, b"new-image"); + let mut prepared = prepared_replacement(root, b"new-image"); + prepared.slice_warning = Some( + "图标 spritesheet 识别出的素材数量超过输出上限:86,最多允许 256 个。".to_string(), + ); let error = commit_prepared_platform_art_asset_strict_slices_at( root, @@ -12460,6 +12483,8 @@ mod canvas_generation_tests { .expect_err("strict spritesheet commit must require at least one slice"); assert!(error.contains("至少需要一个独立切片")); + assert!(error.contains("原始切片告警")); + assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个")); assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image"); assert!(!root .join("assets/art-spritesheet-slices/manifest.json") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 03b0dcac1..0a8974342 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1431,12 +1431,13 @@ mod external_generation_state_tests { base_url, ); let frozen_a = current_platform_session().expect("freeze owner A"); - validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch"); + validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch"); replace_platform_session_for_gui_owner( "fingerprint-owner-b", "fingerprint-token-b", base_url, 2, + 2, ) .expect("switch global session to owner B"); let current_b = current_platform_session().expect("owner B is current after switch"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index d0e3a92a0..624479e44 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { response_stream_fixture("finalization-tool-plan-repair-chain-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "finalization-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "finalization-tool-plan-model".to_string(), @@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-provider-model".to_string(), @@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo LlmMessage::user("修复格式"), ]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-tool-plan-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-tool-plan-model".to_string(), @@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); let root = project.path(); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-generic-retry-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-generic-retry-model".to_string(), @@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { response_stream_fixture("tool-plan-capacity-preflight-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-capacity-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-capacity-model".to_string(), @@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem LlmMessage::user("修复格式"), ]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "durable-control-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "durable-control-tool-plan-model".to_string(), @@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff( snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-cleanup-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-cleanup-model".to_string(), @@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "terminal-handoff-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "terminal-handoff-model".to_string(), @@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "provider-model".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f183e0f6b..02b48e807 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,8 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); -pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< - std::sync::Mutex>, +/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。 +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock< + std::sync::Mutex>, > = OnceLock::new(); #[cfg(test)] pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = @@ -293,8 +294,8 @@ pub(crate) use entrypoints::{ configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event, generate_local_game_draft_at, - install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, - read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, + read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 76b85ef7b..4e5177b10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,11 +1,12 @@ use super::*; const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; +const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; -fn lock_game_creator_manifest_invalidation_event_sink( -) -> std::sync::MutexGuard<'static, Option> { - GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK - .get_or_init(|| Mutex::new(None)) +fn lock_game_creator_manifest_invalidation_event_sinks( +) -> std::sync::MutexGuard<'static, Vec> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS + .get_or_init(|| Mutex::new(Vec::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } @@ -49,6 +50,61 @@ impl DirectGameCreatorTurnUpdateEmitter { status: &'static str, activity: Option<&'static str>, accumulated_text: Option, + tool_calls: Option>, + ) { + self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None); + } + + /// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。 + pub(crate) fn emit_with_reasoning( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + reasoning_text: Option, + ) { + self.emit_full( + status, + activity, + accumulated_text, + tool_calls, + reasoning_text, + Vec::new(), + ); + } + + /// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。 + /// + /// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据, + /// 不能在前端各算一套顺序。 + pub(crate) fn emit_with_stream_items( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + stream_items: Vec, + ) { + self.emit_full( + status, + activity, + accumulated_text, + tool_calls, + None, + stream_items, + ); + } + + #[allow(clippy::too_many_arguments)] + fn emit_full( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + reasoning_text: Option, + stream_items: Vec, ) { let status_is_allowed = matches!( status, @@ -100,6 +156,9 @@ impl DirectGameCreatorTurnUpdateEmitter { status: status.to_string(), activity: activity.map(str::to_string), accumulated_text, + tool_calls, + reasoning_text, + stream_items: (!stream_items.is_empty()).then_some(stream_items), updated_at, }, ); @@ -161,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( token: &str, ) -> Result<(), String> { let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; - install_game_creator_manifest_invalidation_event_sink(sink); + register_game_creator_manifest_invalidation_event_sink(sink); Ok(()) } @@ -182,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink( }) } -pub(crate) fn install_game_creator_manifest_invalidation_event_sink( +/// 登记一个界面窗口的事件接收端。 +/// +/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有 +/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。 +pub(crate) fn register_game_creator_manifest_invalidation_event_sink( sink: GameCreatorManifestInvalidationEventSink, ) { - *lock_game_creator_manifest_invalidation_event_sink() = Some(sink); + let mut sinks = lock_game_creator_manifest_invalidation_event_sinks(); + if let Some(existing) = sinks + .iter_mut() + .find(|existing| existing.token == sink.token) + { + *existing = sink; + return; + } + if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX { + sinks.remove(0); + } + sinks.push(sink); +} + +fn remove_game_creator_manifest_invalidation_event_sink(token: &str) { + lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token); } #[cfg(test)] @@ -200,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard { } pub(crate) fn configured_sink(&self) -> Option { - lock_game_creator_manifest_invalidation_event_sink().clone() + lock_game_creator_manifest_invalidation_event_sinks() + .first() + .cloned() + } + + pub(crate) fn configured_sinks(&self) -> Vec { + lock_game_creator_manifest_invalidation_event_sinks().clone() } } #[cfg(test)] impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { fn drop(&mut self) { - *lock_game_creator_manifest_invalidation_event_sink() = None; + lock_game_creator_manifest_invalidation_event_sinks().clear(); } } @@ -223,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( } fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { - let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); - let Some(sink) = sink else { + let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone(); + if sinks.is_empty() { return Ok(()); + } + let event = GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), }; + let mut failed_tokens = Vec::new(); + let mut last_error = None; + for sink in &sinks { + match relay_game_creator_manifest_invalidation_to_sink(sink, &event) { + Ok(()) => {} + Err(error) => { + // 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。 + failed_tokens.push(sink.token.clone()); + last_error = Some(error); + } + } + } + if !failed_tokens.is_empty() { + lock_game_creator_manifest_invalidation_event_sinks() + .retain(|sink| !failed_tokens.contains(&sink.token)); + } + match last_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +fn relay_game_creator_manifest_invalidation_to_sink( + sink: &GameCreatorManifestInvalidationEventSink, + event: &GameCreatorManifestInvalidatedEvent, +) -> Result<(), String> { let envelope = GameCreatorManifestInvalidationRelayEnvelope { - token: sink.token, - event: GameCreatorManifestInvalidatedEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id: agent_id.to_string(), - }, + token: sink.token.clone(), + event: event.clone(), }; let payload = serde_json::to_vec(&envelope) .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; 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 d8cd1b04a..ade9a894f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1841,8 +1841,9 @@ pub(crate) async fn polish_local_project_prompt( } #[tauri::command] -pub(crate) fn read_platform_account_session_generation() -> u64 { - current_platform_session_generation() +pub(crate) fn read_platform_account_session_state( +) -> crate::platform_session::PlatformSessionWriteState { + crate::platform_session::current_platform_session_write_state() } #[tauri::command] @@ -1850,28 +1851,45 @@ pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; install_external_agent_runner_platform_session( &user_id, &access_token, &api_base_url, - generation, + identity_generation, + revision, )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) }) .await .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { +pub(crate) async fn clear_platform_account_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { tokio::task::spawn_blocking(move || { shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); Ok(()) }) .await @@ -1893,11 +1911,36 @@ pub(crate) fn write_game_creator_app_config( .lock() .map_err(|_| "配置写入锁不可用")?; let (current, overlays) = load_game_creator_app_config_for_write()?; + // 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。 + config.llm.custom_enabled = current.llm.custom_enabled; config.selected_model_id = current.selected_model_id; config.selected_model_is_default = current.selected_model_is_default; persist_game_creator_app_config(config, overlays, false) } +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, @@ -1906,7 +1949,12 @@ pub(crate) fn select_game_creator_model( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - if model_id.is_empty() + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + if config.llm.custom_enabled { + if !config.llm.visible_models.contains(&model_id) { + return Err("所选模型未勾选或已移除,请刷新模型列表".into()); + } + } else if model_id.is_empty() || model_id.len() > 64 || !model_id .bytes() @@ -1914,12 +1962,21 @@ pub(crate) fn select_game_creator_model( { return Err("模型标识无效".into()); } - let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; config.selected_model_is_default = is_default; persist_game_creator_app_config(config, overlays, true) } +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -1935,8 +1992,8 @@ fn persist_game_creator_app_config( let previous = overlay.clone(); if let Some(fields) = overlay.as_object_mut() { for (key, value) in fields.iter_mut() { - if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") - == model_only + if !model_only + || matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") { if let Some(saved_value) = saved.get(key) { // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 @@ -4110,14 +4167,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( access.validate_frozen_session()?; let _platform_session_lease = frozen_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; access.validate_frozen_session()?; @@ -5072,7 +5122,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()) } @@ -5180,6 +5235,31 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) async fn read_direct_tool_calls( + project_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_tool_calls_at(root) + }) + .await + .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) async fn read_direct_turn_stream( + project_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_turn_stream_at(root) + }) + .await + .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? +} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5227,12 +5307,16 @@ pub(crate) async fn read_direct_project_history_slice( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more) = read_direct_project_history_items_slice_at( + let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( root, before_item_id.as_deref(), limit.unwrap_or(20), )?; - Ok(DirectThreadHistorySlice { items, has_more }) + Ok(DirectThreadHistorySlice { + items, + has_more, + item_timestamps, + }) }) .await .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index b66b976af..95c7726f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -108,6 +108,8 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { } pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; +/// 官方路由未选择平台目录模型时写入配置文件的占位标识。 +pub(crate) const OFFICIAL_LLM_ROUTER_DEFAULT_MODEL: &str = "platform-default"; pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), @@ -162,6 +164,9 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { + if llm.custom_enabled { + return build_game_creator_provider_llm_config(llm, config_path); + } if game_creator_official_llm_route_locked() { return build_game_creator_official_platform_llm_config(llm); } @@ -454,7 +459,7 @@ fn check_game_creator_codex_config( ); let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); status.agent_mode = app_config.agent_mode.clone(); - if game_creator_official_llm_route_locked() { + if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() { let account_ready = current_platform_session().is_some(); status.account_credential_state = if account_ready { "ready".to_string() @@ -490,7 +495,7 @@ fn check_game_creator_codex_config( ); agent.configured = cli_error.is_none() && route_error.is_none(); agent.error = cli_error.clone().or(route_error); - if game_creator_official_llm_route_locked() { + if !llm.custom_enabled && game_creator_official_llm_route_locked() { agent.account_credential_state = status.account_credential_state.clone(); agent.official_route_locked = true; agent.configured = status.configured; @@ -528,6 +533,14 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error( if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { return None; } + if llm.custom_enabled { + if let Err(error) = validate_custom_llm_connection(llm) { + return Some(error); + } + if !llm.visible_models.contains(&llm.model) { + return Some("请至少勾选一个模型,并从已勾选列表选择模型".to_string()); + } + } if llm.api_kind != "openai_responses" { return Some(format!( "配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式", @@ -601,7 +614,7 @@ pub(crate) fn check_game_creator_llm_config_values( "unavailable" } .to_string(), - official_route_locked: game_creator_official_llm_route_locked(), + official_route_locked: !config.custom_enabled && game_creator_official_llm_route_locked(), reasoning_effort: config.reasoning_effort.clone(), stream: config.stream, web_search_enabled: config.web_search_enabled, @@ -3332,9 +3345,7 @@ pub(crate) fn configure_game_creator_runtime_config_dir( write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; } - // Both the normal config and the optional local override are persisted - // inputs. Every real AGC build scrubs legacy provider credentials from - // either file before the next read can observe them again. + // 按主配置与本地覆盖的最终开关决定是否保留自定义连接。 for path in [ config_path, config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), @@ -3421,6 +3432,7 @@ pub(crate) fn load_game_creator_app_config() -> Result bool { + if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) { + return false; + } let mut changed = config.agent_mode.as_deref() != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) || config.agent_llm.is_some(); config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()); config.agent_llm = None; if let Some(llm) = config.llm.as_mut() { - changed |= llm.api_key.is_some() - || llm.base_url.is_some() - || llm.model.is_some() - || llm.api_kind.is_some(); - llm.api_key = None; - llm.base_url = None; - llm.model = None; - llm.api_kind = None; + // 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时 + // 用户能看到 baseUrl / apiKey / model / apiKind 四要素与开关、模型列表并列。 + let official_model = config + .selected_model_id + .clone() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string()); + changed |= llm.api_key.as_deref() != Some("") + || llm.base_url.as_deref() != Some(OFFICIAL_LLM_ROUTER_BASE_URL) + || llm.model.as_deref() != Some(official_model.as_str()) + || llm.api_kind.as_deref() != Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + || llm.custom_enabled.is_none() + || llm.visible_models.is_none(); + llm.api_key = Some(String::new()); + llm.base_url = Some(OFFICIAL_LLM_ROUTER_BASE_URL.to_string()); + llm.model = Some(official_model); + llm.api_kind = Some(DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()); + llm.custom_enabled = Some(false); + llm.visible_models = Some(llm.visible_models.take().unwrap_or_default()); } if config.editor_api.is_some() { changed = true; @@ -3480,6 +3507,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), let mut config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; let mut changed = false; + changed |= ensure_game_creator_custom_llm_file_fields(&mut config); let inferred_agent_mode = config .agent_mode .as_deref() @@ -3531,7 +3559,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), )); } } - if game_creator_official_llm_route_locked() { + if game_creator_official_llm_route_locked() && !custom_llm_enabled_at_config_path(path)? { changed |= scrub_locked_game_creator_config_file(&mut config); } if changed { @@ -3542,11 +3570,30 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), Ok(()) } -/// Returns whether a real AGC build must use the authenticated API Server -/// proxy instead of any persisted provider credentials. +/// 让文件始终带 `customEnabled` 与 `visibleModels`:自定义连接靠手写这些字段开启, +/// 键缺席时用户无法从文件本身看出开关和模型列表写在哪里。 +pub(crate) fn ensure_game_creator_custom_llm_file_fields( + config: &mut GameCreatorAppConfigFile, +) -> bool { + let Some(llm) = config.llm.as_mut() else { + return false; + }; + let mut changed = false; + if llm.custom_enabled.is_none() { + llm.custom_enabled = Some(false); + changed = true; + } + if llm.visible_models.is_none() { + llm.visible_models = Some(Vec::new()); + changed = true; + } + changed +} + +/// 默认官方路由策略;显式 llm.customEnabled 由调用方优先处理。 /// /// Debug and release binaries intentionally share this decision. The two -/// exceptions are the Rust unit-test build and the explicitly env-gated debug +/// test exceptions are the Rust unit-test build and the explicitly env-gated debug /// deterministic-provider E2E; their loopback fixtures are never compiled into /// or enabled inside a shipped release binary. pub(crate) fn game_creator_official_llm_route_locked() -> bool { @@ -3577,16 +3624,19 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr return; } config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); + config.agent_llm.clear(); + config.editor_api.api_key.clear(); + if config.llm.custom_enabled { + return; + } config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); config.llm.model = if config.selected_model_id.is_empty() { - "platform-default".to_string() + OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string() } else { config.selected_model_id.clone() }; config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); - config.agent_llm.clear(); - config.editor_api.api_key.clear(); } pub(crate) fn game_creator_app_config_view( @@ -3944,6 +3994,12 @@ pub(crate) fn merge_game_creator_llm_config( config: &mut GameCreatorLlmConfig, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = value; + } + if let Some(value) = patch.visible_models { + config.visible_models = value; + } if let Some(value) = patch.api_key { config.api_key = value; } @@ -3989,6 +4045,12 @@ pub(crate) fn merge_game_creator_llm_patch( config: &mut GameCreatorLlmConfigFile, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = Some(value); + } + if let Some(value) = patch.visible_models { + config.visible_models = Some(value); + } if let Some(value) = patch.api_key { config.api_key = Some(value); } @@ -4073,6 +4135,145 @@ pub(crate) fn trim_config_string(value: &str) -> Option { } } +fn custom_llm_enabled_at_config_path(path: &Path) -> Result { + let parent = path.parent().ok_or("客户端配置缺少父目录")?; + let mut enabled = false; + for name in [ + GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_LOCAL_CONFIG_FILE_NAME, + ] { + if let Some(content) = read_game_creator_config_file(&parent.join(name))? { + let file: GameCreatorAppConfigFile = + serde_json::from_str(&content).map_err(|_| "解析客户端配置失败".to_string())?; + if let Some(value) = file.llm.and_then(|llm| llm.custom_enabled) { + enabled = value; + } + } + } + Ok(enabled) +} + +pub(crate) fn validate_custom_llm_connection( + llm: &GameCreatorLlmConfig, +) -> Result { + if llm.api_key.trim().is_empty() { + return Err("请填写自定义 LLM API Key".to_string()); + } + let url = + url::Url::parse(llm.base_url.trim()).map_err(|_| "自定义 LLM API 地址无效".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("请填写不含凭据、查询参数和片段的 HTTP(S) API 根地址".to_string()); + } + Ok(url) +} + +pub(crate) fn normalize_custom_llm_model_ids(ids: &[String]) -> Result, String> { + if ids.len() > 4096 { + return Err("模型列表超过 4096 项上限".to_string()); + } + let mut result = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in ids { + let id = id.trim(); + if id.is_empty() + || id.len() > 256 + || id.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err("模型标识为空、包含空白或超过 256 字节".to_string()); + } + if seen.insert(id.to_string()) { + result.push(id.to_string()); + } + } + Ok(result) +} + +fn apply_custom_llm_model_selection(config: &mut GameCreatorAppConfig) { + if !config.llm.custom_enabled { + return; + } + if config.selected_model_is_default + || !config + .llm + .visible_models + .contains(&config.selected_model_id) + { + config.selected_model_id = config + .llm + .visible_models + .first() + .cloned() + .unwrap_or_default(); + config.selected_model_is_default = true; + } + config.llm.model = config.selected_model_id.clone(); +} + +pub(crate) async fn fetch_custom_llm_models( + llm: &GameCreatorLlmConfig, +) -> Result, String> { + let mut url = validate_custom_llm_connection(llm)?; + url.set_path(&format!("{}/models", url.path().trim_end_matches('/'))); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| "初始化模型列表请求失败".to_string())?; + let mut response = client + .get(url) + .bearer_auth(llm.api_key.trim()) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + "模型列表请求超时,请重试".to_string() + } else { + "无法连接模型端点,请检查 API 地址和网络".to_string() + } + })?; + if !response.status().is_success() { + return Err(format!( + "模型列表读取失败(HTTP {})", + response.status().as_u16() + )); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "读取模型列表响应失败或超时".to_string())? + { + if body.len() + chunk.len() > 1024 * 1024 { + return Err("模型列表响应超过 1 MiB 上限".to_string()); + } + body.extend_from_slice(&chunk); + } + #[derive(Deserialize)] + struct Model { + id: String, + } + #[derive(Deserialize)] + struct Models { + data: Vec, + } + let models: Models = serde_json::from_slice(&body) + .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + let ids = models + .data + .into_iter() + .map(|model| model.id) + .collect::>(); + let mut ids = normalize_custom_llm_model_ids(&ids)?; + ids.sort(); + Ok(ids) +} + pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { @@ -4086,6 +4287,17 @@ pub(crate) fn normalize_game_creator_app_config( lock_game_creator_app_config_to_official_route(&mut config); } config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?; + if config.llm.custom_enabled { + validate_custom_llm_connection(&config.llm)?; + config.llm.visible_models = normalize_custom_llm_model_ids(&config.llm.visible_models)?; + if config.llm.visible_models.is_empty() { + return Err("请至少勾选一个要显示的模型".to_string()); + } + if config.llm.api_kind != "openai_responses" { + return Err("自定义 LLM 需要支持 OpenAI Responses 协议".to_string()); + } + apply_custom_llm_model_selection(&mut config); + } config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?; @@ -4182,7 +4394,9 @@ pub(crate) fn normalize_game_creator_llm_patch_config( } pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool { - patch.api_key.is_none() + patch.custom_enabled.is_none() + && patch.visible_models.is_none() + && patch.api_key.is_none() && patch.base_url.is_none() && patch.model.is_none() && patch.api_kind.is_none() @@ -4342,6 +4556,188 @@ mod private_file_write_tests { } } +#[cfg(test)] +mod custom_llm_tests { + use super::*; + use std::io::{Read, Write}; + + fn custom_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + custom_enabled: true, + api_key: "custom-fixture-key".into(), + base_url: "https://provider.example/v1".into(), + model: "vendor/model.v1:latest".into(), + visible_models: vec!["vendor/model.v1:latest".into(), "second.model".into()], + ..GameCreatorLlmConfig::default() + } + } + + #[test] + fn custom_llm_defaults_closed_and_explicit_config_survives_scrub() { + assert!(!GameCreatorLlmConfig::default().custom_enabled); + let mut file: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": {"customEnabled": true, "apiKey": "fixture", "baseUrl": "https://custom.example/v1", "visibleModels": ["vendor/a.v1"]} + })).unwrap(); + assert!(!scrub_locked_game_creator_config_file(&mut file)); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_llm_config(&mut config.llm, file.llm.unwrap()); + assert!(config.llm.custom_enabled); + assert_eq!(config.llm.api_key, "fixture"); + assert_eq!(config.llm.visible_models, ["vendor/a.v1"]); + } + + #[test] + fn custom_llm_selection_is_allowlisted_and_removed_model_falls_back() { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + selected_model_id: "second.model".into(), + ..GameCreatorAppConfig::default() + }; + apply_custom_llm_model_selection(&mut config); + assert_eq!(config.llm.model, "second.model"); + config.llm.visible_models.pop(); + let normalized = normalize_game_creator_app_config(config).unwrap(); + assert_eq!(normalized.llm.model, "vendor/model.v1:latest"); + assert_eq!(normalized.selected_model_id, normalized.llm.model); + assert!(normalized.selected_model_is_default); + assert!(game_creator_codex_app_server_llm_route_error( + "codex_app_server", + &normalized.llm, + "llm" + ) + .is_none()); + } + + #[test] + fn custom_llm_missing_connection_or_models_is_rejected_without_official_fallback() { + for field in ["key", "models", "url"] { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + ..GameCreatorAppConfig::default() + }; + match field { + "key" => config.llm.api_key.clear(), + "models" => config.llm.visible_models.clear(), + _ => config.llm.base_url = "file:///private".into(), + } + assert!( + normalize_game_creator_app_config(config).is_err(), + "{field}" + ); + } + let mut config = custom_llm(); + config.api_key.clear(); + assert!(build_game_creator_platform_llm_config(&config, "llm").is_err()); + } + + #[test] + fn custom_llm_migration_uses_merged_overlay_switch() { + let root = tempfile::tempdir().unwrap(); + let primary = root.path().join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.path().join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + std::fs::write( + &primary, + r#"{"llm":{"customEnabled":false,"apiKey":"fixture"}}"#, + ) + .unwrap(); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":true}}"#).unwrap(); + assert!(custom_llm_enabled_at_config_path(&primary).unwrap()); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":false}}"#).unwrap(); + assert!(!custom_llm_enabled_at_config_path(&primary).unwrap()); + } + + fn model_server(status: &str, body: &str) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/v1", listener.local_addr().unwrap()); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + let handle = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buf = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let count = socket.read(&mut buf).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buf[..count]); + } + let _ = socket.write_all(response.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, handle) + } + + #[tokio::test] + async fn custom_llm_discovers_models_directly_with_custom_bearer_and_deduplicates() { + let (url, server) = model_server( + "200 OK", + r#"{"data":[{"id":"vendor/model.v1:latest"},{"id":"second.model"},{"id":"second.model"}]}"#, + ); + let mut llm = custom_llm(); + llm.base_url = url; + assert_eq!( + fetch_custom_llm_models(&llm).await.unwrap(), + ["second.model", "vendor/model.v1:latest"] + ); + let request = server.join().unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer custom-fixture-key")); + assert!(!request.contains("/api/llm")); + } + + #[tokio::test] + async fn custom_llm_discovery_reports_safe_errors_and_bounds_response() { + for (status, body, expected) in [ + ( + "401 Unauthorized", + "private-upstream-secret".to_string(), + "HTTP 401", + ), + ( + "302 Found", + "private-upstream-secret".to_string(), + "HTTP 302", + ), + ("200 OK", "not-json-private-secret".to_string(), "data[].id"), + ("200 OK", "x".repeat(1024 * 1024 + 1), "1 MiB"), + ] { + let (url, server) = model_server(status, &body); + let mut llm = custom_llm(); + llm.base_url = url; + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains(expected), "{error}"); + assert!(!error.contains("secret")); + server.join().unwrap(); + } + } + + #[tokio::test] + async fn custom_llm_discovery_times_out_when_response_body_stalls() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut llm = custom_llm(); + llm.base_url = format!("http://{}/v1", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + socket.read(&mut request).unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") + .unwrap(); + std::thread::sleep(std::time::Duration::from_secs(16)); + }); + let start = std::time::Instant::now(); + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains("超时"), "{error}"); + assert!(start.elapsed() < std::time::Duration::from_secs(16)); + server.join().unwrap(); + } +} + #[cfg(test)] mod private_path_elevation_policy_tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7b428e588..1270fb8bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1010,6 +1010,17 @@ struct GameCreatorDirectTurnUpdateEvent { status: String, activity: Option, accumulated_text: Option, + /// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。 + /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, + /// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。 + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, + /// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。 + /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 + #[serde(skip_serializing_if = "Option::is_none")] + stream_items: Option>, updated_at: u64, } @@ -1073,6 +1084,10 @@ struct GameCreatorAppConfigFile { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] + custom_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + visible_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] api_key: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1128,6 +1143,10 @@ struct GameCreatorAppConfig { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfig { + #[serde(default)] + custom_enabled: bool, + #[serde(default)] + visible_models: Vec, api_key: String, base_url: String, model: String, @@ -1644,6 +1663,8 @@ impl Default for GameCreatorAppConfig { impl Default for GameCreatorLlmConfig { fn default() -> Self { Self { + custom_enabled: false, + visible_models: Vec::new(), api_key: String::new(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), @@ -2198,6 +2219,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) enum GameCreatorGuiRunnerShutdownOutcome { NotRequested, Requested, + /// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。 + Retained, Failed(GameCreatorGuiRunnerShutdownFailure), } @@ -2235,7 +2258,8 @@ fn classify_game_creator_gui_runner_shutdown_error( GameCreatorGuiRunnerShutdownFailure::ProcessIdentity } else if error.contains("当前平台不支持") || error.contains("macOS 不提供") { GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported - } else if error.contains("实例锁") || error.contains("owner 锁") { + } else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁") + { GameCreatorGuiRunnerShutdownFailure::LockTimeout } else if error.contains("endpoint") { GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable @@ -2251,13 +2275,14 @@ fn resolve_game_creator_gui_runner_shutdown( shutdown: F, ) -> GameCreatorGuiRunnerShutdownOutcome where - F: FnOnce() -> Result<(), String>, + F: FnOnce() -> Result, { if !game_creator_gui_run_event_requests_runner_shutdown(event) { return GameCreatorGuiRunnerShutdownOutcome::NotRequested; } match shutdown() { - Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained, Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed( classify_game_creator_gui_runner_shutdown_error(&error), ), @@ -2277,11 +2302,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } } - match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { app_log!("agent.runner.gui_exit.shutdown_requested") } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) } @@ -2590,27 +2621,25 @@ fn main() { ) })?; setup_log.append("startup.runner.configure.complete"); - let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + hold_external_agent_runner_gui_participant_lock(&config_dir) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( - "startup.runner.owner-lock.failed details={details}" + "startup.runner.participant-lock.failed details={details}" )); }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("获取 GUI owner 锁失败:{error}"), + format!("建立 AGC 界面参与锁失败:{error}"), ) })?; - let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); - app.manage(gui_owner_lock); setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); set_direct_thread_manager_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( @@ -2665,6 +2694,8 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, hydrate_design_agent_session, reset_design_agent_session, get_design_agent_runtime_mode, @@ -2697,12 +2728,13 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, - read_platform_account_session_generation, + read_platform_account_session_state, install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, select_game_creator_model, + discover_game_creator_llm_models, upload_local_asset, register_local_asset, create_ui_design_resource, @@ -2764,6 +2796,8 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, + read_direct_tool_calls, + read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0e24b3b53..41fe89783 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -12,12 +11,41 @@ pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_ const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +/// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 +/// +/// `identity_generation` 只在登录主体、服务 origin 或登出状态变化时推进;同一身份的 +/// access token 轮换(长回合保活、401 续期、同账号重新登录)必须保持它不变。 +/// `revision` 只用于 native 写入顺序判定,防止迟到 install / clear 复活旧状态, +/// 不表达身份归属。 #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlatformSessionSnapshot { pub(crate) user_id: String, pub(crate) access_token: String, pub(crate) api_base_url: String, - pub(crate) generation: u64, + pub(crate) identity_generation: u64, + pub(crate) revision: u64, +} + +/// 冻结会话的身份判据。 +/// +/// 只包含登录主体、服务 origin 和身份代次,不包含 token 字节:同一身份的凭据轮换 +/// 不得让在途生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin +/// 变化必须让它失配。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionIdentity { + pub(crate) user_id: String, + pub(crate) api_base_url: String, + pub(crate) identity_generation: u64, +} + +impl PlatformSessionSnapshot { + pub(crate) fn identity(&self) -> PlatformSessionIdentity { + PlatformSessionIdentity { + user_id: self.user_id.clone(), + api_base_url: self.api_base_url.clone(), + identity_generation: self.identity_generation, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -231,26 +259,31 @@ pub(crate) fn load_platform_session_fixture_from_env_for_build( let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; let bytes = read_fixture_file(&fixture_path)?; let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 let snapshot = validated_platform_session_snapshot( &fixture.user_id, &fixture.access_token, &fixture.api_base_url, fixture.generation, + fixture.generation, )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at generation zero. Replacing the + // A fresh CLI/Runner normally starts at revision zero. Replacing the // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch generation rules. - current.generation = snapshot.generation; + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } #[derive(Default)] struct PlatformSessionState { - generation: u64, + revision: u64, + identity_generation: u64, snapshot: Option, } @@ -265,37 +298,60 @@ fn install_platform_session_in( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) { - if generation < current.generation { + if revision < current.revision { return; } - if generation == current.generation { + if revision == current.revision { if current.snapshot.as_ref().is_some_and(|snapshot| { snapshot.user_id == user_id && snapshot.access_token == access_token && snapshot.api_base_url == api_base_url + && snapshot.identity_generation == identity_generation }) { return; } - // Equal-generation retries may only repeat the exact committed snapshot. In - // particular, a late install cannot revive a generation that was cleared. + // 同一 revision 只允许逐字段重复已提交的会话。尤其地:迟到写入不能复活已清除的 + // 会话,也不能在同一个 revision 上偷偷换掉主体或 token。 return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.identity_generation == identity_generation + && (snapshot.user_id != user_id || snapshot.api_base_url != api_base_url) + }) { + // 同一个身份代次不允许更换登录主体或服务 origin:换号必须先推进身份代次, + // 否则旧账号的在途 operation 可能拿到新账号的凭据。 + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation, + identity_generation, + revision, }); } -fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { - if generation < current.generation { +fn clear_platform_session_in( + current: &mut PlatformSessionState, + identity_generation: u64, + revision: u64, +) { + if revision <= current.revision { return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } @@ -303,10 +359,16 @@ pub(crate) fn install_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -315,7 +377,8 @@ pub(crate) fn install_platform_session( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); Ok(()) } @@ -324,7 +387,8 @@ fn validated_platform_session_snapshot( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result { if editor_api_mode() == EditorApiMode::ExternalDeveloper { return Err("独立外部开发发行版不接受陶泥儿网站登录态".to_string()); @@ -342,7 +406,8 @@ fn validated_platform_session_snapshot( user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url, - generation, + identity_generation, + revision, }) } @@ -350,23 +415,43 @@ pub(crate) fn validate_platform_session_input( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ()) + validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + ) + .map(|_| ()) } pub(crate) fn replace_platform_session_for_gui_owner( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = snapshot.generation; + // 这条路径是 GUI authority epoch 的重定性入口:只有 durable claim 的 epoch + session + // revision 与登记完全一致时才会走到这里,新 epoch 可以替换旧进程留下的任意计数器。 + // 因此按调用方快照重定基准,让原生计数与渲染层认知严格一致;Runner 同 epoch 的幂等 + // 重挂仍走 install_platform_session_checked 的精确相等校验。写入的持续单调性由渲染层 + // reserve(max(本地 + 1, 原生下限 + 1))和 durable session revision 保证。 + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } @@ -375,10 +460,16 @@ pub(crate) fn install_platform_session_checked( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -387,12 +478,13 @@ pub(crate) fn install_platform_session_checked( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); if current.snapshot.as_ref() == Some(&snapshot) { Ok(()) } else { - Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string()) + Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) } } @@ -422,30 +514,38 @@ fn normalize_platform_api_base_url(value: &str) -> Result { Ok(value.to_string()) } -pub(crate) fn clear_platform_session(generation: u64) { +pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); + clear_platform_session_in(&mut current, identity_generation, revision); } -pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) { +pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = generation; + // 与 replace 同一口径:epoch 交接按调用方快照重定基准,避免原生计数与渲染层认知漂移。 + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } -pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> { +pub(crate) fn clear_platform_session_checked( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); - if current.generation == generation && current.snapshot.is_none() { + clear_platform_session_in(&mut current, identity_generation, revision); + if current.revision >= revision + && current.identity_generation >= identity_generation + && current.snapshot.is_none() + { Ok(()) } else { - Err("authentication-required: 平台登出 generation 已过期".to_string()) + Err("authentication-required: 平台登出写入已过期".to_string()) } } @@ -457,17 +557,42 @@ pub(crate) fn current_platform_session() -> Option { .clone() } -pub(crate) fn current_platform_session_generation() -> u64 { - platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .generation +/// native 写入顺序 revision。渲染层用它作为只增不减的下限,避免新 WebView 的本地计数 +/// 复位后写出比现存会话更旧的 install / clear。 +/// 原生写入下限,供渲染层reserve新的身份代次与 revision。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlatformSessionWriteState { + pub(crate) identity_generation: u64, + pub(crate) revision: u64, } -pub(crate) fn validate_platform_session_snapshot( +pub(crate) fn current_platform_session_write_state() -> PlatformSessionWriteState { + let current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + PlatformSessionWriteState { + identity_generation: current.identity_generation, + revision: current.revision, + } +} + +/// 冻结会话校验:只比较身份,不比较 token 字节。 +pub(crate) fn validate_frozen_platform_session( expected: &PlatformSessionSnapshot, ) -> Result<(), String> { - if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + validate_platform_session_identity(&expected.identity()) +} + +pub(crate) fn validate_platform_session_identity( + expected: &PlatformSessionIdentity, +) -> Result<(), String> { + let matches = current_platform_session() + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); + if matches { Ok(()) } else { Err( @@ -477,19 +602,11 @@ pub(crate) fn validate_platform_session_snapshot( } } -pub(crate) fn with_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +pub(crate) fn with_validated_platform_session_identity( + expected: &PlatformSessionIdentity, action: impl FnOnce() -> Result, ) -> Result { - let lease = acquire_validated_platform_session_fingerprint( - expected_user_id, - expected_api_base_url, - expected_generation, - expected_access_token_sha256, - )?; + let lease = acquire_platform_session_identity_lease(expected)?; let result = action(); drop(lease); result @@ -499,22 +616,20 @@ pub(crate) struct ValidatedPlatformSessionLease { _guard: std::sync::MutexGuard<'static, PlatformSessionState>, } -pub(crate) fn acquire_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +/// 取得身份租约:持锁期间换号 / 退出无法落地,调用方可以安全地用当前凭据完成一次 +/// 本地提交。凭据续期不改变身份,因此不会被这个租约挡住。 +pub(crate) fn acquire_platform_session_identity_lease( + expected: &PlatformSessionIdentity, ) -> Result { let current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let matches = current.snapshot.as_ref().is_some_and(|snapshot| { - snapshot.user_id == expected_user_id - && snapshot.api_base_url == expected_api_base_url - && snapshot.generation == expected_generation - && format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes())) - == expected_access_token_sha256 - }); + let matches = current + .snapshot + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); if !matches { return Err( "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" @@ -524,13 +639,6 @@ pub(crate) fn acquire_validated_platform_session_fingerprint( Ok(ValidatedPlatformSessionLease { _guard: current }) } -fn platform_session_snapshot_matches( - current: Option<&PlatformSessionSnapshot>, - expected: &PlatformSessionSnapshot, -) -> bool { - current == Some(expected) -} - pub(crate) fn platform_session_is_available() -> bool { current_platform_session().is_some() } @@ -581,12 +689,14 @@ pub(crate) fn install_test_platform_session( .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::mem::take(&mut *current); *current = PlatformSessionState { - generation: 1, + revision: 1, + identity_generation: 1, snapshot: Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation: 1, + identity_generation: 1, + revision: 1, }), }; drop(current); @@ -621,73 +731,42 @@ pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { mod tests { use super::*; + const TEST_ORIGIN: &str = "https://dev.genarrative.world"; + #[test] - fn cleared_generation_rejects_late_install_and_older_clear() { + fn cleared_revision_rejects_late_install_and_older_clear() { let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 1, 1); + clear_platform_session_in(&mut state, 2, 2); + install_platform_session_in(&mut state, "user-a", "late-token-a", TEST_ORIGIN, 1, 1); install_platform_session_in( &mut state, "user-a", - "token-a", - "https://dev.genarrative.world", - 1, - ); - clear_platform_session_in(&mut state, 2); - install_platform_session_in( - &mut state, - "user-a", - "late-token-a", - "https://dev.genarrative.world", - 1, - ); - install_platform_session_in( - &mut state, - "user-a", - "same-generation-token", - "https://dev.genarrative.world", + "same-revision-token", + TEST_ORIGIN, + 2, 2, ); assert!(state.snapshot.is_none()); - assert_eq!(state.generation, 2); + assert_eq!(state.revision, 2); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 3, - ); - clear_platform_session_in(&mut state, 2); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 3, 3); + clear_platform_session_in(&mut state, 2, 2); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-b") ); - assert_eq!(state.generation, 3); + assert_eq!(state.revision, 3); + assert_eq!(state.identity_generation, 3); } #[test] - fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + fn equal_revision_only_accepts_the_exact_idempotent_snapshot() { let mut state = PlatformSessionState::default(); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 4, - ); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-b", TEST_ORIGIN, 4, 4); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-a") @@ -702,18 +781,115 @@ mod tests { } #[test] - fn current_generation_preserves_the_floor_after_session_clear() { + fn same_identity_credential_refresh_keeps_identity_and_frozen_session() { + let _session = install_test_platform_session("refresh-user", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + let identity = frozen.identity(); + + install_platform_session("refresh-user", "token-b", TEST_ORIGIN, 1, 2) + .expect("refresh credential for the same identity"); + + assert_eq!( + current_platform_session().map(|session| session.access_token), + Some("token-b".to_string()) + ); + assert_eq!( + current_platform_session_write_state().identity_generation, + 1 + ); + validate_frozen_platform_session(&frozen) + .expect("same-identity token rotation must keep the frozen session valid"); + validate_platform_session_identity(&identity) + .expect("same-identity token rotation must keep the identity valid"); + } + + #[test] + fn identity_change_invalidates_frozen_session_and_needs_a_new_identity_generation() { + let _session = install_test_platform_session("identity-user-a", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + install_platform_session("identity-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("credential refresh for the same identity"); + + // 同身份代次不允许换主体:否则旧账号在途请求会拿到新账号凭据。 + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 1, 3) + .expect("conflicting subject at the same identity generation is ignored"); + assert_eq!( + current_platform_session().map(|session| session.user_id), + Some("identity-user-a".to_string()) + ); + validate_frozen_platform_session(&frozen) + .expect("ignored conflicting write must not disturb the frozen session"); + + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 2, 4) + .expect("account switch advances the identity generation"); + assert!(validate_frozen_platform_session(&frozen).is_err()); + assert!(current_platform_session().is_some()); + } + + #[test] + fn gui_owner_replacement_rebases_to_the_authority_and_only_subject_change_fences() { + let _session = clear_test_platform_session(); + replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5) + .expect("install gui owner A"); + let installed = current_platform_session().expect("gui owner A session"); + assert_eq!(installed.identity_generation, 5); + assert_eq!(installed.revision, 5); + + // 同一主体只换凭据(续期后 Runner 重挂走的就是这条 replace 路径):身份代次保持、 + // 写入 revision 前进,在途 operation 的冻结会话仍然有效。 + replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6) + .expect("refresh gui owner A credential"); + let refreshed = current_platform_session().expect("gui owner A refreshed session"); + assert_eq!(refreshed.identity_generation, 5); + assert_eq!(refreshed.revision, 6); + validate_frozen_platform_session(&installed) + .expect("same-subject credential replacement keeps the frozen session valid"); + + // 换主体必须推进身份代次,旧身份的在途 operation 失败关闭。 + replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 6, 7) + .expect("switch gui owner"); + let switched = current_platform_session().expect("gui owner B session"); + assert_eq!(switched.user_id, "gui-owner-b"); + assert_eq!(switched.identity_generation, 6); + assert!(validate_frozen_platform_session(&installed).is_err()); + assert!(validate_frozen_platform_session(&refreshed).is_err()); + + // epoch 交接后的清除同样按调用方快照重定基准,让原生计数与渲染层认知一致。 + clear_platform_session_for_gui_owner(7, 8); + let cleared = current_platform_session_write_state(); + assert_eq!(cleared.revision, 8); + assert_eq!(cleared.identity_generation, 7); + assert!(current_platform_session().is_none()); + } + + #[test] + fn older_identity_generation_cannot_restore_a_replaced_subject() { + let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 5); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 6, 6); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 7); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + } + + #[test] + fn current_revision_preserves_the_floor_after_session_clear() { let _session = clear_test_platform_session(); install_platform_session( - "generation-floor-user", - "generation-floor-token", - "https://dev.genarrative.world", + "revision-floor-user", + "revision-floor-token", + TEST_ORIGIN, + 41, 41, ) - .expect("install session generation floor"); - clear_platform_session(42); + .expect("install session revision floor"); + clear_platform_session(42, 42); - assert_eq!(current_platform_session_generation(), 42); + let state = current_platform_session_write_state(); + assert_eq!(state.revision, 42); + assert_eq!(state.identity_generation, 42); assert!(current_platform_session().is_none()); } @@ -752,64 +928,40 @@ mod tests { } #[test] - fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { - let expected = PlatformSessionSnapshot { - user_id: "user-a".to_string(), - access_token: "token-a".to_string(), - api_base_url: "https://dev.genarrative.world".to_string(), - generation: 4, - }; - assert!(platform_session_snapshot_matches( - Some(&expected), - &expected - )); + fn frozen_platform_session_rejects_logout_and_account_switch_but_allows_token_rotation() { + let _session = install_test_platform_session("frozen-user-a", "token-a", TEST_ORIGIN); + let identity = current_platform_session() + .expect("frozen platform session") + .identity(); + validate_platform_session_identity(&identity).expect("matching identity is valid"); - for current in [ - None, - Some(PlatformSessionSnapshot { - user_id: "user-b".to_string(), - ..expected.clone() - }), - Some(PlatformSessionSnapshot { - access_token: "token-b".to_string(), - generation: 5, - ..expected.clone() - }), - ] { - assert!(!platform_session_snapshot_matches( - current.as_ref(), - &expected - )); - } + install_platform_session("frozen-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("same-identity credential rotation"); + validate_platform_session_identity(&identity) + .expect("token rotation must not invalidate the frozen identity"); + + install_platform_session("frozen-user-b", "token-c", TEST_ORIGIN, 2, 3) + .expect("account switch"); + assert!(validate_platform_session_identity(&identity).is_err()); + + clear_platform_session(3, 4); + assert!(validate_platform_session_identity(&identity).is_err()); } #[test] fn validated_session_lease_linearizes_local_commit_with_account_switch() { - let _session = install_test_platform_session( - "lease-user-a", - "lease-token-a", - "https://dev.genarrative.world", - ); - let expected = current_platform_session().expect("current lease session"); - let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes())); - let lease = acquire_validated_platform_session_fingerprint( - &expected.user_id, - &expected.api_base_url, - expected.generation, - &token_sha256, - ) - .expect("acquire validated session lease"); + let _session = install_test_platform_session("lease-user-a", "lease-token-a", TEST_ORIGIN); + let expected = current_platform_session() + .expect("current lease session") + .identity(); + let lease = acquire_platform_session_identity_lease(&expected) + .expect("acquire validated session lease"); let (started_sender, started_receiver) = std::sync::mpsc::channel(); let (finished_sender, finished_receiver) = std::sync::mpsc::channel(); let switcher = std::thread::spawn(move || { started_sender.send(()).expect("signal account switch"); - install_platform_session( - "lease-user-b", - "lease-token-b", - "https://dev.genarrative.world", - 2, - ) - .expect("switch account after lease release"); + install_platform_session("lease-user-b", "lease-token-b", TEST_ORIGIN, 2, 2) + .expect("switch account after lease release"); finished_sender.send(()).expect("signal switched account"); }); started_receiver diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs index 9bad4a007..993c1c378 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation/tests.rs @@ -272,7 +272,7 @@ fn project_history_skips_direct_project_rows_but_keeps_other_broken_rows_failing } #[test] -fn mixed_project_history_rows_stay_readable_from_both_sides() { +fn mixed_project_history_rows_read_by_generic_chain_but_fail_closed_for_direct_project() { let root = unique_conversation_test_root(); init_local_game_project_at(&root, "project-1", "写侧统一测试").expect("init project"); write_project_history(&root, &[DIRECT_PROJECT_ROW]); @@ -309,19 +309,12 @@ fn mixed_project_history_rows_stay_readable_from_both_sides() { vec!["模式切换后由通用写入器补写的回复", "带 id 的旧格式回复"] ); - // 反向:DirectProject 链把同一份文件里的两种行都读出来,混合文件不构成毒化。 - let direct_items = crate::agent::read_direct_project_history_items_at(&root) - .expect("DirectProject must keep reading the mixed history"); - assert_eq!( - direct_items - .iter() - .map(|item| item["content"][0]["text"].as_str().unwrap_or_default()) - .collect::>(), - vec![ - "再加一个按钮", - "模式切换后由通用写入器补写的回复", - "带 id 的旧格式回复", - ] + // 反向:DirectProject 链只接受 response_item 信封,混合文件里的 legacy 行让它失败关闭。 + let error = crate::agent::read_direct_project_history_items_at(&root) + .expect_err("DirectProject must fail closed on legacy rows"); + assert!( + error.starts_with("DirectProject 历史记录类型无效"), + "{error}" ); std::fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 430fd6e9a..36025909e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> { } /// Call before and after every awaited remote action and immediately before installing a - /// binding. Developer-key mode has no process-global account generation to compare. + /// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让 + /// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然 + /// 失败关闭。Developer-key 模式没有进程级身份代次可比对。 pub(crate) fn validate_frozen_session(&self) -> Result<(), String> { validate_external_editor_binding_access_shape(self)?; if let Some(session) = self.frozen_platform_session { - validate_platform_session_snapshot(session)?; + validate_frozen_platform_session(session)?; } Ok(()) } @@ -1152,7 +1154,8 @@ mod tests { user_id: user_id.to_string(), access_token: token.to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation, + identity_generation: generation, + revision: generation, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b1f292976..77183a884 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session( let Some(platform_session) = platform_session else { return action(); }; - let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes()); - with_validated_platform_session_fingerprint( - &platform_session.user_id, - &platform_session.api_base_url, - platform_session.generation, - &access_token_sha256, - action, - ) + with_validated_platform_session_identity(&platform_session.identity(), action) } fn commit_resource_edit_asset_with_frozen_platform_session( @@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( let current_platform_session = current_platform_session(); let _platform_session_lease = current_platform_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &sha256_hex(session.access_token.as_bytes()), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let entries = match fs::read_dir(&directory) { @@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( Ok(()) }; if let Some(session) = platform_session { - let access_token_sha256 = sha256_hex(session.access_token.as_bytes()); - crate::platform_session::with_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &access_token_sha256, + crate::platform_session::with_validated_platform_session_identity( + &session.identity(), archive, )?; } else { @@ -5500,7 +5482,8 @@ mod tests { user_id: "gui-owner".to_string(), access_token: "gui-token".to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation: 7, + identity_generation: 7, + revision: 7, }; let developer_credentials = ( "https://dev.genarrative.world".to_string(), @@ -5911,6 +5894,7 @@ mod tests { "source-binding-token-b", api_base_url, *generation, + *generation, ) .expect("switch account after source registration"); } @@ -6925,7 +6909,7 @@ mod tests { listener, upload_url, false, - Some((base_url.clone(), frozen_session.generation + 1)), + Some((base_url.clone(), frozen_session.identity_generation + 1)), done_receiver, ); let client = reqwest::Client::new(); @@ -6952,7 +6936,8 @@ mod tests { "source-binding-owner-a", "source-binding-token-a", &base_url, - frozen_session.generation + 2, + frozen_session.identity_generation + 2, + frozen_session.identity_generation + 2, ) .expect("switch back to source binding owner A"); let resumed_session = current_platform_session().expect("resumed source binding owner A"); @@ -7018,7 +7003,7 @@ mod tests { install_test_platform_session("submission-owner-a", "submission-token-a", &base_url); let frozen_session = current_platform_session().expect("frozen owner A session"); let switch_base_url = base_url.clone(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let server = std::thread::spawn(move || { let mut stream = accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); @@ -7032,6 +7017,7 @@ mod tests { "submission-token-b", &switch_base_url, switch_generation, + switch_generation, ) .expect("switch to owner B before returning accepted response"); write_json( @@ -7447,8 +7433,14 @@ mod tests { ledger.access_scheme = None; initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a)) .expect("write resource ledger for owner A"); - replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2) - .expect("switch global resource session to owner B"); + replace_platform_session_for_gui_owner( + "resource-owner-b", + "resource-token-b", + base_url, + 2, + 2, + ) + .expect("switch global resource session to owner B"); let error = prepare_resource_edit_service_identity( root, @@ -7509,7 +7501,8 @@ mod tests { user_id: "resource-identity-owner-b".to_string(), access_token: "resource-identity-token-b".to_string(), api_base_url: owner_a.api_base_url.clone(), - generation: owner_a.generation + 1, + identity_generation: owner_a.identity_generation + 1, + revision: owner_a.revision + 1, }; let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); @@ -7535,7 +7528,8 @@ mod tests { &owner_b.user_id, &owner_b.access_token, &owner_b.api_base_url, - owner_b.generation, + owner_b.identity_generation, + owner_b.revision, ) .expect("switch to resource non-owner B"); @@ -7639,7 +7633,8 @@ mod tests { "resource-lease-owner-b", "resource-lease-token-b", api_base_url, - frozen_a.generation + 1, + frozen_a.identity_generation + 1, + frozen_a.revision + 1, ) .expect("switch resource lease owner"); switched_sender.send(()).expect("signal resource switch"); @@ -8259,7 +8254,8 @@ mod tests { "archive-owner-b", "archive-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to owner B"); let error = archive_failed_local_project_resource_edit_at( @@ -8388,7 +8384,8 @@ mod tests { "pending-owner-b", "pending-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to pending owner B"); let owner_b = current_platform_session().expect("pending owner B session"); @@ -9347,7 +9344,7 @@ mod tests { let (attempted_sender, attempted_receiver) = mpsc::channel(); let (completed_sender, completed_receiver) = mpsc::channel(); let switch_api_base_url = api_base_url.to_string(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let switch_thread = std::thread::spawn(move || { begin_switch_receiver .recv() @@ -9360,6 +9357,7 @@ mod tests { "commit-token-b", &switch_api_base_url, switch_generation, + switch_generation, ) .expect("switch to commit owner B"); completed_sender diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index be22c4d96..71fd5bf4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,21 +12,20 @@ pub(crate) use client::{ clear_external_agent_runner_platform_session, compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, - ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, + ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock, + install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, - shutdown_external_agent_runner_if_idle, steer_external_agent_runner, - wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, + steer_external_agent_runner, wake_external_agent_runner_pending, + wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; -pub(crate) use endpoint::{ - acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled, - external_agent_runner_is_server_process, -}; +pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; pub(crate) use server::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 6c6f9fdbf..e27a07792 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { struct ExternalAgentRunnerGuiOwnerRegistration { generation: u64, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, config_dir: PathBuf, params: ExternalAgentRunnerRequestParams, attached_boot_id: Option, } +/// claim 解析模式。 +/// +/// `Adopt` 用于窗口启动:沿用现有 durable claim,只有 claim 缺失或不可读时才发布。 +/// `Publish` 用于本窗口改动了平台登录态:发布新 epoch,成为新的登录态权威。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerGuiOwnerClaimMode { + Adopt, + Publish, +} + static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< + Mutex>, +> = OnceLock::new(); + fn external_agent_runner_gui_owner_attachment_state( ) -> &'static Mutex { EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +fn external_agent_runner_gui_participant_lock( +) -> &'static Mutex> { + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) +} + +/// 取得并持有本窗口的界面参与锁,直到窗口退出。 +/// +/// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 +/// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +pub(crate) fn hold_external_agent_runner_gui_participant_lock( + config_dir: &Path, +) -> Result<(), String> { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)?; + *lock_unpoisoned(external_agent_runner_gui_participant_lock()) = Some(lock); + Ok(()) +} + +fn release_external_agent_runner_gui_participant_lock() { + drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); +} + +/// 登记本窗口的 owner claim 与 attach 参数。 +/// +/// 这里不做 claim 文件 IO:claim 由调用方按 `claim_mode` 解析后写进 `params`, +/// 因此该函数可以在没有真实 AppData 的单元测试里使用。 pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, mut params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; - params.gui_owner_session_revision = Some(generation); - if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() { - write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?; + if params.gui_owner_session_revision.is_none() { + params.gui_owner_session_revision = Some(generation); } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, + claim_mode, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, @@ -61,6 +102,29 @@ pub(super) fn register_external_agent_runner_gui_owner_attachment( Ok(()) } +pub(super) fn reserve_external_agent_runner_gui_owner_claim_revision( + state: &Mutex, +) -> u64 { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + state.generation +} + +pub(super) fn resolve_external_agent_runner_gui_owner_claim( + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, + session_revision: u64, +) -> Result { + match claim_mode { + ExternalAgentRunnerGuiOwnerClaimMode::Adopt => { + adopt_or_publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + ExternalAgentRunnerGuiOwnerClaimMode::Publish => { + publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + } +} + pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( state: &Mutex, config_dir: &Path, @@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with Result<(), String> where - F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, + F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, { - let Some((generation, params)) = ({ - let state = lock_unpoisoned(state); - state.registration.as_ref().and_then(|registration| { - (registration.config_dir == config_dir - && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) - .then(|| (registration.generation, registration.params.clone())) - }) - }) else { - return Ok(()); - }; + const ATTACH_CLAIM_RETRY_LIMIT: usize = 3; + let mut last_claim_error = None; + for attempt in 0..ATTACH_CLAIM_RETRY_LIMIT { + let Some((generation, params, claim_mode)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| { + ( + registration.generation, + registration.params.clone(), + registration.claim_mode, + ) + }) + }) + }) else { + return Ok(()); + }; - attach(endpoint, params)?; - - let mut state = lock_unpoisoned(state); - if let Some(registration) = state.registration.as_mut() { - if registration.generation == generation && registration.config_dir == config_dir { - registration.attached_boot_id = Some(endpoint.boot_id.clone()); + match attach(endpoint, params) { + Ok(()) => { + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation + && registration.config_dir == config_dir + { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + return Ok(()); + } + Err(error) if attempt + 1 < ATTACH_CLAIM_RETRY_LIMIT && error.contains("claim") => { + // 另一个窗口在本次 attach 前后发布了新 claim:按最新 claim 重新解析后重试。 + last_claim_error = Some(error); + refresh_registered_external_agent_runner_gui_owner_claim( + state, config_dir, claim_mode, + )?; + } + Err(error) => return Err(error), } } + Err(last_claim_error.unwrap_or_else(|| "Agent Runner attach 重试后仍然失败".to_string())) +} + +fn refresh_registered_external_agent_runner_gui_owner_claim( + state: &Mutex, + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, +) -> Result<(), String> { + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision(state); + let claim = + resolve_external_agent_runner_gui_owner_claim(config_dir, claim_mode, session_revision)?; + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return Ok(()); + }; + if registration.config_dir != config_dir { + return Ok(()); + } + registration.claim_mode = claim_mode; + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + registration.attached_boot_id = None; Ok(()) } @@ -963,7 +1072,6 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, - gui_owner_epoch: &str, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); @@ -971,13 +1079,25 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; let platform_session = crate::current_platform_session(); + // 启动阶段先采纳现有 durable claim:第二个及后续窗口与第一个窗口共享同一 + // epoch,因此不会被判定为抢走登录态权威;claim 缺失或不可读时才发布新 claim。 + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision( + external_agent_runner_gui_owner_attachment_state(), + ); + let claim = resolve_external_agent_runner_gui_owner_claim( + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + session_revision, + )?; register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), - gui_owner_epoch: Some(gui_owner_epoch.to_string()), + gui_owner_epoch: Some(claim.owner_epoch), + gui_owner_session_revision: Some(claim.session_revision), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), @@ -987,7 +1107,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner( platform_api_base_url: platform_session .as_ref() .map(|session| session.api_base_url.clone()), - platform_auth_generation: platform_session.map(|session| session.generation), + platform_auth_generation: platform_session + .as_ref() + .map(|session| session.identity_generation), + platform_auth_revision: platform_session.map(|session| session.revision), ..ExternalAgentRunnerRequestParams::default() }, )?; @@ -998,7 +1121,8 @@ pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; @@ -1008,7 +1132,8 @@ pub(crate) fn install_external_agent_runner_platform_session( remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1017,7 +1142,8 @@ pub(crate) fn install_external_agent_runner_platform_session( &config_dir, &endpoint, Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) }) }, @@ -1025,7 +1151,10 @@ pub(crate) fn install_external_agent_runner_platform_session( ) } -pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { +pub(crate) fn clear_external_agent_runner_platform_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(()); }; @@ -1035,7 +1164,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), None, - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1044,7 +1174,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R &config_dir, &endpoint, None, - generation, + identity_generation, + revision, ) }) }, @@ -1057,7 +1188,8 @@ fn validate_external_agent_runner_platform_session_attachment( config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let state = lock_unpoisoned(state); let registration = state.registration.as_ref().ok_or_else(|| { @@ -1070,7 +1202,8 @@ fn validate_external_agent_runner_platform_session_attachment( || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_session_revision != Some(registration.generation) - || registration.params.platform_auth_generation != Some(generation) + || registration.params.platform_auth_generation != Some(identity_generation) + || registration.params.platform_auth_revision != Some(revision) || registration.params.platform_user_id.as_deref() != expected_user_id || registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_api_base_url.as_deref() != expected_api_base_url @@ -1101,34 +1234,41 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with( pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { remember_external_agent_runner_platform_session_with( state, session, - generation, - write_external_agent_runner_gui_owner_claim_atomic, + identity_generation, + revision, + publish_external_agent_runner_gui_owner_claim, ) } pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, - write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, + identity_generation: u64, + revision: u64, + publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { return Ok(()); }; - let current_generation = registration.params.platform_auth_generation.unwrap_or(0); - if generation < current_generation { + // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision + // 但保持 identity generation 不变。 + let current_revision = registration.params.platform_auth_revision.unwrap_or(0); + if revision < current_revision { return Ok(()); } - if generation == current_generation { + if revision == current_revision { match session { Some((user_id, access_token, api_base_url)) if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_auth_generation + == Some(identity_generation) && registration.params.platform_access_token.as_deref() == Some(access_token) && registration.params.platform_api_base_url.as_deref() @@ -1147,29 +1287,35 @@ pub(super) fn remember_external_agent_runner_platform_session_with( } state.generation = state.generation.wrapping_add(1); let registration_generation = state.generation; - let claim = state.registration.as_ref().and_then(|registration| { - registration - .params - .gui_owner_epoch - .as_deref() - .map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string())) - }); - if let Some((config_dir, owner_epoch)) = claim { - write_claim(&config_dir, &owner_epoch, registration_generation)?; - } + // 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。 + // 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。 + // 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身) + // 不写任何 claim 文件。 + let published_claim = state + .registration + .as_ref() + .filter(|registration| registration.params.gui_owner_epoch.is_some()) + .map(|registration| registration.config_dir.clone()) + .map(|config_dir| publish_claim(&config_dir, registration_generation)) + .transpose()?; let registration = state .registration .as_mut() .expect("checked GUI owner registration must remain present while locked"); registration.generation = registration_generation; + registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish; registration.attached_boot_id = None; registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); registration.params.platform_access_token = session.map(|(_, access_token, _)| access_token.to_string()); registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); - registration.params.platform_auth_generation = Some(generation); - registration.params.gui_owner_session_revision = Some(registration_generation); + registration.params.platform_auth_generation = Some(identity_generation); + registration.params.platform_auth_revision = Some(revision); + if let Some(claim) = published_claim { + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + } Ok(()) } @@ -1263,6 +1409,25 @@ pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result Result { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(true); + }; + release_external_agent_runner_gui_participant_lock(); + if external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))? { + return Ok(false); + } + shutdown_external_agent_runner_at(&config_dir)?; + Ok(true) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -1301,34 +1466,12 @@ pub(super) fn ensure_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { - match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { - ExternalAgentRunnerReuseDecision::Reuse => { - if ping_external_agent_runner(&endpoint).is_ok() { - attach_registered_external_agent_runner_gui_owner_if_needed( - config_dir, &endpoint, - )?; - return Ok(endpoint); - } - } - ExternalAgentRunnerReuseDecision::Retire => { - let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( - &endpoint, - endpoint.protocol_version, - random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, - "runner.ping", - ExternalAgentRunnerRequestParams::default(), - ); - if incompatible_ping.is_ok() { - retire_incompatible_external_agent_runner( - &endpoint_path, - &endpoint, - EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT - .load(std::sync::atomic::Ordering::Acquire), - )?; - } - } - } + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); } let mut launched = launch_external_agent_runner(config_dir)?; match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { @@ -1345,11 +1488,57 @@ pub(super) fn ensure_external_agent_runner( Err(error) => { let _ = launched.child.kill(); let _ = launched.child.wait(); + // 同一 AppData 的另一个窗口可能在这段时间里已经启动了 Runner: + // 实例锁竞争失败不能立刻报成启动失败,先按最新 endpoint 复用一次。 + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); + } Err(error) } } } +fn reuse_or_retire_external_agent_runner_endpoint( + config_dir: &Path, + endpoint_path: &Path, + executable_fingerprint: &str, +) -> Result, String> { + if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { + match external_agent_runner_endpoint_reuse_decision(&endpoint, executable_fingerprint) { + ExternalAgentRunnerReuseDecision::Reuse => { + if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; + return Ok(Some(endpoint)); + } + } + ExternalAgentRunnerReuseDecision::Retire => { + let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + ); + if incompatible_ping.is_ok() { + retire_incompatible_external_agent_runner( + endpoint_path, + &endpoint, + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .load(std::sync::atomic::Ordering::Acquire), + )?; + } + } + } + } + Ok(None) +} + pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; @@ -1472,6 +1661,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( platform_access_token: None, platform_api_base_url: None, platform_auth_generation: None, + platform_auth_revision: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index b254c88f9..3186fd863 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,6 +1,6 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{ - install_game_creator_manifest_invalidation_event_sink, + register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; use serde::Deserialize; @@ -116,9 +116,9 @@ fn apply_external_agent_runner_gui_owner_attachment( .gui_owner_session_revision .ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?; let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?; if durable_claim.owner_epoch != requested_epoch @@ -127,43 +127,59 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 已过期".to_string()); } let requested_claim = (requested_epoch.to_string(), requested_revision); - let replace_claim = active_claim.as_ref() != Some(&requested_claim); + // 同一 AppData 的多个窗口共享同一个 epoch:只有 epoch 变化(本窗口发布了新的 + // 登录态权威)才允许强制替换会话。同一 epoch 内的重复 attach 只做单调校验, + // 因此后开窗口的“无登录态 attach”不会清空已有会话。 + let epoch_changed = match active_claim.as_ref() { + Some(active) => active.0 != requested_epoch, + None => true, + }; + let replace_claim = epoch_changed; let result = match ( params.platform_user_id.as_deref(), params.platform_access_token.as_deref(), params.platform_api_base_url.as_deref(), params.platform_auth_generation, + params.platform_auth_revision, ) { - (Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => { + ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(identity_generation), + Some(revision), + ) => { if replace_claim { crate::replace_platform_session_for_gui_owner( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } else { crate::install_platform_session_checked( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } } - (None, None, None, Some(generation)) => { + (None, None, None, Some(identity_generation), Some(revision)) => { if replace_claim { - crate::clear_platform_session_for_gui_owner(generation); + crate::clear_platform_session_for_gui_owner(identity_generation, revision); Ok(()) } else { - crate::clear_platform_session_checked(generation) + crate::clear_platform_session_checked(identity_generation, revision) } } - (None, None, None, None) if replace_claim => { - crate::clear_platform_session_for_gui_owner(0); + (None, None, None, None, None) if epoch_changed => { + crate::clear_platform_session_for_gui_owner(0, 0); Ok(()) } - (None, None, None, None) => Ok(()), + (None, None, None, None, None) => Ok(()), _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), }; result?; @@ -171,7 +187,7 @@ fn apply_external_agent_runner_gui_owner_attachment( Ok(claim) => claim, Err(error) => { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err(format!( "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" )); @@ -181,11 +197,11 @@ fn apply_external_agent_runner_gui_owner_attachment( || committed_claim.session_revision != requested_revision { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { - install_game_creator_manifest_invalidation_event_sink(event_sink); + register_game_creator_manifest_invalidation_event_sink(event_sink); } *active_claim = Some(requested_claim); Ok(()) @@ -195,9 +211,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( state: &ExternalAgentRunnerServerState, ) -> Result<(), String> { let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir); let matches = durable_claim.as_ref().is_ok_and(|claim| { @@ -207,7 +223,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( return Ok(()); } *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); match durable_claim { Ok(_) => Err( "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" @@ -768,7 +784,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( } } "runner.attach_gui_owner" => { - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let event_sink = request .params @@ -810,7 +826,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( Ok(false) => ExternalAgentRunnerResponse::failure( &request.request_id, "gui-owner-missing", - "Agent Runner 未检测到活跃 GUI owner 锁", + "Agent Runner 未检测到活跃的 AGC 界面进程", ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index bfbd56241..de3ade0ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -6,7 +6,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5); +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL: Duration = + Duration::from_millis(40); pub(super) fn external_agent_runner_config_dir_lock() -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_CONFIG_DIR.get_or_init(|| Mutex::new(None)) @@ -305,8 +310,8 @@ pub(super) fn external_agent_runner_lock_path(config_dir: &Path) -> PathBuf { config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME) } -pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf { - config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME) +pub(super) fn external_agent_runner_gui_participant_lock_path(config_dir: &Path) -> PathBuf { + config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME) } pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf { @@ -341,8 +346,9 @@ pub(super) fn read_external_agent_runner_gui_owner_claim( Ok(claim) } -pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result { - match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? { +/// 独占探测:返回 `true` 表示仍有界面进程持有该参与锁。 +pub(super) fn external_agent_runner_lock_is_held(path: &Path) -> Result { + match try_open_external_agent_runner_lock(path, "AGC 界面参与锁")? { Some(lock) => { drop(lock); Ok(false) @@ -743,10 +749,21 @@ pub(super) fn read_current_external_agent_runner_endpoint( }) } +/// 锁文件的两种打开方式。 +/// +/// `Exclusive` 是权威探测:能否独占取得句柄决定“还有没有存活持有者”。 +/// `Shared` 是参与者持有:同一 AppData 的多个界面进程可以同时持有。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerLockMode { + Exclusive, + Shared, +} + #[cfg(unix)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::fd::AsRawFd; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -809,14 +826,30 @@ pub(super) fn try_open_external_agent_runner_lock( path.display() )); } + let flock_operation = match mode { + ExternalAgentRunnerLockMode::Exclusive => libc::LOCK_EX | libc::LOCK_NB, + ExternalAgentRunnerLockMode::Shared => libc::LOCK_SH | libc::LOCK_NB, + }; // SAFETY: flock only observes the valid fd owned by `file`; `file` remains alive on success. - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + let result = unsafe { libc::flock(file.as_raw_fd(), flock_operation) }; if result == 0 { return Ok(Some(file)); } let error = io::Error::last_os_error(); if error.kind() == io::ErrorKind::WouldBlock { - Ok(None) + return match mode { + ExternalAgentRunnerLockMode::Exclusive => Ok(None), + ExternalAgentRunnerLockMode::Shared => Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )), + }; + } + if mode == ExternalAgentRunnerLockMode::Shared { + Err(format!( + "以共享方式获取 {label} 失败:{}: {error}", + path.display() + )) } else { Err(format!( "获取 {label} 系统锁失败:{}: {error}", @@ -825,39 +858,58 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(unix)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + #[cfg(windows)] pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { matches!(error.raw_os_error(), Some(32 | 33)) } #[cfg(windows)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::windows::fs::OpenOptionsExt; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; let parent = path .parent() .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; let runner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); - let gui_owner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME); - if path != runner_lock_path && path != gui_owner_lock_path { + let gui_participant_lock_path = + private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME); + if path != runner_lock_path && path != gui_participant_lock_path { return Err(format!( "{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}", runner_lock_path.display(), - gui_owner_lock_path.display() + gui_participant_lock_path.display() )); } + let share_mode = match mode { + ExternalAgentRunnerLockMode::Exclusive => 0, + ExternalAgentRunnerLockMode::Shared => { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } + }; match OpenOptions::new() .create(true) .read(true) .write(true) - .share_mode(0) + .share_mode(share_mode) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) { @@ -875,16 +927,30 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - // share_mode(0) gives this process an exclusive handle. At this point the fixed - // lock path is known to be a stale, single-link, non-reparse regular file inside - // the current TokenUser's private AppData. Repairing its owner is therefore safe - // and is required when Windows creates it with TokenOwner=Administrators. + // The fixed lock path is known to be a single-link, non-reparse regular file + // inside the current TokenUser's private AppData. Repairing its owner is + // therefore safe and is required when Windows creates it with + // TokenOwner=Administrators. crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; validate_windows_regular_file_handle(&file, label)?; crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), + Err(error) + if mode == ExternalAgentRunnerLockMode::Exclusive + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Ok(None) + } + Err(error) + if mode == ExternalAgentRunnerLockMode::Shared + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )) + } Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() @@ -892,12 +958,29 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn open_external_agent_runner_lock_file( + path: &Path, + label: &str, + _mode: ExternalAgentRunnerLockMode, +) -> Result, String> { + Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) +} + #[cfg(not(any(unix, windows)))] pub(super) fn try_open_external_agent_runner_lock( path: &Path, label: &str, ) -> Result, String> { - Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) } pub(super) fn acquire_external_agent_runner_instance_lock( @@ -929,40 +1012,90 @@ pub(super) fn acquire_external_agent_runner_instance_lock( Ok(ExternalAgentRunnerInstanceLock { _file: file }) } -pub(crate) fn acquire_external_agent_runner_gui_owner_lock( +/// 取得本窗口在该 AppData 下的界面参与锁。 +/// +/// 参与锁以共享句柄打开:同一 AppData 可以同时持有任意数量的界面窗口。 +/// Runner 侧用同文件的独占探测判断“是否仍有界面进程存活”,探测窗口很短, +/// 所以这里遇到瞬时冲突时按 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL` +/// 重试,直到 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT` 截止。 +pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, -) -> Result { - let path = external_agent_runner_gui_owner_lock_path(config_dir); - let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")? - else { - return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string()); - }; - let owner_epoch = uuid::Uuid::new_v4().to_string(); - let acquired_at = unix_millis(); +) -> Result { + let path = external_agent_runner_gui_participant_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; + let mut last_error = "AGC 界面参与锁未知失败".to_string(); + loop { + match open_external_agent_runner_lock_file( + &path, + "AGC 界面参与锁", + ExternalAgentRunnerLockMode::Shared, + ) { + Ok(Some(mut file)) => { + write_external_agent_runner_gui_participant_diagnostic(&mut file, &path)?; + return Ok(ExternalAgentRunnerGuiParticipantLock { _file: file }); + } + Ok(None) => { + last_error = format!("AGC 界面参与锁无法以共享方式取得:{}", path.display()); + } + Err(error) => last_error = error, + } + if Instant::now() >= deadline { + return Err(format!("取得 AGC 界面参与锁失败:{last_error}")); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL); + } +} + +/// 参与锁诊断内容只由首个窗口写入,后续窗口不覆写,避免并发写坏 JSON。 +fn write_external_agent_runner_gui_participant_diagnostic( + file: &mut File, + path: &Path, +) -> Result<(), String> { + let existing_len = file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + if existing_len > 0 { + return Ok(()); + } let diagnostic = serde_json::to_vec(&json!({ "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, "pid": std::process::id(), - "ownerEpoch": owner_epoch, - "acquiredAt": acquired_at, + "instanceId": uuid::Uuid::new_v4().to_string(), + "acquiredAt": unix_millis(), })) - .map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?; + .map_err(|error| format!("生成 AGC 界面参与锁信息失败:{error}"))?; file.set_len(0) .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) .and_then(|_| file.write_all(&diagnostic)) .and_then(|_| file.sync_data()) - .map_err(|error| { - format!( - "写入 Agent Runner GUI owner 锁信息失败:{}: {error}", - path.display() - ) - })?; - write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?; - Ok(ExternalAgentRunnerGuiOwnerLock { - _file: file, + .map_err(|error| format!("写入 AGC 界面参与锁信息失败:{}: {error}", path.display())) +} + +/// 发布新的 durable claim:新 epoch + 本次会话 revision。 +/// +/// 发布是“谁改动登录态谁成为新 epoch 权威”的实现;并发发布以最后一次 +/// 成功写入为准,落败窗口按最新 claim 重试。 +pub(crate) fn publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + let owner_epoch = uuid::Uuid::new_v4().to_string(); + write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, session_revision)?; + Ok(ExternalAgentRunnerGuiOwnerClaim { owner_epoch, + session_revision, }) } +/// 采纳现有 durable claim;只有 claim 缺失或不可读时才发布新 claim。 +pub(crate) fn adopt_or_publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + match read_external_agent_runner_gui_owner_claim(config_dir) { + Ok(claim) => Ok(claim), + Err(_) => publish_external_agent_runner_gui_owner_claim(config_dir, session_revision), + } +} + pub(super) fn write_external_agent_runner_gui_owner_claim_atomic( config_dir: &Path, owner_epoch: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 4f0e3d555..5f4ae187c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -13,8 +13,8 @@ pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str = - "agent-runner.gui-owner.lock"; +pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str = + "agent-runner.gui-participant.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str = "agent-runner.gui-owner.claim.json"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock"; @@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) platform_api_base_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_auth_generation: Option, + /// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进 + /// revision,但不推进 `platform_auth_generation`(身份代次)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_revision: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index cde54a691..998ca0e9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -156,7 +156,7 @@ fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) - if !state.gui_owner_attached.load(Ordering::Acquire) { return false; } - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let _ = validate_external_agent_runner_gui_owner_claim_current(state); false @@ -224,7 +224,7 @@ pub(crate) fn run_external_agent_runner_server( )?; let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( gui_owner_required, - external_agent_runner_gui_owner_is_locked(&external_agent_runner_gui_owner_lock_path( + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( &config_dir, ))?, )?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 3070039b6..7dda8ab43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) draining: AtomicBool, pub(super) active_connections: AtomicUsize, pub(super) known_roots: Mutex>, - pub(super) gui_owner_lock_path: PathBuf, + pub(super) gui_participant_lock_path: PathBuf, pub(super) project_execution_owners: Mutex>, project_execution_owner_recovery_changed: Condvar, @@ -80,10 +80,10 @@ impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { - let gui_owner_lock_path = endpoint_path + let gui_participant_lock_path = endpoint_path .parent() - .map(external_agent_runner_gui_owner_lock_path) - .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)); + .map(external_agent_runner_gui_participant_lock_path) + .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME)); Self { endpoint_path, endpoint: Mutex::new(endpoint), @@ -94,7 +94,7 @@ impl ExternalAgentRunnerServerState { draining: AtomicBool::new(false), active_connections: AtomicUsize::new(0), known_roots: Mutex::new(BTreeSet::new()), - gui_owner_lock_path, + gui_participant_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), @@ -231,16 +231,10 @@ pub(super) struct ExternalAgentRunnerInstanceLock { pub(super) _file: File, } +/// 界面进程持有的参与锁。共享句柄,同一 AppData 可同时存在多个窗口。 #[derive(Debug)] -pub(crate) struct ExternalAgentRunnerGuiOwnerLock { +pub(crate) struct ExternalAgentRunnerGuiParticipantLock { pub(super) _file: File, - pub(super) owner_epoch: String, -} - -impl ExternalAgentRunnerGuiOwnerLock { - pub(crate) fn owner_epoch(&self) -> &str { - &self.owner_epoch - } } pub(super) struct ExternalAgentRunnerProjectOwnerStorage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 20a6dfc0d..f1d2f7a39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -44,6 +44,23 @@ fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf { .expect("prepare private runner AppData") } +/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。 +struct TestGuiParticipant { + _lock: ExternalAgentRunnerGuiParticipantLock, + owner_epoch: String, +} + +fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir) + .expect("acquire GUI participant lock"); + let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + .expect("publish GUI owner claim"); + TestGuiParticipant { + _lock: lock, + owner_epoch: claim.owner_epoch, + } +} + fn acquire_project_owner_after_release( root: &Path, boot_id: &str, @@ -574,8 +591,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; - register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params) - .expect("register GUI owner attachment"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + params, + ) + .expect("register GUI owner attachment"); let calls = std::cell::RefCell::new(Vec::new()); let endpoint_a = test_endpoint( @@ -657,6 +679,7 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_330), event_sink_token: Some("f".repeat(64)), @@ -668,14 +691,16 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { &state, Some(("user-a", "token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("remember owner A session"); - remember_external_agent_runner_platform_session(&state, None, 5) + remember_external_agent_runner_platform_session(&state, None, 5, 5) .expect("remember logged-out session"); remember_external_agent_runner_platform_session( &state, Some(("user-a", "late-token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("ignore stale owner A session"); remember_external_agent_runner_platform_session( @@ -686,12 +711,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { "https://dev.genarrative.world", )), 5, + 5, ) .expect("ignore conflicting same-generation session"); remember_external_agent_runner_platform_session( &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 6, + 6, ) .expect("remember latest owner B session"); @@ -725,6 +752,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_331), event_sink_token: Some("d".repeat(64)), @@ -732,6 +760,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -752,7 +781,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { ) .expect("attach owner A"); - remember_external_agent_runner_platform_session(&state, None, 2) + remember_external_agent_runner_platform_session(&state, None, 2, 2) .expect("remember logged-out session"); attach_registered_external_agent_runner_gui_owner_if_needed_with( &state, @@ -776,11 +805,13 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { platform_user_id: Some("user-a".to_string()), platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -800,6 +831,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 2, + 2, ) .expect("remember owner B while owner A attach is in flight"); Ok(()) @@ -827,8 +859,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { fn gui_owner_platform_session_payload_clears_runner_session() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire platform-session clear owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-clear-token", "platform-clear-boot", 31_333), @@ -841,9 +872,10 @@ fn gui_owner_platform_session_payload_clears_runner_session() { apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -855,8 +887,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire partial-session owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-partial-token", "platform-partial-boot", 31_334), @@ -870,11 +901,12 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let error = apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -896,9 +928,8 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old "runner-token-seed", "https://dev.genarrative.world", ); - let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire old GUI owner epoch"); - let owner_a_epoch = owner_a.owner_epoch().to_string(); + let owner_a = acquire_test_gui_participant(&config_dir, 0); + let owner_a_epoch = owner_a.owner_epoch.clone(); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { @@ -908,29 +939,31 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(10), + platform_auth_revision: Some(10), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("old GUI installs high-generation owner A"); drop(owner_a); - let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire new GUI owner epoch"); + let owner_b = acquire_test_gui_participant(&config_dir, 0); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner_b.owner_epoch().to_string()), + gui_owner_epoch: Some(owner_b.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("new GUI epoch replaces higher-generation old owner"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); @@ -943,6 +976,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(11), + platform_auth_revision: Some(11), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -963,8 +997,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "platform-claim-gate-boot", 31_337), ); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim gate owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let _session = crate::install_test_platform_session( "runner-owner-seed", "runner-token-seed", @@ -973,19 +1006,20 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(8), + platform_auth_revision: Some(8), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("attach owner A claim"); state.gui_owner_attached.store(true, Ordering::Release); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance durable claim before reattach"); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state) @@ -996,12 +1030,13 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1009,7 +1044,8 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ validate_external_agent_runner_gui_owner_claim_current(&state) .expect("reattached owner B claim is current"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); } @@ -1041,18 +1077,19 @@ fn failed_platform_session_sync_fences_runner_before_returning_error() { fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim-write failure owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1069,7 +1106,8 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, - |_, _, _| Err("injected durable claim write failure".to_string()), + 2, + |_, _| Err("injected durable claim write failure".to_string()), ) }, || { @@ -1118,6 +1156,7 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams::default(), ) .expect("register GUI owner attachment"); @@ -1166,6 +1205,7 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_322), event_sink_token: Some("c".repeat(64)), @@ -1215,6 +1255,7 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_323), event_sink_token: Some("d".repeat(64)), @@ -1267,6 +1308,7 @@ fn gui_owner_registration_does_not_cross_config_dirs() { register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_324), event_sink_token: Some(event_sink_token.clone()), @@ -1316,18 +1358,116 @@ fn gui_owner_registration_does_not_cross_config_dirs() { } #[test] -fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { +fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let first = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("first GUI owns AppData"); - let error = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect_err("second GUI must not share the same Runner owner"); - assert!(error.contains("其他进程运行")); + let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir); + assert!(!external_agent_runner_lock_is_held(&participant_lock_path) + .expect("probe without any window")); + let first = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("first window participates"); + assert!(external_agent_runner_lock_is_held(&participant_lock_path) + .expect("first window keeps the runner alive")); + let second = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("second window shares the same AppData"); + + drop(second); + assert!( + external_agent_runner_lock_is_held(&participant_lock_path) + .expect("remaining window keeps the runner alive"), + "runner must survive while any window is still open" + ); drop(first); - acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("GUI owner lock is recoverable after the first frontend exits"); + assert!( + !external_agent_runner_lock_is_held(&participant_lock_path) + .expect("last window releases the participant lock"), + "runner may stop once every window has exited" + ); +} + +#[test] +fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let published = + publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim"); + assert_eq!(published.session_revision, 3); + + let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9) + .expect("adopt existing claim"); + assert_eq!(adopted.owner_epoch, published.owner_epoch); + assert_eq!( + adopted.session_revision, 3, + "采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch" + ); + + let rotated = + publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim"); + assert_ne!(rotated.owner_epoch, published.owner_epoch); + assert_eq!(rotated.session_revision, 9); + assert_eq!( + read_external_agent_runner_gui_owner_claim(&config_dir) + .expect("read durable claim") + .session_revision, + 9 + ); +} + +#[test] +fn second_window_attach_with_same_claim_keeps_runner_platform_session() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "multi-window-claim-token-multi-window-claim-token", + "multi-window-claim-boot", + 31_338, + ), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + let owner = acquire_test_gui_participant(&config_dir, 0); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + platform_access_token: Some("runner-token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(7), + platform_auth_revision: Some(7), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("first window installs its session"); + assert_eq!( + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), + Some(("runner-owner-a".to_string(), 7)) + ); + + // 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。 + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("second window attaches with the same claim"); + assert_eq!( + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), + Some(("runner-owner-a".to_string(), 7)), + "同一 claim 的第二个窗口不得清空平台登录态" + ); } #[test] @@ -1341,8 +1481,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "gui-owner-monitor-boot", 31319), ); - let owner = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("acquire GUI owner lock"); + let owner = acquire_test_gui_participant(&config_dir, 0); let attached = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, @@ -1352,7 +1491,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1372,7 +1511,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance owner claim revision"); let replacement = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1383,7 +1522,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_319), event_sink_token: Some("c".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, @@ -1392,11 +1531,18 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u ); assert!(replacement.ok); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }) + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], + "第二个窗口 attach 必须让两个接收端同时保留" ); let stale_replay = handle_external_agent_runner_request( @@ -1408,7 +1554,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1421,11 +1567,17 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u Some("platform-session-invalid") ); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }), + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], "旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index adb540bc2..9e049b60d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1,5 +1,73 @@ use super::*; +#[test] +fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() { + // 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐 + // 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。 + let mut config: GameCreatorAppConfigFile = serde_json::from_str( + r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#, + ) + .unwrap(); + assert!(ensure_game_creator_custom_llm_file_fields(&mut config)); + assert!(scrub_locked_game_creator_config_file(&mut config)); + let migrated: serde_json::Value = serde_json::to_value(&config).unwrap(); + assert_eq!(migrated["llm"]["customEnabled"], false); + assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([])); + assert_eq!(migrated["llm"]["apiKey"], ""); + assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL); + assert_eq!(migrated["llm"]["model"], "quality"); + assert_eq!( + migrated["llm"]["apiKind"], + DEFAULT_GAME_CREATOR_LLM_API_KIND + ); + assert_eq!(migrated["llm"]["reasoningEffort"], "max"); +} + +#[test] +fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() { + let root = unique_project_path(); + fs::create_dir_all(&root).unwrap(); + let _guard = use_test_runtime_config_dir(root.clone()); + let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + write_game_creator_config_atomically( + &primary, + &serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(), + ) + .unwrap(); + write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap(); + let mut config = read_game_creator_app_config().unwrap().config; + assert_eq!(config.llm.model, "a/v1"); + let selected = select_game_creator_model("b:v2".into(), false).unwrap(); + assert_eq!(selected.config.llm.model, "b:v2"); + assert!(select_game_creator_model("not-listed".into(), false).is_err()); + config.llm.visible_models = vec!["b:v2".into()]; + config.llm.api_key = "changed-fixture-key".into(); + write_game_creator_app_config(config).unwrap(); + let reloaded = read_game_creator_app_config().unwrap().config; + assert!(reloaded.llm.custom_enabled); + assert_eq!(reloaded.llm.visible_models, ["b:v2"]); + assert_eq!(reloaded.llm.model, "b:v2"); + assert_eq!(reloaded.llm.api_key, "changed-fixture-key"); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap(); + for key in [ + "customEnabled", + "visibleModels", + "apiKey", + "baseUrl", + "model", + "apiKind", + "reasoningEffort", + ] { + assert!( + persisted["llm"].get(key).is_some(), + "本地配置始终保留 {key},方便手写自定义连接:{persisted}" + ); + } + fs::remove_dir_all(root).unwrap(); +} + #[test] fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); @@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() { .llm .as_ref() .expect("global llm remains as non-sensitive tuning"); - assert!(llm.api_key.is_none()); - assert!(llm.base_url.is_none()); - assert!(llm.model.is_none()); - assert!(llm.api_kind.is_none()); + // 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。 + assert_eq!(llm.api_key.as_deref(), Some("")); + assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL)); + assert_eq!( + llm.api_kind.as_deref(), + Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + ); + assert!(llm.model.is_some()); let serialized = serde_json::to_string(&config).expect("serialize scrubbed config"); assert!(!serialized.contains("legacy-global-key")); assert!(!serialized.contains("legacy-agent-key")); @@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() { agent_llm.insert( " planner ".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some(" planner-key ".to_string()), base_url: Some(" https://planner.example.test/v1 ".to_string()), model: Some(" planner-model ".to_string()), @@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() { schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(), agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), @@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, ) @@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f12416e7d..a91336e16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -202,9 +202,13 @@ fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { GameCreatorGuiRunnerShutdownOutcome::NotRequested ); assert_eq!( - resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())), + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(true)), GameCreatorGuiRunnerShutdownOutcome::Requested ); + assert_eq!( + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(false)), + GameCreatorGuiRunnerShutdownOutcome::Retained + ); assert_eq!( resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || { Err("private shutdown diagnostic".to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 1eacbe9c9..dd4946ea4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -5885,6 +5885,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "planner".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("planner-key".to_string()), base_url: Some(planner_base_url), model: Some("planner-model".to_string()), @@ -5903,6 +5905,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "generator".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("generator-key".to_string()), base_url: Some(generator_base_url), model: Some("generator-model".to_string()), @@ -5921,6 +5925,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "art-asset-plan".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("art-key".to_string()), base_url: Some(art_base_url), model: Some("art-model".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 762b67983..db84a285b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1494,7 +1494,7 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() assert!(prompt_input.contains("只删除项目内普通文件")); let verification_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(Duration::from_secs(10)) .expect("verification plan request"); assert!(verification_request.contains("file.delete")); assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt")); diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index c5fa7f96b..1d4c33053 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿", - "version": "0.1.29", + "version": "0.1.47", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs index c49ef18c1..d9cd7e56b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; +const GUI_PARTICIPANT_LOCK_FILE_NAME: &str = "agent-runner.gui-participant.lock"; struct TestDirectory(PathBuf); @@ -50,8 +50,9 @@ fn open_locked_file(path: &Path) -> File { .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) .open(path) .expect("open isolated lock file"); + // 模拟一个界面窗口:参与锁以共享锁持有,多个窗口可以同时持有。 // SAFETY: file owns a live descriptor and flock does not retain pointers. - assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0); + assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH) }, 0); file } @@ -101,7 +102,7 @@ fn runner_binary() -> &'static str { #[test] fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { let directory = TestDirectory::new("owner-lost-before-check"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let script = "kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required"; let mut child = Command::new("/bin/sh") @@ -139,7 +140,7 @@ fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { #[test] fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() { let directory = TestDirectory::new("owner-lost-after-start"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let mut child = Command::new(runner_binary()) .arg("--agent-runner") .arg("--config-dir") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 25bc209ca..3e22b1d0a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -54,9 +54,11 @@ import type { DesignClarificationRequest, DesignEvent, DesignView, + DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorDirectActiveTurn, + GameCreatorDirectToolCall, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -94,6 +96,7 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, + TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -130,6 +133,7 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -145,6 +149,7 @@ import { type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; +import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { agentConversationReadDraftsFromManifest, @@ -218,6 +223,15 @@ import { isMissingProjectFileError, parseAgentRunTrace, } from './features/project-workspace/agentRunTrace'; +import { + chatQueueFullNotice, + createQueuedChatTurn, + dequeueChatTurn, + enqueueChatTurn, + isChatTurnQueueFull, + type QueuedChatTurn, + removeQueuedChatTurn, +} from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { @@ -225,9 +239,9 @@ import { directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, - emptyDirectThreadReducerState, - reduceDirectThreadEvents, + isDirectTurnInProgress, } from './features/project-workspace/directThreadEvents'; +import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, @@ -284,6 +298,16 @@ 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:'; +/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = + '当前项目已有另一条 Direct 客户端回合正在运行'; +/** + * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, + * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 + */ +const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; +const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = + '该回合已无响应,可在输入盒点「终止」结束它以继续'; // 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 @@ -375,9 +399,8 @@ function directCodexActivityDetail( case 'finalizing': return '正在整理结果'; case 'completed': - return '正在提交回复'; case 'failed': - return '正在记录失败原因'; + return ''; default: return '正在处理任务'; } @@ -410,11 +433,8 @@ function directCodexProcessDetail({ activity?: string | null; status: string; }) { - if (status === 'completed') { - return '正在提交回复'; - } - if (status === 'failed') { - return '正在记录失败原因'; + if (status === 'completed' || status === 'failed') { + return ''; } if (status === 'streaming') { return '正在生成回复'; @@ -460,6 +480,72 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } +export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; + +/** + * 回合流排序:`seq`(条目首次出现时钉死)优先,其次 `at`,最后按 id 兜底。 + * 与 Rust 侧同一口径——前端不自己发明顺序。 + */ +function sortTurnStreamItems(items: readonly TurnStreamItem[]) { + return [...items].sort( + (left, right) => + left.seq - right.seq || + left.at - right.at || + left.id.localeCompare(right.id), + ); +} + +/** + * 归并一批回合流条目:同 id 幂等覆盖(`updatedAt` 单调,同刻取更长文本),新 id 追加。 + * 实时增量与回读历史共用这一处,所以界面上的顺序只有一份来源。 + */ +function mergeTurnStreamItems( + existing: readonly TurnStreamItem[], + incoming: readonly TurnStreamItem[], +): TurnStreamItem[] { + if (incoming.length === 0) { + return [...existing]; + } + const byId = new Map(); + for (const item of existing) { + const id = item.id?.trim(); + if (id) { + byId.set(id, item); + } + } + for (const item of incoming) { + const id = item.id?.trim(); + if (!id) { + continue; + } + const previous = byId.get(id); + const normalized: TurnStreamItem = { ...item, id }; + if (!previous) { + byId.set(id, normalized); + continue; + } + // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 + const textLength = (value: TurnStreamItem) => + value.kind === 'text' ? (value.text?.length ?? 0) : 0; + // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 + const takeIncoming = + normalized.updatedAt > previous.updatedAt || + (normalized.updatedAt === previous.updatedAt && + textLength(normalized) > textLength(previous)); + byId.set(id, { + ...(takeIncoming ? normalized : previous), + id, + updatedAt: Math.max(previous.updatedAt, normalized.updatedAt), + seq: Math.min(previous.seq, normalized.seq), + at: + previous.at > 0 && normalized.at > 0 + ? Math.min(previous.at, normalized.at) + : Math.max(previous.at, normalized.at), + } as TurnStreamItem); + } + return sortTurnStreamItems([...byId.values()]); +} + export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -467,6 +553,26 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) { .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); } +/** + * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 + * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 + * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + */ +export function isDirectCodexAnotherTurnRunningError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +} + +/** + * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 + * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 + * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + */ +export function isDirectCodexTurnInterruptedError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes('turn 已中断') || message.includes('已终止本次回合'); +} + function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; @@ -486,19 +592,55 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) { return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); } -function claimInitialSupervisorMessageForPage(projectPath: string) { +function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); if (!claimedProjectPaths) { claimedProjectPaths = new Set(); initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); } - if (claimedProjectPaths.has(projectPath)) { + const claimKey = `${projectPath}\u0000${scope}`; + if (claimedProjectPaths.has(claimKey)) { return false; } - claimedProjectPaths.add(projectPath); + claimedProjectPaths.add(claimKey); return true; } +/** + * 历史回读与"尚未落盘的运行时消息"合并。 + * + * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), + * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 + * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 + * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + */ +function mergeLoadedConversationWithPendingRuntimeMessages( + loaded: ChatMessage[], + current: ChatMessage[], +): ChatMessage[] { + if (current.length === 0) { + return loaded; + } + const loadedIds = new Set( + loaded + .map((message) => message.messageId) + .filter((id): id is string => Boolean(id)), + ); + const loadedTexts = new Set( + loaded.map((message) => `${message.role}\u0000${message.text}`), + ); + const pending = current.filter((message) => { + if (!message.runtimeOwned) { + return false; + } + if (message.messageId) { + return !loadedIds.has(message.messageId); + } + return !loadedTexts.has(`${message.role}\u0000${message.text}`); + }); + return pending.length > 0 ? [...loaded, ...pending] : loaded; +} + export { AuthenticatedClient } from './app/AuthenticatedClient'; export type { PendingCommand } from './app/types'; export { @@ -529,6 +671,7 @@ type AppProps = { activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; supervisorChatOnly?: boolean; initialSupervisorMessage?: string; + initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; @@ -578,6 +721,7 @@ export function App({ activeVersionId = null, supervisorChatOnly = false, initialSupervisorMessage = '', + initialSupervisorMessageClaimScope = '', initialCreationType = null, initialAttachments = [], playRequest = null, @@ -637,6 +781,7 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; + ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -710,6 +855,7 @@ export function App({ const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), + claimScope: initialSupervisorMessageClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); @@ -737,10 +883,39 @@ export function App({ : '', ); const [chatReferences, setChatReferences] = useState([]); + /** + * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 + * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, + * 提交后即清空——后端协议不变。 + */ + const [chatAttachments, setChatAttachments] = useState< + DirectCodexTurnAttachment[] + >([]); + const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); + /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ + const [chatTurnQueue, setChatTurnQueue] = useState([]); + const chatTurnQueueRef = useRef([]); + chatTurnQueueRef.current = chatTurnQueue; + const [chatComposerNotice, setChatComposerNotice] = useState(''); + const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = + useState(false); + const queuedChatTurnSequenceRef = useRef(0); const [chatContent, setChatContent] = useState( [], ); const chatComposerRef = useRef(null); + /** + * 切项目即清空只属于上一个项目的输入盒状态:待发附件的 `localPath` 是**项目相对**的, + * 队列也属于刚结束的那条对话;留着会把 A 项目的附件路径带进 B 项目的下一个回合。 + */ + useEffect(() => { + setChatAttachments([]); + setChatContent([]); + setChatAttachmentNotice(''); + setChatComposerNotice(''); + setChatTurnQueue([]); + chatTurnQueueRef.current = []; + }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); const [directCodexStatus, setDirectCodexStatus] = useState< @@ -752,6 +927,10 @@ export function App({ const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); const directCodexTransientReplyRef = useRef(''); + // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 + const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = + useState(''); + /** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */ const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -761,11 +940,102 @@ export function App({ turnId: string; lastSequence: number; receivedDirectUpdate: boolean; + restored?: boolean; + } | null>(null); + const directActiveSnapshotVersionRef = useRef(0); + const directTurnLifecycleRef = useRef<{ + reset: () => void; + loadHistory: (projectPath: string) => Promise; + restore: (projectPath: string) => Promise; } | null>(null); - const directThreadSubscriptionIdRef = useRef(null); - const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState()); const lastDirectCodexActivityRef = useRef(null); + /** + * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, + * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 + */ + const recoveredDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + } | null>(null); + const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); + // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 + // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 + const [directToolCalls, setDirectToolCalls] = useState< + GameCreatorDirectToolCall[] + >([]); + const directToolCallsRef = useRef([]); + const directTimelineProjectPathRef = useRef(null); + + function ensureDirectTimelineProject(project: string | null) { + if (directTimelineProjectPathRef.current === project) return; + directTimelineProjectPathRef.current = project; + directToolCallsRef.current = []; + directTurnStreamRef.current = []; + setDirectToolCalls([]); + setDirectTurnStream([]); + } + /** + * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), + * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + */ + function applyDirectToolCalls( + incoming: readonly GameCreatorDirectToolCall[], + ) { + if (incoming.length === 0) { + return; + } + const merged = [...directToolCallsRef.current]; + for (const call of incoming) { + const id = call.id?.trim(); + if (!id) { + continue; + } + const existingIndex = merged.findIndex( + (existing) => existing.id === id && existing.turnId === call.turnId, + ); + const normalized: GameCreatorDirectToolCall = { + ...call, + id, + startedAt: normalizeDirectTimestamp(call.startedAt), + updatedAt: normalizeDirectTimestamp(call.updatedAt), + detail: call.detail ?? { changes: [] }, + }; + // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 + const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; + if (existing) { + if ( + existing.updatedAt > normalized.updatedAt || + (existing.status !== 'running' && normalized.status === 'running') + ) + continue; + normalized.detail = { + ...normalized.detail, + command: normalized.detail.command ?? existing.detail.command, + output: normalized.detail.output ?? existing.detail.output, + }; + } + if ( + existing && + existing.startedAt > 0 && + (normalized.startedAt === 0 || + existing.startedAt < normalized.startedAt) + ) { + normalized.startedAt = existing.startedAt; + } + if (existingIndex >= 0) { + merged[existingIndex] = normalized; + } else { + merged.push(normalized); + } + } + merged.sort( + (left, right) => + left.startedAt - right.startedAt || left.id.localeCompare(right.id), + ); + directToolCallsRef.current = merged; + setDirectToolCalls(merged); + } const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -786,6 +1056,8 @@ export function App({ } function resetDirectCodexTurn() { + directActiveSnapshotVersionRef.current += 1; + clearRecoveredDirectCodexTurnWatch(); activeDirectCodexTurnRef.current = null; lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); @@ -793,10 +1065,167 @@ export function App({ setDirectCodexProcessKey(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } + /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ + function clearRecoveredDirectCodexTurnWatch() { + if (recoveredDirectCodexTurnTimerRef.current !== null) { + window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); + recoveredDirectCodexTurnTimerRef.current = null; + } + recoveredDirectCodexTurnRef.current = null; + } + + /** + * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 + * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 + * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 + * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 + */ + function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { + clearRecoveredDirectCodexTurnWatch(); + recoveredDirectCodexTurnRef.current = { projectPath, turnId }; + recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { + recoveredDirectCodexTurnTimerRef.current = null; + const watch = recoveredDirectCodexTurnRef.current; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !watch || + watch.projectPath !== projectPath || + watch.turnId !== turnId || + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId || + activeTurn.receivedDirectUpdate + ) { + return; + } + setDirectCodexStatus('running'); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + setDirectCodexProgressUpdatedAt(Date.now()); + setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); + } + + /** + * 重进会话时接管仍在运行的 Direct 回合。 + * + * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 + * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 + * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 + * + * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 + */ + async function restoreRunningDirectCodexTurn( + projectPath: string, + reconcile = false, + ) { + if (!directCodexProductRuntime || !projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + const owner = activeDirectCodexTurnRef.current; + const sequence = owner?.lastSequence; + const scopeVersion = projectScopeVersionRef.current; + if (!reconcile && owner?.projectPath === projectPath) { + return; + } + const readVersion = ++directActiveSnapshotVersionRef.current; + let turns: GameCreatorDirectActiveTurn[]; + try { + turns = await invoke( + 'list_game_creator_direct_active_turns', + ); + } catch { + // 读取失败不等于没有活动回合。 + return; + } + if (!Array.isArray(turns)) return; + if ( + localProjectPathRef.current !== projectPath || + designAgentActiveRef.current || + projectScopeVersionRef.current !== scopeVersion || + directActiveSnapshotVersionRef.current !== readVersion || + activeDirectCodexTurnRef.current !== owner || + owner?.lastSequence !== sequence + ) { + return; + } + const activeView = turns.find( + (turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath) && + isDirectTurnInProgress(turn.status), + ); + if (!activeView || !isDirectTurnInProgress(activeView.status)) { + // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 + if (owner && !owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + setChatAgentBusy(false); + if (owner && reconcile) { + void loadProjectConversation(projectPath, false, 'replace'); + } + return; + } + const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; + if (owner && !matchingOwner) { + if (!owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + } + if (!matchingOwner) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeView.turnId, + lastSequence: -1, + receivedDirectUpdate: false, + restored: true, + }; + setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); + setProjectSupervisorRuntimeError(''); + watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); + } + setChatAgentBusy(true); + // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 + if ( + !matchingOwner?.receivedDirectUpdate || + activeView.sequence > matchingOwner.lastSequence + ) { + setDirectCodexStatus(activeView.status); + setDirectCodexProgress( + directCodexActivityDetail(activeView.activity, activeView.status), + ); + setDirectCodexProgressUpdatedAt(activeView.updatedAt); + } + } + + directTurnLifecycleRef.current = { + reset: resetDirectCodexTurn, + loadHistory: (projectPath) => + loadProjectConversation(projectPath, false, 'replace'), + restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), + }; + + // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, + // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 + const [directTurnStream, setDirectTurnStream] = useState( + [], + ); + const directTurnStreamRef = useRef([]); + + /** 归并一批回合流条目(实时事件里的 `streamItems`)。 */ + function applyTurnStreamItems(incoming: readonly TurnStreamItem[]) { + if (incoming.length === 0) { + return; + } + const merged = mergeTurnStreamItems(directTurnStreamRef.current, incoming); + directTurnStreamRef.current = merged; + setDirectTurnStream(merged); + } + function clearDirectCodexTransientReply(projectPath: string, turnId: string) { const activeTurn = activeDirectCodexTurnRef.current; if ( @@ -807,6 +1236,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; @@ -896,7 +1326,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } - return view.messages + const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ role: message.role === 'user' ? 'user' : 'assistant', @@ -906,6 +1336,21 @@ export function App({ reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); + const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + if ( + initialPrompt && + !messages.some( + (message) => message.role === 'user' && message.text === initialPrompt, + ) + ) { + messages.unshift({ + role: 'user', + text: initialPrompt, + runtimeOwned: true, + updatedAt: Date.now(), + }); + } + return messages; } function applyDesignView(view: DesignView, projectPath: string) { @@ -1508,19 +1953,45 @@ export function App({ } activeTurn.lastSequence = payload.sequence; activeTurn.receivedDirectUpdate = true; + // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 + if ( + recoveredDirectCodexTurnRef.current?.projectPath === + payload.projectPath && + recoveredDirectCodexTurnRef.current.turnId === payload.turnId + ) { + clearRecoveredDirectCodexTurnWatch(); + setChatComposerNotice((current) => + current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE + ? '' + : current, + ); + } + ensureDirectTimelineProject(payload.projectPath); + // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 + if (payload.toolCalls?.length) { + applyDirectToolCalls( + payload.toolCalls.map((call) => ({ + ...call, + turnId: payload.turnId, + })), + ); + } + if (typeof payload.reasoningText === 'string') { + setDirectCodexTransientReasoning(payload.reasoningText); + } + // 回合流的顺序真相:字段可选,老事件(undefined)走原路径。 + if (payload.streamItems?.length) { + applyTurnStreamItems(payload.streamItems); + } const updatedAt = Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 ? payload.updatedAt : Date.now(); const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed') { - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexStatus(payload.status); - setDirectCodexProgress(processDetail); - setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); - setDirectCodexTransientReplyUpdatedAt(null); + if (payload.status === 'failed' || payload.status === 'completed') { + directTurnLifecycleRef.current?.reset(); + setChatAgentBusy(false); + void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); return; } setDirectCodexStatus(payload.status); @@ -1580,122 +2051,77 @@ export function App({ useEffect(() => { const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) { - directThreadSubscriptionIdRef.current = null; - directThreadReducerStateRef.current = emptyDirectThreadReducerState(); - return; - } + if (!directCodexProductRuntime || !projectPath || !directInvoke) return; let disposed = false; let cleanup: (() => void) | null = null; + let subscriptionId: string | null = null; + let consuming = false; + let consumeAgain = false; - const applyReducerState = ( - state: ReturnType, - ) => { - if (disposed) return; - directThreadReducerStateRef.current = state; - const running = - state.status === 'accepted' || - state.status === 'running' || - state.status === 'streaming' || - state.status === 'finalizing'; - setChatAgentBusy(running); - setDirectCodexStatus(state.status); - setDirectCodexProgress(state.progress); - setDirectCodexProgressUpdatedAt(Date.now()); - if (state.accumulatedText) { - setDirectCodexTransientReply(state.accumulatedText); - directCodexTransientReplyRef.current = state.accumulatedText; - setDirectCodexTransientReplyUpdatedAt(Date.now()); - } else { - setDirectCodexTransientReply(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - } + // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 + const refreshActive = () => { + if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); }; - const bootstrap = async () => { - try { - const activeTurns = await directInvoke( - 'list_game_creator_direct_active_turns', - ); - const activeTurn = activeTurns.find((turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath), - ); - if (activeTurn && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeTurn.turnId, - lastSequence: 0, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); - setChatAgentBusy(true); - setDirectCodexStatus('running'); - setDirectCodexProgress('正在处理'); - setDirectCodexProgressUpdatedAt(Date.now()); - } - } catch { - // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 - } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, ); if (disposed) return; - directThreadSubscriptionIdRef.current = result.subscriptionId; - const state = reduceDirectThreadEvents( - result.events, - emptyDirectThreadReducerState(), - ); - applyReducerState(state); - if (state.turnId && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: state.turnId, - lastSequence: state.lastSeq, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`); - } + subscriptionId = result.subscriptionId; + refreshActive(); }; - const consume = async () => { - const subscriptionId = directThreadSubscriptionIdRef.current; if (!subscriptionId || disposed) return; + if (consuming) { + consumeAgain = true; + return; + } + consuming = true; try { - const result = await directInvoke( - 'consume_direct_project_thread', - { subscriptionId }, - ); - if (disposed) return; - const state = reduceDirectThreadEvents( - result.events, - directThreadReducerStateRef.current, - ); - applyReducerState(state); + do { + consumeAgain = false; + const result = await directInvoke( + 'consume_direct_project_thread', + { subscriptionId }, + ); + if (disposed) return; + if ( + result.events.some( + (event) => + event.type === 'turn.started' || + event.type === 'turn.completed', + ) + ) { + refreshActive(); + } + if ( + result.events.some((event) => event.type === 'turn.completed') && + !activeDirectCodexTurnRef.current?.receivedDirectUpdate + ) { + // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 + void directTurnLifecycleRef.current?.loadHistory(projectPath); + } + } while (consumeAgain && !disposed); } catch (error) { - if (String(error).includes('SUBSCRIPTION_EXPIRED')) { - directThreadSubscriptionIdRef.current = null; + if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { + subscriptionId = null; try { await bootstrap(); } catch { - // A later project activation or notification will retry bootstrap. + /* 活动快照轮询仍然有效。 */ } } + } finally { + consuming = false; } }; - const setup = async () => { try { const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if ( - event.payload.subscriptionId === - directThreadSubscriptionIdRef.current - ) { - void consume(); - } + if (event.payload.subscriptionId === subscriptionId) void consume(); }, ); if (disposed) { @@ -1704,15 +2130,21 @@ export function App({ } cleanup = unlisten; await bootstrap(); + await consume(); } catch { - // The history view remains usable when the runtime subscription is unavailable. + // 历史仍可使用;订阅失败不伪造忙碌态。 } }; + refreshActive(); + const timer = window.setInterval( + refreshActive, + DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + ); void setup(); return () => { disposed = true; cleanup?.(); - directThreadSubscriptionIdRef.current = null; + window.clearInterval(timer); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -3111,8 +3543,12 @@ export function App({ setProjectSupervisorResponseStream(null); } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(() => { - const nextMessages = conversationMessages; + setMessages((current) => { + // 不能整体替换:乐观插入、尚未落盘的用户消息会被冲掉(初始需求看不到就是这个原因)。 + const nextMessages = mergeLoadedConversationWithPendingRuntimeMessages( + conversationMessages, + current, + ); savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = nextMessages.length; latestMessagesRef.current = nextMessages; @@ -3226,7 +3662,10 @@ export function App({ return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages(slice.items), + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), } satisfies LocalConversationResult; }); })() @@ -3235,6 +3674,35 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; + // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 + // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, + // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 + // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 + if (directCodexProductRuntime) { + const persistedToolCalls = await invoke( + 'read_direct_tool_calls', + { projectPath: nextProjectPath }, + ).catch(() => []); + // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 + // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 + // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 + const persistedTurnStream = await invoke( + 'read_direct_turn_stream', + { projectPath: nextProjectPath }, + ).catch(() => []); + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) + return; + ensureDirectTimelineProject(nextProjectPath); + // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 + applyDirectToolCalls(persistedToolCalls); + applyTurnStreamItems(persistedTurnStream); + // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 + // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 + await restoreRunningDirectCodexTurn(nextProjectPath); + } let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -3299,7 +3767,14 @@ export function App({ ?.messageId ?? null; } setMessages((current) => { - const nextConversationMessages = conversationMessages; + // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 + const nextConversationMessages = + mergeLoadedConversationWithPendingRuntimeMessages( + conversationMessages, + current, + ); + // 空对话(没有默认问候之后的新常态)同样应当接受回读结果。 + const isEmptyConversation = current.length === 0; const hasOnlyDefaultGreeting = current.length === 1 && current[0]?.role === 'assistant' && @@ -3312,6 +3787,7 @@ export function App({ current[1]?.text === `已设置本地项目:${nextProjectPath}`; if ( mode !== 'replace' && + !isEmptyConversation && !hasOnlyDefaultGreeting && !hasOnlyOpenStatus ) { @@ -6007,8 +6483,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 = @@ -6112,17 +6595,21 @@ export function App({ const appendDirectAssistantMessage = ( current: ChatMessage[], text: string, + failed = false, ): ChatMessage[] => { + const messageId = failed + ? `direct-codex:${clientTurnId}:failure` + : directAssistantMessageId; const nextMessage: ChatMessage = { role: 'assistant', text, runtimeOwned: true, - messageId: directAssistantMessageId, + messageId, updatedAt: Date.now(), }; const withUser = appendDirectUserMessageIfMissing(current); const existingIndex = withUser.findIndex( - (message) => message.messageId === directAssistantMessageId, + (message) => message.messageId === messageId, ); if (existingIndex < 0) { return [...withUser, nextMessage]; @@ -6142,6 +6629,7 @@ export function App({ setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); setDirectCodexProgress('正在等待陶泥儿开始'); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); setDirectCodexProgressUpdatedAt(Date.now()); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); @@ -6191,24 +6679,42 @@ export function App({ setMessages((current) => appendDirectAssistantMessage(current, reply), ); - setDirectCodexStatus('finalizing'); - setDirectCodexProgress('正在同步项目文件'); - setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { - if (isDirectCodexTurnAlreadyRunningError(error)) { + if ( + isDirectCodexTurnAlreadyRunningError(error) || + isDirectCodexAnotherTurnRunningError(error) + ) { if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( - '陶泥儿仍在处理这条消息,请稍候刷新对话。', + '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); + // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 + // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally + // 里 setChatAgentBusy(false) 的复位竞态。 + window.setTimeout(() => { + void restoreRunningDirectCodexTurn(directProjectPath); + }, 0); } return; } if (localProjectPathRef.current !== directProjectPath) { return; } + if (isDirectCodexTurnInterruptedError(error)) { + // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 + clearDirectCodexTransientReply(directProjectPath, clientTurnId); + setDirectCodexStatus('failed'); + setDirectCodexProgress(''); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice('已终止本次回合'); + setMessages((current) => + appendDirectAssistantMessage(current, '已终止本次回合。', true), + ); + return; + } void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); const message = error instanceof Error ? error.message : String(error); @@ -6241,7 +6747,7 @@ export function App({ setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => - appendDirectAssistantMessage(current, visibleMessage), + appendDirectAssistantMessage(current, visibleMessage, true), ); } } finally { @@ -6250,15 +6756,18 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - setChatAgentBusy(false); - setDirectCodexProgress(''); const activeTurn = activeDirectCodexTurnRef.current; if ( - !activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId) + localProjectPathRef.current === directProjectPath && + (!activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId)) ) { + setChatAgentBusy(false); + setDirectCodexTurnCancelling(false); resetDirectCodexTurn(); + // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + dispatchNextQueuedChatTurn(); } } } @@ -6430,12 +6939,14 @@ export function App({ return; } if (localProject.projectPath !== latch.projectPath) { - claimInitialSupervisorMessageForPage(latch.projectPath); + // 不能在这里先"占用"这条初始消息:项目路径可能因为分隔符/大小写/时序先落到别的 + // 路径上,一旦占用,真正匹配的项目就再也不会收到这条消息,用户的输入被静默丢掉。 + // 这里只等待,占用留给下面真正要发送的那一步。 return; } if ( chatAgentBusy || - !claimInitialSupervisorMessageForPage(latch.projectPath) + !claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope) ) { return; } @@ -11345,8 +11856,8 @@ export function App({ ); const projectSupervisorHasConversationControls = Boolean( projectSupervisorRuntime?.pendingToolAction || - projectSupervisorRuntime?.userInputRequest || - projectSupervisorNeedsUserInput, + projectSupervisorRuntime?.userInputRequest || + projectSupervisorNeedsUserInput, ); const visibleAgentConversationMessages = latestVisibleItems( agentConversationMessages, @@ -11391,18 +11902,19 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages(slice.items).map( - (message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - }), - ); + const older = directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ).map((message) => ({ + role: + message.role === 'user' + ? ('user' as const) + : ('assistant' as const), + text: message.content, + runtimeOwned: true, + messageId: message.messageId, + updatedAt: message.updatedAt, + })); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); @@ -11478,12 +11990,212 @@ export function App({ } }, [agentStatusCards, selectedAgent]); + /** + * 发起一轮 direct-codex 对话回合:提交与队列出队共用同一条路径,避免两条入口的 + * 消息落盘/回合 id/附件参数走样。 + */ + function startDirectCodexConversationTurn(input: { + prompt: string; + attachments?: DirectCodexTurnAttachment[]; + references?: ChatReference[]; + content?: DirectCodexUserContentPart[]; + }) { + const clientTurnId = createDirectCodexConversationTurnId(); + supervisorChatShouldFollowLatestRef.current = true; + setMessages((current) => [ + ...current, + { + role: 'user', + text: input.prompt, + runtimeOwned: true, + messageId: directCodexConversationMessageId(clientTurnId, 'user'), + updatedAt: Date.now(), + }, + ]); + void executeChatAgentReply({ + prompt: input.prompt, + clientTurnId, + attachments: input.attachments?.length ? input.attachments : undefined, + references: input.references, + userItem: chatComposerDraftToDirectCodexUserItem( + { + text: input.prompt, + references: input.references ?? [], + content: input.content ?? [], + }, + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }); + } + + /** + * 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目, + * 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。 + */ + async function handleChatComposerUploadFiles(files: readonly File[]) { + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke || !nextProjectPath) { + setChatAttachmentNotice('需要先打开本地项目,才能上传文件'); + return; + } + const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length; + const accepted = files.slice(0, Math.max(remaining, 0)); + if (accepted.length === 0) { + setChatAttachmentNotice( + `最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`, + ); + return; + } + setChatAttachmentNotice('正在上传文件'); + try { + const imported = await uploadLocalFilesAsAttachments( + invoke, + nextProjectPath, + accepted, + ); + const attachments = toDirectCodexTurnAttachments(imported); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + setChatAttachments((current) => + [...current, ...attachments].slice(0, MAX_CHAT_COMPOSER_ATTACHMENTS), + ); + const failed = attachments.filter( + (attachment) => attachment.status === 'failed', + ); + setChatAttachmentNotice( + failed.length > 0 + ? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}` + : `已上传 ${attachments.length} 个文件,将在下次发送时作为本轮附件`, + ); + void refreshManifest(nextProjectPath); + } catch (error) { + if (localProjectPathRef.current === nextProjectPath) { + setChatAttachmentNotice( + error instanceof Error ? error.message : String(error), + ); + } + } + } + + function removeChatComposerAttachment(index: number) { + setChatAttachments((current) => + current.filter((_, currentIndex) => currentIndex !== index), + ); + } + + /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ + function enqueueChatTurnForRunningTurn(input: { + prompt: string; + attachments: DirectCodexTurnAttachment[]; + references: ChatReference[]; + content: DirectCodexUserContentPart[]; + }): boolean { + if (isChatTurnQueueFull(chatTurnQueueRef.current)) { + setChatComposerNotice(chatQueueFullNotice()); + return false; + } + queuedChatTurnSequenceRef.current += 1; + const turn = createQueuedChatTurn({ + id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, + prompt: input.prompt, + attachments: input.attachments, + references: input.references, + content: input.content, + createdAt: Date.now(), + }); + const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); + chatTurnQueueRef.current = nextQueue; + setChatTurnQueue(nextQueue); + setChatComposerNotice('已加入发送队列,当前回合结束后自动发送'); + return true; + } + + function cancelQueuedChatTurn(id: string) { + const nextQueue = removeQueuedChatTurn(chatTurnQueueRef.current, id); + chatTurnQueueRef.current = nextQueue; + setChatTurnQueue(nextQueue); + if (nextQueue.length === 0) { + setChatComposerNotice(''); + } + } + + /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + function dispatchNextQueuedChatTurn() { + const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); + if (!next) { + return; + } + chatTurnQueueRef.current = rest; + setChatTurnQueue(rest); + if (rest.length === 0) { + setChatComposerNotice(''); + } + startDirectCodexConversationTurn({ + prompt: next.prompt, + attachments: next.attachments, + references: next.references, + content: next.content, + }); + } + + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ + async function handleCancelDirectCodexTurn() { + if (directCodexTurnCancelling) { + return; + } + const invoke = resolveTauriInvoke(); + const activeTurn = activeDirectCodexTurnRef.current; + const directProjectPath = + activeTurn?.projectPath ?? resolveChatProjectPath(localProject); + if (!invoke || !directProjectPath || !activeTurn) { + setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); + return; + } + setDirectCodexTurnCancelling(true); + setChatComposerNotice('正在终止当前回合'); + try { + const result = await invoke( + 'cancel_direct_codex_turn', + { + projectPath: directProjectPath, + clientTurnId: activeTurn.turnId, + }, + ); + const message = result?.message?.trim(); + if (result?.outcome === 'released') { + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 + // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 + // 与"任务执行中"会一直挂着,用户仍然发不出消息。 + resetDirectCodexTurn(); + setChatAgentBusy(false); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice( + message ?? '已结束这一轮占用,可以直接重新发送消息', + ); + return; + } + setDirectCodexProgress('正在终止当前回合'); + if (message) { + setChatComposerNotice(message); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setProjectSupervisorRuntimeError(`终止失败:${message}`); + setChatComposerNotice(''); + } finally { + setDirectCodexTurnCancelling(false); + } + } + function handleProjectSupervisorOnlySubmit( event: FormEvent, ) { event.preventDefault(); const prompt = chatInput.trim(); const references = chatReferences; + const pendingAttachments = chatAttachments; if ( !directCodexProductRuntime && supervisorChatOnly && @@ -11499,7 +12211,32 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ((!prompt && references.length === 0) || chatAgentBusy) { + if ( + !prompt && + references.length === 0 && + chatContent.length === 0 && + pendingAttachments.length === 0 + ) { + return; + } + if (chatAgentBusy) { + // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 + // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 + if (directCodexProductRuntime) { + const enqueued = enqueueChatTurnForRunningTurn({ + prompt, + attachments: pendingAttachments, + references, + content: chatContent, + }); + if (enqueued) { + setChatInput(''); + setChatContent([]); + setChatReferences([]); + setChatAttachments([]); + setChatAttachmentNotice(''); + } + } return; } if (directCodexProductRuntime && prompt === '/history') { @@ -11516,15 +12253,22 @@ export function App({ if (supervisorChatOnly || directCodexProductRuntime) { supervisorChatShouldFollowLatestRef.current = true; } - const directConversationTurnId = directCodexProductRuntime - ? createDirectCodexConversationTurnId() - : undefined; - const directUserItem = directConversationTurnId - ? chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, - directCodexConversationMessageId(directConversationTurnId, 'user'), - ) - : undefined; + if (directCodexProductRuntime) { + // 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。 + setChatInput(''); + setChatReferences([]); + setChatAttachments([]); + setChatContent([]); + setChatAttachmentNotice(''); + setChatComposerNotice(''); + startDirectCodexConversationTurn({ + prompt, + attachments: pendingAttachments, + references, + content: chatContent, + }); + return; + } setChatInput(''); setChatReferences([]); setChatContent([]); @@ -11534,23 +12278,10 @@ export function App({ role: 'user', text: prompt, runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'user', - ), - } - : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ - prompt, - clientTurnId: directConversationTurnId, - references, - userItem: directUserItem, - }); + void executeChatAgentReply({ prompt, references }); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -11612,9 +12343,20 @@ export function App({ if (projectSupervisorOnly) { return ( void handleCancelDirectCodexTurn()} + onRemoveAttachment={removeChatComposerAttachment} + onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} + queuedTurns={chatTurnQueue} + turnCancelling={directCodexTurnCancelling} composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} @@ -11642,6 +12384,23 @@ export function App({ } pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} + toolCalls={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directToolCalls + : [] + } + turnStreamItems={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directTurnStream + : [] + } + conversationMessages={messages} + hasUnloadedHistory={directHistoryHasMore} + activeTurnId={ + directCodexProductRuntime + ? (activeDirectCodexTurnRef.current?.turnId ?? null) + : null + } transientReply={ designAgentActive ? designAgentTransientReply diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 2c9fe3834..fdef7af6f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -404,7 +404,9 @@ export function AuthenticatedClient({ ); return; } - if (result.status === 'failed') { + // 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时 + // 不可用,必须保留既有会话与 access token。 + if (result.status === 'failed' && result.authoritative) { clearStoredAuthAccessToken(); setAuthUser(null); setAuthStatus('unauthenticated'); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 9b0b3086a..4398c98e0 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -454,11 +454,7 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - | 'streaming' - | 'ready' - | 'committed' - | 'discarded' - | 'failed'; + 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -575,24 +571,17 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - | 'openai_responses' - | 'openai_chat' - | 'anthropic'; + 'openai_responses' | 'openai_chat' | 'anthropic'; export type GameCreatorAgentMode = - | 'codex_app_server' - | 'codex_cli' - | 'provider'; + 'codex_app_server' | 'codex_cli' | 'provider'; export type RuntimeLlmProviderPresetId = - | 'custom' - | 'openai' - | 'deepseek' - | 'anthropic' - | 'ark'; + 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; export type RuntimeAgentLlmProviderPresetId = - | 'inherit' - | RuntimeLlmProviderPresetId; + 'inherit' | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { + customEnabled?: boolean; + visibleModels?: string[]; apiKey: string; baseUrl: string; model: string; @@ -912,12 +901,7 @@ export interface AgentProgressEvent { } export type GameCreatorDirectTurnUpdateStatus = - | 'accepted' - | 'running' - | 'streaming' - | 'finalizing' - | 'completed' - | 'failed'; + 'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed'; export type GameCreatorDirectTurnActivity = | 'request-accepted' @@ -932,6 +916,55 @@ export type GameCreatorDirectTurnActivity = | 'response-finalization' | 'none'; +export type GameCreatorDirectToolCallKind = + | 'command' + | 'file_change' + | 'mcp_tool' + | 'web_search' + | 'context_compaction' + | 'other'; + +export type GameCreatorDirectToolCallStatus = + 'running' | 'completed' | 'failed'; + +export interface GameCreatorDirectToolCallChange { + path: string; + kind: 'add' | 'update' | 'delete' | string; +} + +export interface GameCreatorDirectToolCallDetail { + command?: string; + output?: string; + changes?: GameCreatorDirectToolCallChange[]; +} + +/** + * 一条工具调用(Codex item 的结构化投影)。 + * + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: + * 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件 + * `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于 + * 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。 + */ +export interface GameCreatorDirectToolCall { + schemaVersion: string; + id: string; + turnId: string; + kind: GameCreatorDirectToolCallKind; + title: string; + summary: string; + status: GameCreatorDirectToolCallStatus; + detail: GameCreatorDirectToolCallDetail; + startedAt: number; + updatedAt: number; +} + +/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ +export type GameCreatorDirectTurnToolCall = Omit< + GameCreatorDirectToolCall, + 'turnId' +>; + export interface GameCreatorDirectTurnUpdateEvent { projectPath: string; turnId: string; @@ -939,9 +972,69 @@ export interface GameCreatorDirectTurnUpdateEvent { status: GameCreatorDirectTurnUpdateStatus; activity?: GameCreatorDirectTurnActivity | null; accumulatedText?: string | null; + /** + * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 + * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 + */ + toolCalls?: GameCreatorDirectTurnToolCall[] | null; + /** + * 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。 + */ + reasoningText?: string | null; + /** + * 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。 + * + * 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据, + * 前端不再自己猜切点。可选:老版本事件没有这个字段。 + */ + streamItems?: TurnStreamItem[] | null; updatedAt: number; } +/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */ +export interface TurnStreamTextItem extends TurnStreamItemBase { + kind: 'text'; + text: string; +} + +/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */ +export interface TurnStreamToolItem extends TurnStreamItemBase { + kind: 'tool'; + callId: string; +} + +interface TurnStreamItemBase { + schemaVersion: string; + /** 幂等身份:文本段 `text::`、工具 `tool::`。 */ + id: string; + turnId: string; + /** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */ + seq: number; + /** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */ + at: number; + updatedAt: number; +} + +/** + * 回合流条目(`read_direct_turn_stream` 的返回元素)。 + * + * 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。 + */ +export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem; + +/** `cancel_direct_codex_turn` 的返回值。 */ +export interface DirectTurnCancelView { + /** + * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; + * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 + */ + outcome: string; + /** 给用户看的可读结果。 */ + message: string; + /** 被终止 / 被释放的 clientTurnId。 */ + clientTurnId: string; +} + export interface AgentRunControlResult { runId: string; status: string; diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css new file mode 100644 index 000000000..d212361a5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css @@ -0,0 +1,37 @@ +/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */ +.agc-markdown-code .hljs-comment, +.agc-markdown-code .hljs-quote { + color: #6a737d; +} + +.agc-markdown-code .hljs-keyword, +.agc-markdown-code .hljs-name, +.agc-markdown-code .hljs-selector-tag, +.agc-markdown-code .hljs-literal, +.agc-markdown-code .hljs-deletion { + color: #a6264c; +} + +.agc-markdown-code .hljs-string, +.agc-markdown-code .hljs-regexp, +.agc-markdown-code .hljs-addition { + color: #276438; +} + +.agc-markdown-code .hljs-number, +.agc-markdown-code .hljs-attr, +.agc-markdown-code .hljs-variable, +.agc-markdown-code .hljs-built_in { + color: #075a9c; +} + +.agc-markdown-code .hljs-title, +.agc-markdown-code .hljs-type, +.agc-markdown-code .hljs-section { + color: #6f42a0; +} + +.agc-markdown-code .hljs-meta, +.agc-markdown-code .hljs-symbol { + color: #8a4c0a; +} diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 3d79df338..d8acce153 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -1,3 +1,5 @@ +import './codeHighlight.css'; + import type { ErrorInfo, ReactNode } from 'react'; import { Children, @@ -7,14 +9,33 @@ import { useContext, } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; import remarkGfm from 'remark-gfm'; export type ChatMarkdownMessageProps = { text: string; role: 'assistant' | 'user'; streaming?: boolean; + /** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */ + preserveBlankLines?: boolean; }; +const MAX_HIGHLIGHT_CHARACTERS = 100_000; +const CodeBlockContext = createContext(false); + +/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */ +function normalizeMarkdownBlankLines(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split(/(```[\s\S]*?```)/g) + .map((part, index) => + index % 2 === 1 + ? part + : part.replace(/[ \t]*\n(?:[ \t]*\n){2,}/g, '\n\n'), + ) + .join(''); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -66,7 +87,6 @@ export class MarkdownErrorBoundary extends Component< } const ListDepthContext = createContext(0); -const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); type ListItemParagraphPosition = 'first' | 'continuation'; const ListItemContext = createContext(null); @@ -75,15 +95,11 @@ function MarkdownUnorderedList({ children }: { children?: ReactNode }) { const depth = useContext(ListDepthContext); return ( - -
    0 ? 'pl-4' : 'pl-0' - }`} - > - {children} -
-
+ {/* 用真正的列表标记(`list-disc`)而不是手写 `'- '` 文本:手写前缀既没有悬挂缩进 + (换行后的第二行会顶回最左边),也不算列表语义(读屏读成普通文本)。 */} +
    + {children} +
); } @@ -98,14 +114,12 @@ function MarkdownOrderedList({ const depth = useContext(ListDepthContext); return ( - -
    - {children} -
-
+
    + {children} +
); } @@ -145,7 +159,6 @@ function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) { } function MarkdownListItem({ children }: { children?: ReactNode }) { - const listKind = useContext(ListKindContext); let paragraphIndex = 0; const childrenWithParagraphContext = Children.map( children, @@ -168,7 +181,6 @@ function MarkdownListItem({ children }: { children?: ReactNode }) { ); return (
  • - {listKind === 'unordered' ? '- ' : null} {childrenWithParagraphContext}
  • ); @@ -179,22 +191,50 @@ const markdownComponents: Components = { a: ({ children }) => children, img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), h1: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h2: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h3: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h4: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h5: ({ children }) => ( -
    {children}
    +
    + {children} +
    ), h6: ({ children }) => ( -
    +
    {children}
    ), @@ -209,15 +249,18 @@ const markdownComponents: Components = { ), pre: ({ children }) => (
    -      {children}
    +      
    +        {children}
    +      
         
    ), - code: ({ className, children, node: _node, ...props }) => { - const isBlock = - Boolean(className?.includes('language-')) || - String(children).includes('\n'); + code: function MarkdownCode({ className, children, node: _node, ...props }) { + const isBlock = useContext(CodeBlockContext); return isBlock ? ( - + {children} ) : ( @@ -231,7 +274,10 @@ const markdownComponents: Components = { }, table: ({ children }) => (
    - +
    {children}
    @@ -260,6 +306,7 @@ export function ChatMarkdownMessage({ text, role, streaming = false, + preserveBlankLines = false, }: ChatMarkdownMessageProps) { if (role === 'user') { return {text}; @@ -270,11 +317,14 @@ export function ChatMarkdownMessage({ - {text} + {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)} ); 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/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 0eadbeacd..f859284e1 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -38,11 +38,16 @@ export function useDirectActiveTurns({ const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); + const retryTimerRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; }, []); @@ -73,12 +78,12 @@ export function useDirectActiveTurns({ return; } catch { if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { - await new Promise((resolve) => - window.setTimeout( - resolve, - DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, - ), - ); + await new Promise((resolve) => { + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null; + resolve(); + }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); + }); } } } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 386cefa8b..e2f6291ce 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -231,9 +231,9 @@ export function sameAgentRuntimeRun( ) { return Boolean( previous && - previous.agentId === state.agentId && - previous.sessionId === state.sessionId && - previous.runId === state.runId, + previous.agentId === state.agentId && + previous.sessionId === state.sessionId && + previous.runId === state.runId, ); } @@ -636,12 +636,12 @@ export function conversationContainsProjectSupervisorResponseStream( ) { return Boolean( stream?.accumulatedText.trim() && - messages.some( - (message) => - message.role === 'assistant' && - message.content === stream.accumulatedText && - message.updatedAt >= stream.startedAt, - ), + messages.some( + (message) => + message.role === 'assistant' && + message.content === stream.accumulatedText && + message.updatedAt >= stream.startedAt, + ), ); } @@ -789,8 +789,8 @@ export function agentRuntimeNeedsUserInput( ) { return Boolean( runtime?.userInputRequest || - runtime?.status === 'waiting-for-user-input' || - runtime?.phase === 'waiting-for-user-input', + runtime?.status === 'waiting-for-user-input' || + runtime?.phase === 'waiting-for-user-input', ); } @@ -1290,12 +1290,9 @@ export function isMissingAgentGoalCommandError(error: unknown) { } export function createDefaultChatMessages(): ChatMessage[] { - return [ - { - role: 'assistant', - text: '想做什么游戏?', - }, - ]; + // 默认问候「想做什么游戏?」已移除:它在对话记录里没有信息量,而且会出现在用户消息之后。 + // 空对话由空状态提示(panels.tsx 的引导文案)承担,不再往消息列表里塞占位消息。 + return []; } export function isRuntimeConfigMissingError(message: string) { @@ -1800,15 +1797,13 @@ function directPlatformFailureDetail(message: string) { return null; } const lower = trimmed.toLowerCase(); - if ( - !( - lower.includes('陶泥儿美术包生成失败') || - lower.includes('平台图片生成任务失败') || - lower.includes('external editor') || - lower.includes('透明美术图集') || - lower.includes('图集切片') - ) - ) { + if (!( + lower.includes('陶泥儿美术包生成失败') || + lower.includes('平台图片生成任务失败') || + lower.includes('external editor') || + lower.includes('透明美术图集') || + lower.includes('图集切片') + )) { return null; } if ( @@ -1860,14 +1855,12 @@ function directCodexFailureDetail(message: string) { function directRuntimeFailureDetail(message: string) { const trimmed = message.trim(); - if ( - !( - trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || - trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || - trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || - trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') - ) - ) { + if (!( + trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || + trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || + trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || + trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') + )) { return null; } if ( @@ -2162,9 +2155,9 @@ export function projectSupervisorPendingRepairMatchesProfessional( const summary = action?.inputSummary ?? ''; return Boolean( action?.tool === 'agent.delegate' && - runtime.delegationId && - summary.includes(`agentId=${runtime.agentId}`) && - summary.includes(`repairOf=${runtime.delegationId}`), + runtime.delegationId && + summary.includes(`agentId=${runtime.agentId}`) && + summary.includes(`repairOf=${runtime.delegationId}`), ); } 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..e4af6c8be 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 (