diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 07a5f736f..6a99b11c1 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -1,93 +1,70 @@ --- name: genarrative-external-editor-api -description: Guide use of Genarrative's external editor/canvas OpenAPI. Use when a user describes a canvas/editor integration need and Codex must infer the right `/api/external/v1` API automatically, prepare canvas and asset-library context, abstract reusable art specs before generating assets, upload references, draft curl/HTTP/SDK requests, or set up and safely handle a Genarrative developer API Key. +description: Guide use of Genarrative's hosted external editor/canvas MCP or asynchronous `/api/external/v1` OpenAPI. Use when an Agent needs to discover the hosted integration, choose a canvas or asset operation, upload local reference media, create or update projects and asset-library records, submit and poll image/video/audio generation, interpret generated artifacts and warnings, draft HTTP/Python calls, or securely handle a Genarrative developer API Key. --- # Genarrative External Editor API -Use the live OpenAPI contract as the source of truth: `GET https://www.genarrative.world/api/external/v1/openapi.json`. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`. If exact fields or enums matter, read the contract before emitting final code. +Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`. -Prefer the bundled Python helper for runnable examples: `scripts/genarrative_external_api.py`. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload/confirm/generation routes. +Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration. + +Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval. ## Workflow -1. At the start of a new conversation, ask the user for the canvas name before the first generation call unless an existing session is already provided. Create or use a project with that name and an asset-library folder with the same name. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state. -2. Before any art asset generation, abstract the user's request into a reusable art spec. Ask only for missing spec fields required by the selected asset type. If a current spec already exists and the user does not request a new style/spec, reuse it automatically. -3. Classify the user's natural-language intent. Do not ask the user to choose an API: - - "生成/生图/做一张图" -> image generation - - "重绘/修改这张图" -> image edit - - "用这张参考图/基于本地图生成" -> upload local reference image, then generation or edit - - "上传本地素材" -> upload ticket, OSS form upload, object confirm - - "保存画板/更新布局" -> canvas save - - "读取私有素材" -> signed read URL -4. Ask only for missing inputs that affect the request body or an actually ambiguous route: - - credentials JSON path only if the user cannot use the default local path - - canvas name when no current canvas session exists; existing `projectId`, folder/resource IDs only when resuming a known project - - media type, prompt, references, dimensions, model, ratio, duration, and resolution - - whether referenced media is already uploaded as `objectKey` or still local -5. Every external generation must write to both the canvas and the asset library. Include `projectId`, `assetFolderId`, a display label, and `canvasCompletion` whenever the target endpoint supports them. For character animation, use the helper's two-step fallback: generate with `projectId` + `canvasCompletion`, then create a library asset from the first returned frame in the session folder. -6. If the user lacks an API Key, guide setup before request design. -7. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference. -8. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or a chain that should execute with fewer hand-written curl steps. -9. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs. +1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST. +2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec. +3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints. +4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts. +5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to generation; never substitute a temporary signed URL. +6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder. +7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`. +8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request. +9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact. +10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope. -## Core Routes +## Essential Invariants -| Intent | Method and path | Required fields | -| --- | --- | --- | -| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` | -| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` | -| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` | -| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` | -| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` | -| Image edit/redraw | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | -| Icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | -| UI asset extraction | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize`; use `assetFolderId` for library folder | -| Character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | -| Video generation | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | -| Sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | -| Background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | +- Authenticate MCP and business API calls with `Authorization: Bearer `. Never ask the user to paste a key into chat or place one in repository files. +- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result. +- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again. +- Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access. +- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. +- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. +- Keep generated artifacts in the canvas and asset library together. Character animation may need a post-completion library fallback from the first returned frame when no direct asset is present; the helper implements it. -## Art Spec Interface +## Documentation Navigation -Maintain one current art spec per conversation. A compact spec is enough: +Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI: -```json -{ - "assetType": "character | background | prop | ui | icon | animation | video | audio", - "subject": "要生成的主体", - "style": "画风/材质/时代/参考风格", - "palette": "主色与禁用色", - "composition": "构图、镜头、姿态或布局", - "format": "比例、尺寸、分辨率、帧数、时长", - "constraints": "必须保留/禁止出现/透明或绿幕要求", - "references": ["objectKey 或本地路径说明"] -} -``` +- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG. +- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls. +- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs. +- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings. -For a first spec, infer fields from the user's words and ask only for missing fields that block the selected API. Examples: character animation needs source image/layer, dimensions, motion, ratio, frame count, and duration; UI extraction needs source design image plus target density; icon spritesheet needs reference image and icon descriptions. After a spec exists, reuse it for later assets unless the user changes style, subject family, palette, format, or constraints. +The hosted MCP exposes the same documents through: -## API Key +- `genarrative://external-editor/skill` +- `genarrative://external-editor/skill/references/capability-routing.md` +- `genarrative://external-editor/skill/references/api-operations.md` +- `genarrative://external-editor/skill/references/authentication-and-safety.md` +- `genarrative://external-editor/skill/references/requests-and-outputs.md` +- `genarrative://external-editor/openapi` -The external OpenAPI uses: +## Hosted Integration Discovery -```text -Authorization: Bearer -``` +- Manifest: `GET /api/external/v1/agent-integration.json`. +- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key. +- OpenAPI: `GET /api/external/v1/openapi.json`. +- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`. +- Complete Skill archive: `GET /api/external/v1/skill.zip`. -The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key. +The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication. -Use this fixed production base URL: +## Python Helper -```text -https://www.genarrative.world/ -``` - -Guide the user to create a key from the logged-in product UI under `开发者 API Key`. The raw key is shown only once; never ask the user to paste it into chat. Tell them to store it in this local private JSON file, outside the repository: - -```text -~/.config/genarrative/external-editor-api.json -``` +Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`: ```json { @@ -95,274 +72,45 @@ Guide the user to create a key from the logged-in product UI under `开发者 AP } ``` -Set the file readable only by the current user where possible: `chmod 600 ~/.config/genarrative/external-editor-api.json`. Do not use environment variables for this API. - -Smoke test by reading the JSON file, without printing the key: - -```bash -api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")" -curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \ - -H "Authorization: Bearer $api_key" -``` - -For generated client code, read `apiKey` from the JSON file, fail with a clear missing-config error, and redact keys in logs. - -Python smoke without printing the key: +Set restrictive permissions where possible, then smoke-test without printing the key: ```bash +chmod 600 ~/.config/genarrative/external-editor-api.json python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects ``` -## Request Patterns - -For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`. - -For Python callers, prefer: +For a canvas-backed generation: ```python from genarrative_external_api import GenarrativeExternalClient client = GenarrativeExternalClient() session = client.prepare_canvas_session("新画板") -art_spec = { - "assetType": "background", - "subject": "幻想森林主视觉", - "style": "手绘游戏概念图", - "palette": "翡翠绿、金色光斑,避免低饱和灰", - "composition": "16:9 横版,中心留出角色站位", - "format": "16:9, 1K", - "constraints": "无文字、无 UI 按钮", - "references": [], -} client.generate_image( - "生成幻想森林背景", + "生成一张 16:9 幻想森林游戏背景", canvasSession=session, assetLabel="森林背景", aspectRatio="16:9", imageSize="1K", - artSpec=art_spec, + artSpec={ + "assetType": "background", + "subject": "幻想森林主视觉", + "style": "手绘游戏概念图", + "palette": "翡翠绿与金色光斑", + "composition": "横版,中心留出角色站位", + "format": "16:9, 1K", + "constraints": "无文字、无 UI 按钮", + "references": [], + }, ) ``` -For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation: - -```python -client.generate_icon_spritesheet( - "editor-resource-current-art-spec", - ["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"], - canvasSession=session, - assetLabel="贪吃蛇透明图集", - screenColor="auto", -) -``` - -Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path. - -Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables. - -Use this shared base: - -```bash -api="https://www.genarrative.world" -credentials_file="$HOME/.config/genarrative/external-editor-api.json" -api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")" -auth=(-H "Authorization: Bearer $api_key") -json=(-H "Content-Type: application/json") -``` - -Create a project: - -```bash -curl -fsS "$api/api/external/v1/editor/projects" \ - "${auth[@]}" "${json[@]}" \ - -d '{"title":"新画板"}' -``` - -Generate an image and save it into both the canvas and the asset-library folder: - -```json -{ - "prompt": "一张横版幻想森林背景,适合游戏主视觉", - "kind": "spec", - "aspectRatio": "16:9", - "imageSize": "1K", - "projectId": "", - "assetFolderId": "", - "assetLabel": "森林背景", - "generationInputs": { - "artSpec": { - "assetType": "background", - "style": "手绘游戏概念图" - } - }, - "canvasCompletion": { - "title": "森林背景", - "placeholder": { - "x": 0, - "y": 0, - "width": 1024, - "height": 576, - "originalWidth": 1024, - "originalHeight": 576 - } - } -} -``` - -Then call `POST /api/external/v1/editor/images/generations`. - -For direct HTTP/curl, create or find the folder first with `GET /api/external/v1/editor/assets/library` and `POST /api/external/v1/editor/assets/folders`. The folder label should match the canvas name. - -## Reference Images - -When the user provides a local reference image path/file, upload it first; do not ask the user to convert it to base64. - -Python helper path: - -```python -from genarrative_external_api import GenarrativeExternalClient - -client = GenarrativeExternalClient() -session = client.prepare_canvas_session("新画板") -ref = client.upload_reference_image("/path/to/reference.png") -client.generate_image( - "基于参考图生成一张 16:9 游戏背景", - canvasSession=session, - assetLabel="参考图背景", - aspectRatio="16:9", - imageSize="1K", - referenceImageSrcs=[ref["objectKey"]], -) -``` - -Use the normal upload flow with: - -```json -{ - "legacyPrefix": "generated-character-drafts", - "pathSegments": ["editor", "external-editor-references"], - "fileName": "", - "contentType": "image/png", - "access": "private" -} -``` - -After OSS form upload, confirm the object with `assetKind: "editor_reference_image"`. Put the returned `objectKey` into the generation request: - -- image generation: `referenceImageSrcs` -- image edit/redraw: `sourceImageSrc`; extra references go in `referenceImageSrcs` -- icon spritesheet: `referenceImageSrc` -- UI asset extraction: `sourceImageSrc`; extra references go in `referenceImageSrcs` -- character animation: `sourceImageSrc` -- video generation image references: `referenceImageSrcs` - -Use `signedUrl` only for display/download. For generation requests, use `objectKey`, project resource ID, asset ID, public URL, or Data URL as the endpoint allows; prefer uploaded `objectKey` for local/private reference images. - -OSS form upload shape, using the ticket response saved as `ticket.json`. The default response has `upload`; if the caller explicitly requested the API response envelope, use `data.upload`: - -```bash -node - <<'NODE' ticket.json /path/to/reference.png -const fs = require('fs'); -const path = require('path'); - -(async () => { - const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); - const ticket = body.upload || body.data?.upload; - if (!ticket) throw new Error('Upload ticket response missing upload payload'); - const filePath = process.argv[3]; - const form = new FormData(); - for (const [key, value] of Object.entries(ticket.formFields)) { - if (value != null) form.append(key, value); - } - const bytes = fs.readFileSync(filePath); - form.append( - 'file', - new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }), - path.basename(filePath), - ); - const response = await fetch(ticket.host, { method: 'POST', body: form }); - if (!response.ok) { - throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`); - } -})().catch((error) => { - console.error(error.message); - process.exit(1); -}); -NODE -``` - -Then confirm with `contentLength`: - -```json -{ - "objectKey": "", - "contentType": "image/png", - "contentLength": 12345, - "assetKind": "editor_reference_image", - "accessPolicy": "private" -} -``` - -`contentLength` is a JSON number from the local file byte size, not a quoted string. - -For character animation from an uploaded local image, set: - -```json -{ - "sourceLayerId": "external-reference-hero", - "sourceImageSrc": "", - "sourceWidth": 720, - "sourceHeight": 1280, - "promptText": "让角色自然呼吸并轻微转身", - "resolution": "720p", - "ratio": "9:16", - "frameCount": 40, - "durationSeconds": 5, - "model": "seedance2.0-fast" -} -``` - -Use an existing canvas layer ID when the image came from a project layer. If it came only from a local upload, derive a stable synthetic `sourceLayerId` from the file name, for example `external-reference-hero`. Read `sourceWidth` and `sourceHeight` from the actual image before upload; ask the user only if the dimensions cannot be determined. - -The helper uses a 420 second timeout for generation calls, including character animation and video. Direct HTTP clients should not use a 70 second request timeout for animation. - -Character animation currently returns canvas completion data but not a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; the helper creates a library asset from the first returned frame in the session folder after the animation call succeeds. - -For video generation, always include `mode: "std"`. When using image/video/audio references, default to `model: "seedance2.0-fast"` unless the user asks for another listed model, because reference media support is limited to the Seedance 2.0 family. - -For image edit/redraw that should replace an existing canvas layer, pass `projectId` and `targetLayerId`. If the user instead gives an explicit `canvasCompletion`, let that placement win. - -For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation. - -## Successful Responses with Warnings - -Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists. - -- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation. -- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. -- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning. - -For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed. - -## AI Game Creator Canonical Visual DAG - -The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request: - -1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image. -2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`. -3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`. - -Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG. +Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`. ## Guardrails -- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. -- Do not omit canvas/library context for generation. New generated assets should enter both the canvas and the asset-library folder named after the canvas. -- Do not put API Keys in repository files, generated project files, command history snippets with literal secrets, logs, docs, commits, or screenshots. The only default storage is the user's local private JSON credentials file. -- Do not use account JWT endpoints as the default external integration path. The profile API can create/revoke keys for logged-in product users, but it is not part of the external editor OpenAPI. -- When an endpoint returns `project`, `resource`, or `asset`, treat those as the authoritative updated project/resource/asset snapshots. - -## Resources - -- `references/api-selection.md`: intent routing and required-field cheat sheet. -- `scripts/genarrative_external_api.py`: stdlib Python helper for OpenAPI fetch, API Key loading, local reference upload, object confirm, project/canvas calls, and generation requests. +- Do not change the fixed production base URL in generated examples. +- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets. +- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result. +- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response. +- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas. diff --git a/.codex/skills/genarrative-external-editor-api/agents/openai.yaml b/.codex/skills/genarrative-external-editor-api/agents/openai.yaml index a9b66dccb..446170018 100644 --- a/.codex/skills/genarrative-external-editor-api/agents/openai.yaml +++ b/.codex/skills/genarrative-external-editor-api/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Genarrative External Editor API" - short_description: "Auto-route canvas API generation" - default_prompt: "Use $genarrative-external-editor-api to prepare a canvas session, infer the right API, and generate assets into the canvas and library." + short_description: "Route async canvas generation safely" + default_prompt: "Use $genarrative-external-editor-api to discover the hosted integration, prepare a canvas session, and submit and poll asset generation into the canvas and library." policy: allow_implicit_invocation: true diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md new file mode 100644 index 000000000..06fbf30a1 --- /dev/null +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -0,0 +1,99 @@ +# API Operations + +Use this reference after selecting a capability. Treat `GET /api/external/v1/openapi.json` as authoritative for exact request/response schemas, required fields, constraints, and operation IDs. + +All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key. + +## Project and Canvas Operations + +| Operation | Method and path | Minimum input | +| --- | --- | --- | +| List projects | `GET /api/external/v1/editor/projects` | Authentication | +| Create project | `POST /api/external/v1/editor/projects` | Optional `title` | +| Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication | +| Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` | +| Delete project | `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` | +| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` | +| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` | +| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` | + +Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout. + +## Asset and Upload Operations + +| Operation | Method and path | Minimum input | +| --- | --- | --- | +| Create direct-upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` | +| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` | +| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` | +| Read asset library | `GET /api/external/v1/editor/assets/library` | Authentication | +| Create folder | `POST /api/external/v1/editor/assets/folders` | `label` | +| Update folder | `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `label` or `collapsed` | +| Delete folder | `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `folderId` | +| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` | +| Update asset record | `PATCH /api/external/v1/editor/assets/{assetId}` | `label` or `folderId` | +| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` | + +Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow. + +## Generation Operations + +Every generation row requires a stable `Idempotency-Key` header and returns HTTP `202` with an asynchronous submission, not the generated media. + +| Capability | POST path | Required body fields | Common optional body fields | +| --- | --- | --- | --- | +| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | +| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | +| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion` | +| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | +| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | + +Poll all eight through: + +```text +GET /api/external/v1/generations/{operationId} +``` + +Supply the `operationId` returned by submission. Poll no faster than `pollAfterMs` and retain the ID after a caller-side timeout. + +## Canvas and Library Field Rules + +- Pass `projectId` and `canvasCompletion` to write generated output into the canvas. +- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported. +- UI extraction uses `assetFolderId` and `spritesheetLabel`. +- Character animation does not accept the same library fields. If its completed compact result lacks a direct `asset`, create a library record from the first returned frame; do not duplicate one when an asset already exists. +- Reload project/library state after completion when full current state is required. + +## Reference Field Mapping + +After confirming a local upload, pass its stable `objectKey` into: + +| Target capability | Field | +| --- | --- | +| Image generation | `referenceImageSrcs` | +| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` | +| Icon spritesheet | `referenceImageSrc`; additional style references in `referenceImageSrcs` | +| UI design extraction | `sourceImageSrc`; additional references in `referenceImageSrcs` | +| Character animation | `sourceImageSrc` | +| Video with image references | `referenceImageSrcs` | + +Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference. + +## Common Values + +Use OpenAPI as the final authority; these common values are a routing aid: + +- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. +- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. +- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. +- Image `imageSize`: `0.5K`, `1K`, `2K`. +- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. +- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`. +- Video `resolution`: `480p`, `720p`, `1080p`; `mode`: `std`; `sound`: `on` or `off`. +- Character animation uses `model: "seedance2.0-fast"`; `resolution`: `480p` or `720p`; `frameCount`: `32`, `40`, or `48`; `durationSeconds`: `4`, `5`, or `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, or `3:4`. +- UI extraction uses `aspectRatio: "1:1"`; use `imageSize: "1K"` for normal/small extraction and `2K` for dense designs. + +Do not hard-code this list as a replacement client schema. In particular, the top-level image `style` field is intentionally extensible; see `requests-and-outputs.md` for its fallback behavior. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md deleted file mode 100644 index c3f18feb8..000000000 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ /dev/null @@ -1,239 +0,0 @@ -# External Editor API Routing - -Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`. - -## Base - -- Fixed base URL: `https://www.genarrative.world/`. -- Public contract: `GET /api/external/v1/openapi.json`. -- Authenticated calls: `Authorization: Bearer `. -- Default credentials file: `~/.config/genarrative/external-editor-api.json` with an `apiKey` string. -- Generation clients should allow long-running responses. Use at least 420 seconds for character animation and video; 70 seconds is too short for animation. - -## Canvas Session and Art Spec - -At the start of a new conversation, ask for a canvas name before the first generation call unless the user already supplied `projectId` and `assetFolderId`. Create or reuse: - -1. `POST /api/external/v1/editor/projects` with `title` = canvas name. -2. `GET /api/external/v1/editor/assets/library`; if no folder has the same label, `POST /api/external/v1/editor/assets/folders` with `label` = canvas name. -3. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state. - -Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful. - -For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain: - -1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`. -2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`. - -Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG. - -## Intent Routing - -Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous. - -| User says | Route | -| --- | --- | -| "生成图片", "生图", "做一张背景/角色/宣发图" | `POST /api/external/v1/editor/images/generations` | -| "重绘", "调整这张图", "基于这张图修改" | `POST /api/external/v1/editor/images/edits` | -| "用这张参考图", "参考本地图片生成", "基于本地图做图" | Upload local image first, then pass returned `objectKey` into the generation/edit reference field | -| "按规范图生成图标", "拆图标" | `POST /api/external/v1/editor/icon-spritesheets/generations` | -| "从 UI 设计图提取素材" | `POST /api/external/v1/editor/ui-designs/assets/extractions` | -| "让角色动起来", "生成角色动画帧" | `POST /api/external/v1/editor/character-animations/generations` | -| "生成视频" | `POST /api/external/v1/editor/videos/generations` | -| "生成音效" | `POST /api/external/v1/editor/audios/sound-effects/generations` | -| "生成背景音乐/BGM" | `POST /api/external/v1/editor/audios/background-music/generations` | -| "上传本地素材/图片/音频/视频" | Upload flow: direct upload ticket -> OSS form upload -> object confirm | -| "保存画板布局" | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | -| "创建/读取/删除画板项目" | Project endpoints | -| "素材库/文件夹/素材记录" | Asset library endpoints | -| "读取私有素材/拿可访问链接" | `GET /api/external/v1/assets/read-url` | - -Ask a follow-up only when two routes could both be correct and produce different artifacts, for example "处理这张图" without saying edit, extract UI assets, or use it as a reference for new generation. - -## Endpoint Map - -| User intent | Endpoint | Minimum request | -| --- | --- | --- | -| Read contract | `GET /api/external/v1/openapi.json` | No auth required | -| List projects | `GET /api/external/v1/editor/projects` | API Key | -| Create project | `POST /api/external/v1/editor/projects` | Optional `title` | -| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key | -| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` | -| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` | -| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` | -| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` | -| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` | -| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` | -| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` | -| Read asset library | `GET /api/external/v1/editor/assets/library` | API Key | -| Create/update/delete folder | `POST /api/external/v1/editor/assets/folders`, `PATCH`/`DELETE /api/external/v1/editor/assets/folders/{folderId}` | create: `label`; update: `label` or `collapsed` | -| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` | -| Update/delete asset | `PATCH`/`DELETE /api/external/v1/editor/assets/{assetId}` | update: `label` or `folderId` | - -## Generation Endpoints - -| User intent | Endpoint | Required fields | Common optional fields | -| --- | --- | --- | --- | -| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | -| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | -| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | -| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | -| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame | -| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | -| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | -| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | - -## Image Post-processing Style - -The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting. - -- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning. -- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation. -- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`. -- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum. - -Image or character generation with pixel-art snapping: - -```json -{ - "prompt": "生成一个正面站立的像素风冒险者角色", - "kind": "character", - "style": "pixelArt" -} -``` - -Icon spritesheet generation with pixel-art snapping: - -```json -{ - "referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png", - "iconDescriptions": ["木剑", "圆盾", "红色药水"], - "style": "pixelArt" -} -``` - -All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`. - -## HTTP 2xx Warning Handling - -Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist. - -- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch. -- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning. -- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one. - -## Reference Image Upload - -If the user provides a local file as a reference image, run upload before the generation request: - -1. `POST /api/external/v1/assets/direct-upload-tickets`. - Use `legacyPrefix: "generated-character-drafts"`, `pathSegments: ["editor", "external-editor-references"]`, original `fileName`, detected image `contentType`, and `access: "private"`. -2. Upload the file to the returned OSS form endpoint with all returned `formFields`. -3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey`, detected `contentType`, `contentLength` if known, `assetKind: "editor_reference_image"`, and `accessPolicy: "private"`. -4. Use the returned `objectKey` in the actual editor request. - -OSS form upload uses `upload.host` and every non-null `upload.formFields` entry, then the file part named `file`. Default responses expose `upload`; envelope responses expose `data.upload`. Save the upload ticket response as `ticket.json`: - -```bash -node - <<'NODE' ticket.json /path/to/reference.png -const fs = require('fs'); -const path = require('path'); - -(async () => { - const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); - const ticket = body.upload || body.data?.upload; - if (!ticket) throw new Error('Upload ticket response missing upload payload'); - const filePath = process.argv[3]; - const form = new FormData(); - for (const [key, value] of Object.entries(ticket.formFields)) { - if (value != null) form.append(key, value); - } - const bytes = fs.readFileSync(filePath); - form.append( - 'file', - new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }), - path.basename(filePath), - ); - const response = await fetch(ticket.host, { method: 'POST', body: form }); - if (!response.ok) { - throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`); - } -})().catch((error) => { - console.error(error.message); - process.exit(1); -}); -NODE -``` - -Field mapping after upload: - -| Target API | Put uploaded `objectKey` in | -| --- | --- | -| Image generation | `referenceImageSrcs` | -| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` | -| Icon spritesheet | `referenceImageSrc`; additional style refs in `referenceImageSrcs` | -| UI design extraction | `sourceImageSrc`; additional refs in `referenceImageSrcs` | -| Character animation | `sourceImageSrc` | -| Video generation with image references | `referenceImageSrcs` | - -Do not put the signed read URL into generation fields. Signed URLs are for user-visible preview/download; generation fields should use the stable `objectKey` for uploaded private references. - -## Common Enums - -- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`. -- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. -- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. -- Image `imageSize`: `0.5K`, `1K`, `2K`. -- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`. -- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`. -- Video `resolution`: `480p`, `720p`, `1080p`. -- Video `mode`: always `std`. -- Video `sound`: `on`, `off`. -- Character animation `model`: always `seedance2.0-fast`. -- Character animation `resolution`: `480p`, `720p`; `frameCount`: `32`, `40`, `48`; `durationSeconds`: `4`, `5`, `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, `3:4`. - -## Local Reference Media Details - -- `contentLength` in object confirm is a JSON number from local byte size, not a string. -- For character animation, use an existing project layer ID as `sourceLayerId` when available. -- If the source is only an uploaded local image, derive `sourceLayerId` from the file name, such as `external-reference-hero`, and keep it stable across retries. -- Read `sourceWidth` and `sourceHeight` from the local image. If dimensions cannot be read, ask instead of inventing dimensions. -- UI design extraction uses fixed `aspectRatio: "1:1"`; choose `imageSize: "1K"` for normal/small extractions and `2K` for dense designs. -- Video image/video/audio references are supported only by the Seedance 2.0 family; default referenced-media video requests to `model: "seedance2.0-fast"`, `mode: "std"`, and explicit `sound`. -- Image edit/redraw can pass `targetLayerId` with `projectId` to replace an existing canvas layer when no explicit `canvasCompletion` is supplied. -- Image, edit, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record. -- Icon spritesheet and UI extraction can pass `assetFolderId`; UI extraction can also pass `spritesheetLabel`. - -## Canvas Completion - -Use `canvasCompletion` for generation in this skill so the generated result is written back into the project canvas by the backend. - -Required: - -```json -{ - "title": "素材名称", - "placeholder": { - "x": 0, - "y": 0, - "width": 512, - "height": 512, - "originalWidth": 512, - "originalHeight": 512 - } -} -``` - -`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally. - -## Upload Flow - -For a local file that should become a project resource or library asset: - -1. `POST /api/external/v1/assets/direct-upload-tickets` with `legacyPrefix`, `fileName`, and optional `contentType`, `access`, `maxSizeBytes`. -2. Submit the file to the returned OSS form endpoint with returned `formFields`. -3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey` and an `assetKind`. -4. Create a project resource or library asset with the confirmed `assetObjectId`/`objectKey`. - -For reading private/generated assets, call `GET /api/external/v1/assets/read-url?objectKey=...` and use the returned `signedUrl`. diff --git a/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md b/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md new file mode 100644 index 000000000..ec51c8675 --- /dev/null +++ b/.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md @@ -0,0 +1,146 @@ +# Authentication and Safety + +Read this reference before handling credentials, local files, private objects, uploads, retries, or logs. + +## Contents + +- [API Key Setup](#api-key-setup) +- [Idempotency and Unknown Outcomes](#idempotency-and-unknown-outcomes) +- [Local Reference Upload](#local-reference-upload) +- [Stable and Temporary Media References](#stable-and-temporary-media-references) +- [Logging and Command Safety](#logging-and-command-safety) +- [Scope and Retry Guardrails](#scope-and-retry-guardrails) + +## API Key Setup + +Authenticated calls use: + +```text +Authorization: Bearer +``` + +Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat. + +Store it outside repositories in the user's private JSON file: + +```text +~/.config/genarrative/external-editor-api.json +``` + +```json +{ + "apiKey": "tnr_sk_..." +} +``` + +Set the file readable only by the current user where supported: + +```bash +chmod 600 ~/.config/genarrative/external-editor-api.json +``` + +Use this fixed production base URL: + +```text +https://www.genarrative.world/ +``` + +Do not use environment variables as the default API Key storage for this integration. Generated clients must load the JSON file, fail clearly when it is absent or malformed, and redact credentials from errors and logs. + +Smoke-test without printing the key: + +```bash +api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")" +curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \ + -H "Authorization: Bearer $api_key" +``` + +The OpenAPI document, integration manifest, raw Skill entry, and Skill archive are public. Hosted MCP and all project, asset, upload, generation, and generation-query operations require the Bearer API Key. + +## Idempotency and Unknown Outcomes + +For each logical generation: + +1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes. +2. Persist the key with the exact request body and returned `operationId`. +3. If submission transport fails or the response is lost, resend only the exact same body with the same key. +4. Never allocate a new key merely because the outcome is unknown. +5. On a polling timeout, retain `operationId` and query later. Do not submit another generation. + +Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation. + +## Local Reference Upload + +Do not ask the user to convert local files to base64. Upload from the Agent/client machine: + +1. Detect the original filename, MIME type, byte length, and image dimensions when relevant. +2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`. +3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`. +4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`. +5. Pass the confirmed stable `objectKey` to the selected editor operation. + +For a private reference image, use a ticket body shaped like: + +```json +{ + "legacyPrefix": "generated-character-drafts", + "pathSegments": ["editor", "external-editor-references"], + "fileName": "", + "contentType": "image/png", + "access": "private" +} +``` + +The default response exposes `upload`; an explicitly enveloped response exposes `data.upload`. Treat the returned host and form fields as opaque. Do not log the entire ticket or persist it longer than needed. + +Confirm with the actual file metadata: + +```json +{ + "objectKey": "", + "contentType": "image/png", + "contentLength": 12345, + "assetKind": "editor_reference_image", + "accessPolicy": "private" +} +``` + +`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined. + +For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries. + +The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs: + +```python +from genarrative_external_api import GenarrativeExternalClient + +client = GenarrativeExternalClient() +reference = client.upload_reference_image("/path/to/reference.png") +print(reference["objectKey"]) +``` + +Do not print the complete confirmation response if it may contain temporary access data. Prefer passing the returned `objectKey` directly to the next call. + +## Stable and Temporary Media References + +- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input. +- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output. +- Never use a Blob URL outside the browser process that created it. +- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download. +- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists. + +## Logging and Command Safety + +- Never place an API Key in repository files, generated projects, command arguments containing a literal key, docs, commits, screenshots, stack traces, test fixtures, or telemetry. +- Redact `Authorization`, API Key values, upload signatures, cookies, signed URL query strings, and private absolute paths from logs and user-visible errors. +- Do not print credentials while diagnosing JSON configuration. Report only presence/absence and safe validation errors. +- Do not commit the credentials file or copy it into the Skill archive. +- Do not expose provider diagnostics, worker leases, queue internals, or server filesystem paths returned by an unexpected error. + +## Scope and Retry Guardrails + +- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract. +- Do not call internal workers, queues, SpacetimeDB, or admin endpoints. +- Do not bypass upload confirmation or invent an object key. +- Do not retry post-processing locally by fabricating assets. Respect completed warning semantics from `requests-and-outputs.md`. +- Use bounded polling. A local wait budget ending does not cancel or fail the server operation. diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md new file mode 100644 index 000000000..b89050080 --- /dev/null +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -0,0 +1,87 @@ +# Capability Routing + +Use this reference to translate user intent into a hosted MCP tool or its corresponding External v1 REST operation. Use `genarrative://external-editor/openapi` or `GET /api/external/v1/openapi.json` for exact schemas. + +## Integration Surface + +- Fixed production base URL: `https://www.genarrative.world/`. +- Discovery manifest: `GET /api/external/v1/agent-integration.json`. +- Hosted MCP: `/api/external/v1/mcp`, Streamable HTTP, authenticated with the same Bearer API Key as REST. +- Public contract: `GET /api/external/v1/openapi.json`. +- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`. + +Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name. + +## Canvas Session + +Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`. + +1. List or create a project. When creating one, use the canvas name as `title`. +2. Read the asset library. Reuse a folder with the same label or create one with the canvas name. +3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state. + +Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation may return no direct library asset; after completion, create one from the first returned frame only when the compact result still lacks an asset. + +## Art Spec Routing + +Before art generation, normalize the user's request into: + +```json +{ + "assetType": "character | background | prop | ui | icon | animation | video | audio", + "subject": "要生成的主体", + "style": "画风、材质、时代或参考风格", + "palette": "主色与禁用色", + "composition": "构图、镜头、姿态或布局", + "format": "比例、尺寸、分辨率、帧数或时长", + "constraints": "必须保留、禁止出现、透明或绿幕要求", + "references": ["objectKey、资源 ID 或本地文件说明"] +} +``` + +Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Store structured context under `generationInputs.artSpec` where supported and summarize it in the prompt when useful. + +## Intent Map + +| User intent | MCP/REST capability | +| --- | --- | +| Generate a background, character, spec, UI mockup, or publication image | Image generation | +| Redraw, retouch, or replace an existing image | Image edit | +| Generate from a local reference | Upload and confirm the local file, then image generation or edit | +| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation | +| Extract marked assets from an existing UI design | UI design asset extraction | +| Animate a character into frames | Character animation generation | +| Generate video | Video generation | +| Generate a sound effect | Sound-effect generation | +| Generate background music/BGM | Background-music generation | +| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm | +| Save viewport/layers | Canvas save | +| Create, load, rename, or delete a canvas | Project operations | +| Organize folders and asset records | Asset-library operations | +| Obtain temporary access to private media | Signed read URL | +| Check generation progress or retrieve its result | Generation query | + +Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation. + +## Route-Specific Decisions + +- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied. +- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects. +- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation. +- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename. +- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`. +- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation. + +## AI Game Creator Canonical Visual DAG + +Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request: + +1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. +2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`. + +Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. + +## Scope Boundary + +Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md new file mode 100644 index 000000000..cbd26dfa7 --- /dev/null +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -0,0 +1,236 @@ +# Requests and Outputs + +Use this reference to build generation payloads, carry canvas/library context, poll asynchronous jobs, and interpret compact completed results. Verify exact schemas against `GET /api/external/v1/openapi.json`. + +## Contents + +- [Asynchronous Submission](#asynchronous-submission) +- [Polling State Machine](#polling-state-machine) +- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion) +- [Art Spec and Image Request](#art-spec-and-image-request) +- [Local Reference Requests](#local-reference-requests) +- [Compact Completed Result](#compact-completed-result) +- [Warning Semantics](#warning-semantics) +- [Output Handling Checklist](#output-handling-checklist) + +## Asynchronous Submission + +All eight generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like: + +```json +{ + "operationId": "task-...", + "kind": "editor_image_generation", + "status": "queued", + "statusUrl": "/api/external/v1/generations/task-...", + "pollAfterMs": 1500, + "updatedAtMicros": 1785456000000000 +} +``` + +The response acknowledges durable submission only. It is never the completed media response. + +Submit with one stable key per logical request: + +```bash +api="https://www.genarrative.world" +credentials_file="$HOME/.config/genarrative/external-editor-api.json" +api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")" +idempotency_key="$(node -e 'process.stdout.write(require("node:crypto").randomUUID())')" + +submission="$(curl -fsS "$api/api/external/v1/editor/images/generations" \ + -H "Authorization: Bearer $api_key" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $idempotency_key" \ + -d @request.json)" +operation_id="$(node -e 'const v=JSON.parse(process.argv[1]); process.stdout.write(v.operationId || v.data?.operationId || "")' "$submission")" +``` + +Persist the key, exact request body, and `operationId`. If submission outcome is uncertain, reuse the same body and key; do not submit a replacement key. + +## Polling State Machine + +Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`: + +- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again. +- `completed`: consume the compact `result` and all warning fields, then stop polling. +- `failed`: surface the safe `error`, stop polling, and do not infer provider or worker internals. + +A caller-side timeout leaves the operation pending. Persist the ID for later query. Do not keep the original POST connection open and do not infer failure from a local wait budget. + +The helper's convenience generation methods block only in the local process while sending short submit and status requests. Its default overall wait budget is 1800 seconds. For explicit orchestration: + +```python +submission = client.submit_generation( + "/api/external/v1/editor/images/generations", + request_body, + idempotency_key=stable_key, +) +operation_id = submission["operationId"] +status = client.get_generation(operation_id) +completed = client.wait_for_generation(operation_id) +``` + +## Canvas and Asset-Library Completion + +For endpoints that support these fields, include: + +- `projectId`: target canvas project. +- `assetFolderId`: folder whose label matches the canvas name. +- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label. +- `canvasCompletion`: backend canvas placement instructions. + +A minimal `canvasCompletion` is: + +```json +{ + "title": "素材名称", + "placeholder": { + "x": 0, + "y": 0, + "width": 1024, + "height": 576, + "originalWidth": 1024, + "originalHeight": 576 + } +} +``` + +`dialogId` is optional. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed. + +Character animation may complete without a direct `asset` field. To preserve the canvas/library invariant, create a library asset from the first returned frame only if the compact result lacks one. Prefer `client.animate_character(..., canvasSession=session, canvasTitle="...")`, which implements this fallback. + +## Art Spec and Image Request + +Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt: + +```json +{ + "prompt": "一张横版幻想森林背景,适合游戏主视觉,无文字", + "aspectRatio": "16:9", + "imageSize": "1K", + "projectId": "", + "assetFolderId": "", + "assetLabel": "森林背景", + "generationInputs": { + "artSpec": { + "assetType": "background", + "subject": "幻想森林主视觉", + "style": "手绘游戏概念图", + "palette": "翡翠绿与金色光斑", + "composition": "横版,中心留出角色站位", + "format": "16:9, 1K", + "constraints": "无文字、无 UI 按钮", + "references": [] + } + }, + "canvasCompletion": { + "title": "森林背景", + "placeholder": { + "x": 0, + "y": 0, + "width": 1024, + "height": 576, + "originalWidth": 1024, + "originalHeight": 576 + } + } +} +``` + +The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing: + +- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning. +- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation. +- Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`. +- Non-string JSON values: malformed request, HTTP `400`. + +Keep this field extensible. Do not impose a closed client enum beyond the server contract. + +## Local Reference Requests + +Upload and confirm a local file before generation, then use the stable `objectKey`: + +```python +client = GenarrativeExternalClient() +session = client.prepare_canvas_session("新画板") +reference = client.upload_reference_image("/path/to/reference.png") +client.generate_image( + "基于参考图生成一张 16:9 游戏背景", + canvasSession=session, + assetLabel="参考图背景", + aspectRatio="16:9", + imageSize="1K", + referenceImageSrcs=[reference["objectKey"]], +) +``` + +For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID: + +```json +{ + "sourceLayerId": "external-reference-hero", + "sourceImageSrc": "", + "sourceWidth": 720, + "sourceHeight": 1280, + "promptText": "让角色自然呼吸并轻微转身", + "resolution": "720p", + "ratio": "9:16", + "frameCount": 40, + "durationSeconds": 5, + "model": "seedance2.0-fast" +} +``` + +Do not guess dimensions or pass a temporary signed read URL. See `authentication-and-safety.md` for upload and credential rules. + +## Compact Completed Result + +The completed `result` may contain stable artifact fields such as: + +- `objectKey`, media type, dimensions, or task ID. +- `resource`, `resourceId`, or equivalent canvas reference. +- `asset`, `assetId`, or equivalent library reference. +- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata. +- `warning` and `sliceWarning` structures. + +It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`. + +## Warning Semantics + +Interpret warnings only after the query reaches `status=completed`. The query-level `warning` is display-ready text. Compact `result.warning` and `result.sliceWarning` preserve structured artifact semantics. + +### Source-preserved post-processing failure + +When `result.warning.code` is `postprocess-failed-source-preserved`: + +- Treat the saved provider source as the authoritative main result. +- For character output, do not claim a transparent derivative. +- For icon spritesheet or UI extraction, do not claim a transparent spritesheet or individual slices. +- Display the safe reason. +- Do not fabricate derivatives or restart generation automatically. + +Use `resource` / `asset` for character results and `spritesheetResource` / `spritesheetAsset` for icon/UI results, then reload authoritative project/library state. + +### Slice failure after transparent-sheet success + +`result.sliceWarning` means transparent spritesheet post-processing succeeded but automatic splitting failed: + +- Continue using the complete transparent spritesheet. +- Do not claim individual slices. +- Display the slice reason. + +### Coexisting warnings + +`warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that path never reaches slicing. A general warning from unsupported style normalization or pixel-art snapping can coexist with `sliceWarning`. Render both reasons. + +Before registering a requested transparent deliverable, verify the full sheet actually contains transparency. If source-preserved warning is present, do not register the opaque provider source as the requested transparent atlas. If only `sliceWarning` is present, the transparent full sheet remains valid. + +## Output Handling Checklist + +1. Require terminal `completed` before consuming artifacts. +2. Preserve stable IDs and `objectKey` values. +3. Surface all warning channels without downgrading completion to failure. +4. Avoid claiming absent transparent derivatives or slices. +5. Obtain temporary preview/download URLs only through `/assets/read-url`. +6. Reload authoritative project and library state when downstream logic needs complete records. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index a099fd8c9..0e45ccf6d 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -11,6 +11,7 @@ import re import struct import sys import tempfile +import time import urllib.error import urllib.parse import urllib.request @@ -22,7 +23,7 @@ from typing import Any BASE_URL = "https://www.genarrative.world/" DEFAULT_CREDENTIALS_FILE = Path.home() / ".config/genarrative/external-editor-api.json" DEFAULT_REQUEST_TIMEOUT_SECONDS = 60 -GENERATION_REQUEST_TIMEOUT_SECONDS = 420 +GENERATION_WAIT_TIMEOUT_SECONDS = 1800 class GenarrativeApiError(RuntimeError): @@ -125,17 +126,18 @@ class GenarrativeExternalClient: query: dict[str, Any] | None = None, auth: bool = True, timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, + headers: dict[str, str] | None = None, ) -> Any: url = f"{self.base_url}{path}" if query: url = f"{url}?{urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})}" data = None if body is None else json.dumps(body).encode("utf-8") - headers = {"Accept": "application/json"} + request_headers = {"Accept": "application/json", **(headers or {})} if data is not None: - headers["Content-Type"] = "application/json" + request_headers["Content-Type"] = "application/json" if auth: - headers["Authorization"] = f"Bearer {self.api_key}" - request = urllib.request.Request(url, data=data, headers=headers, method=method.upper()) + request_headers["Authorization"] = f"Bearer {self.api_key}" + request = urllib.request.Request(url, data=data, headers=request_headers, method=method.upper()) try: with urllib.request.urlopen(request, timeout=timeout) as response: payload = response.read() @@ -440,24 +442,120 @@ class GenarrativeExternalClient: def read_url(self, object_key: str) -> Any: return self.request_json("GET", "/api/external/v1/assets/read-url", query={"objectKey": object_key}) + def submit_generation( + self, + path: str, + body: dict[str, Any], + idempotency_key: str | None = None, + ) -> dict[str, Any]: + key = normalize_optional_text(idempotency_key) or str(uuid.uuid4()) + submission = None + for attempt in range(2): + try: + submission = self.request_json( + "POST", + path, + body, + headers={"Idempotency-Key": key}, + ) + break + except (urllib.error.URLError, TimeoutError) as error: + if attempt == 0: + time.sleep(0.5) + continue + raise GenarrativeApiError( + "Generation submission transport outcome is unknown. " + f"Retry the same body with Idempotency-Key {key}; do not create a new key." + ) from error + if not isinstance(submission, dict) or not normalize_optional_text(submission.get("operationId")): + raise GenarrativeApiError("Generation submission response missing operationId.") + submission.setdefault("idempotencyKey", key) + return submission + + def get_generation(self, operation_id: str) -> dict[str, Any]: + result = self.request_json( + "GET", + f"/api/external/v1/generations/{urllib.parse.quote(operation_id, safe='')}", + ) + if not isinstance(result, dict): + raise GenarrativeApiError("Generation status response must be an object.") + return result + + def wait_for_generation( + self, + submission_or_operation_id: dict[str, Any] | str, + timeout_seconds: int = GENERATION_WAIT_TIMEOUT_SECONDS, + ) -> dict[str, Any]: + operation_id = ( + submission_or_operation_id.get("operationId") + if isinstance(submission_or_operation_id, dict) + else submission_or_operation_id + ) + operation_id = normalize_optional_text(operation_id) + if not operation_id: + raise GenarrativeApiError("Generation operationId is required.") + deadline = time.monotonic() + max(1, timeout_seconds) + while True: + try: + job = self.get_generation(operation_id) + except GenarrativeApiError as error: + if any(f"HTTP {status}" in str(error) for status in (429, 502, 503, 504)): + if time.monotonic() >= deadline: + raise GenarrativeApiError( + f"Generation {operation_id} is still running; keep this operationId and continue polling." + ) from error + time.sleep(1.5) + continue + raise + status = job.get("status") + if status == "completed": + result = job.get("result") + if not isinstance(result, dict): + raise GenarrativeApiError( + f"Generation {operation_id} completed without a result payload." + ) + return result + if status == "failed": + raise GenarrativeApiError( + f"Generation {operation_id} failed: {job.get('error') or 'unknown error'}" + ) + if time.monotonic() >= deadline: + raise GenarrativeApiError( + f"Generation {operation_id} is still running; keep this operationId and continue polling." + ) + poll_after_ms = job.get("pollAfterMs", 1500) + if not isinstance(poll_after_ms, (int, float)): + poll_after_ms = 1500 + time.sleep(max(0.25, min(float(poll_after_ms) / 1000.0, 5.0))) + + def submit_and_wait_generation( + self, + path: str, + body: dict[str, Any], + idempotency_key: str | None = None, + timeout_seconds: int = GENERATION_WAIT_TIMEOUT_SECONDS, + ) -> dict[str, Any]: + submission = self.submit_generation(path, body, idempotency_key=idempotency_key) + return self.wait_for_generation(submission, timeout_seconds=timeout_seconds) + def generate_image(self, prompt: str, **fields: Any) -> Any: self._apply_canvas_session_fields(fields, prompt, 1024, 1024) prompt = self._apply_art_spec(fields, prompt) - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/images/generations", {"prompt": prompt, **fields}, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def edit_image(self, prompt: str, source_image_src: str, **fields: Any) -> Any: self._apply_canvas_session_fields(fields, prompt, 1024, 1024) prompt = self._apply_art_spec(fields, prompt) - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/images/edits", {"prompt": prompt, "sourceImageSrc": source_image_src, **fields}, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def generate_icon_spritesheet( @@ -472,25 +570,25 @@ class GenarrativeExternalClient: label = fields.get("assetLabel", "图标图集") self._apply_canvas_session_fields(fields, label, 1024, 1024) fields.setdefault("screenColor", "auto") - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/icon-spritesheets/generations", { "referenceImageSrc": reference_image_src, "iconDescriptions": descriptions, **fields, }, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any: fields.pop("aspectRatio", None) self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel") - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/ui-designs/assets/extractions", {"sourceImageSrc": source_image_src, "imageSize": image_size, **fields, "aspectRatio": "1:1"}, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def animate_character( @@ -512,6 +610,7 @@ class GenarrativeExternalClient: prompt_text = self._apply_art_spec(fields, prompt_text) fields.pop("assetFolderId", None) fields.pop("assetLabel", None) + idempotency_key = fields.pop("idempotencyKey", None) body = { "sourceLayerId": source_layer_id, "sourceImageSrc": source_image_src, @@ -525,11 +624,10 @@ class GenarrativeExternalClient: **fields, "model": "seedance2.0-fast", } - result = self.request_json( - "POST", + result = self.submit_and_wait_generation( "/api/external/v1/editor/character-animations/generations", body, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) if isinstance(session, dict) and isinstance(result, dict) and not result.get("asset"): frames = result.get("frames") @@ -559,6 +657,7 @@ class GenarrativeExternalClient: fields.pop("mode", None) self._apply_canvas_session_fields(fields, prompt, 1280, 720) prompt = self._apply_art_spec(fields, prompt) + idempotency_key = fields.pop("idempotencyKey", None) body = { "prompt": prompt, "model": fields.pop("model", "seedance2.0-fast"), @@ -569,31 +668,30 @@ class GenarrativeExternalClient: **fields, "mode": "std", } - return self.request_json( - "POST", + return self.submit_and_wait_generation( "/api/external/v1/editor/videos/generations", body, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def generate_sound_effect(self, prompt: str, duration: int, **fields: Any) -> Any: self._apply_canvas_session_fields(fields, prompt, 360, 120) prompt = self._apply_art_spec(fields, prompt) - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/audios/sound-effects/generations", {"prompt": prompt, "duration": duration, **fields}, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) def generate_background_music(self, description: str, **fields: Any) -> Any: self._apply_canvas_session_fields(fields, description, 360, 120) description = self._apply_art_spec(fields, description) - return self.request_json( - "POST", + idempotency_key = fields.pop("idempotencyKey", None) + return self.submit_and_wait_generation( "/api/external/v1/editor/audios/background-music/generations", {"gptDescriptionPrompt": description, **fields, "makeInstrumental": True}, - timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + idempotency_key=idempotency_key, ) @@ -625,17 +723,29 @@ def _self_test() -> None: query: dict[str, Any] | None = None, auth: bool = True, timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, + headers: dict[str, str] | None = None, ) -> Any: - calls.append({"method": method, "path": path, "body": body, "timeout": timeout}) + calls.append({ + "method": method, + "path": path, + "body": body, + "timeout": timeout, + "headers": headers, + }) if path == "/api/external/v1/editor/assets": return {"asset": {"assetId": "editor-asset-demo"}} - return { + generated = { "taskId": "task-demo", "model": "seedance2.0-fast", "prompt": "角色呼吸", "previewVideoPath": "/generated/preview.mp4", "frames": [{"frameIndex": 1, "imageSrc": "/generated/frame01.png", "width": 512, "height": 768}], } + if method == "POST": + return {"operationId": "task-operation-demo", "status": "queued", "pollAfterMs": 1} + if path == "/api/external/v1/generations/task-operation-demo": + return {"operationId": "task-operation-demo", "status": "completed", "result": generated} + return generated client.request_json = fake_request_json # type: ignore[method-assign] result = client.animate_character( @@ -647,10 +757,12 @@ def _self_test() -> None: canvasSession=session, canvasTitle="角色呼吸动画", ) - assert calls[0]["timeout"] == GENERATION_REQUEST_TIMEOUT_SECONDS + assert calls[0]["timeout"] == DEFAULT_REQUEST_TIMEOUT_SECONDS + assert calls[0]["headers"]["Idempotency-Key"] assert calls[0]["body"]["projectId"] == "proj-demo" assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画" - assert calls[1]["path"] == "/api/external/v1/editor/assets" + assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" + assert calls[2]["path"] == "/api/external/v1/editor/assets" assert result["asset"]["assetId"] == "editor-asset-demo" calls.clear() client.generate_icon_spritesheet( @@ -663,6 +775,7 @@ def _self_test() -> None: assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec" assert calls[0]["body"]["screenColor"] == "auto" assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" + assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo" print("self-test ok") diff --git a/AGENTS.md b/AGENTS.md index 21884a813..694ec26fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 - 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。 +- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。 - SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。 - 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。 - 除 CI/CD 脚本内部受控用法外,人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`。 diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 879b1dbab..e2982bbc3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -4,8 +4,8 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "npm --prefix ../.. exec tauri -- dev", - "game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat", + "dev": "node scripts/start-tauri-dev.mjs", + "game-chat": "node scripts/start-tauri-dev.mjs --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 2f858797a..91e8f5e2d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -452,10 +452,7 @@ async function runConfigWizardRegressionChecks() { assert.equal(fs.existsSync(missingLinkedConfigDir), false); assert.equal( await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir), - path.join( - fs.realpathSync.native(realConfigAncestor), - 'missing-appdata', - ), + path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'), ); const gitRoot = path.join(testRoot, 'tracked-repository'); @@ -1260,6 +1257,21 @@ if ( ); } +if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') { + throw new Error( + 'AI game creator shell dev must run through the managed Tauri dev launcher', + ); +} + +if ( + packageConfig.scripts?.['game-chat'] !== + 'node scripts/start-tauri-dev.mjs --game-chat' +) { + throw new Error( + 'AI game creator shell game-chat must run through the managed Tauri dev launcher', + ); +} + const gameChatInitialUrlApply = 'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)'; const gameChatInitialUrlApplyIndexes = Array.from( @@ -1473,8 +1485,15 @@ for (const snippet of [ 'assertSafeGameCreatorConfigDestination', 'readGameCreatorWizardConfigState', '$security.SetAccessRuleProtection($true, $false)', + '$targetItem = Get-Item -LiteralPath $target -Force', + '$targetItem.SetAccessControl($security)', + '$verified = $targetItem.GetAccessControl()', '$rules.Count -ne 1', '[System.Security.AccessControl.FileSystemRights]::FullControl', + "runChildCapture('powershell.exe'", + "'-NoProfile'", + "'-Command'", + 'windowsPrivateAclScript', 'await secureWindowsPath(temporaryPath, { isDirectory: false })', 'await temporaryFile.writeFile', ]) { @@ -1484,6 +1503,11 @@ for (const snippet of [ ); } } +if (/\bGet-Acl\b/u.test(configWizardSource)) { + throw new Error( + 'AI game creator config wizard must not rely on Get-Acl module auto-loading', + ); +} await runConfigWizardRegressionChecks(); await runHiddenInputRegressionChecks(); diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 98bf22ef3..7cd45583a 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -2370,6 +2370,7 @@ function createDeterministicCanvasFixture(apiKey) { const projectId = 'deterministic-canvas-project'; const folderId = 'deterministic-canvas-folder'; const images = new Map(); + const generationOperations = new Map(); const imageCache = new Map(); const stats = { canvasApiRequestCount: 0, @@ -2476,6 +2477,16 @@ function createDeterministicCanvasFixture(apiKey) { request.method === 'POST' && parsed.pathname === '/api/external/v1/editor/images/generations' ) { + const idempotencyKey = request.headers['idempotency-key']; + if ( + typeof idempotencyKey !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + idempotencyKey, + ) + ) { + request.resume(); + return json(400, { error: { message: 'invalid idempotency key' } }); + } const body = await readJsonBody(request); const image = imageForAspectRatio(body?.aspectRatio); generationSequence += 1; @@ -2489,8 +2500,22 @@ function createDeterministicCanvasFixture(apiKey) { const assetKind = typeof body?.assetKind === 'string' ? body.assetKind : 'game-art'; images.set(imageId, { ...image, objectKey }); - return json(200, { - data: { + const operationId = `task-${imageId}`; + generationOperations.set(operationId, { + imageSrc: `/${objectKey}`, + objectKey, + assetObjectId, + width: image.width, + height: image.height, + sourceType: 'generated', + prompt: body?.prompt ?? 'deterministic canvas fixture', + actualPrompt: body?.prompt ?? 'deterministic canvas fixture', + model: 'deterministic-canvas-v1', + provider: 'deterministic-loopback', + taskId: `task-${imageId}`, + resource: { + resourceId, + projectId, imageSrc: `/${objectKey}`, objectKey, assetObjectId, @@ -2502,22 +2527,45 @@ function createDeterministicCanvasFixture(apiKey) { model: 'deterministic-canvas-v1', provider: 'deterministic-loopback', taskId: `task-${imageId}`, - resource: { - resourceId, - projectId, - imageSrc: `/${objectKey}`, - objectKey, - assetObjectId, - width: image.width, - height: image.height, - sourceType: 'generated', - assetKind, - }, - asset: { - assetId: `asset-${imageId}`, - assetObjectId, - assetKind, - }, + assetKind, + }, + asset: { + assetId: `asset-${imageId}`, + assetObjectId, + assetKind, + }, + }); + return json(202, { + data: { + operationId, + kind: 'editor_image_generation', + status: 'queued', + statusUrl: `/api/external/v1/generations/${operationId}`, + pollAfterMs: 1, + updatedAtMicros: generationSequence, + }, + }); + } + if ( + request.method === 'GET' && + parsed.pathname.startsWith('/api/external/v1/generations/') + ) { + request.resume(); + const operationId = parsed.pathname.slice( + '/api/external/v1/generations/'.length, + ); + const result = generationOperations.get(operationId); + if (!result) return json(404, { error: { message: 'operation not found' } }); + return json(200, { + data: { + operationId, + kind: 'editor_image_generation', + status: 'completed', + phaseLabel: '图片画布生成图片', + phaseDetail: '生成已完成。', + progress: 100, + result, + updatedAtMicros: generationSequence, }, }); } diff --git a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs index 0d0522f22..dee615c1e 100644 --- a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs +++ b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs @@ -51,9 +51,10 @@ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( [System.Security.AccessControl.AccessControlType]::Allow ) $security.AddAccessRule($rule) | Out-Null -(Get-Item -LiteralPath $target -Force).SetAccessControl($security) +$targetItem = Get-Item -LiteralPath $target -Force +$targetItem.SetAccessControl($security) -$verified = Get-Acl -LiteralPath $target +$verified = $targetItem.GetAccessControl() $owner = $verified.GetOwner([System.Security.Principal.SecurityIdentifier]) $rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) if (-not $owner.Equals($currentSid) -or -not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 91bf6d1b2..562e9baaf 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -134,6 +135,21 @@ async function readExistingViteServer() { return httpGetText(viteUrl); } +function isVitePortListening() { + return new Promise((resolveRequest) => { + const socket = net.connect({ host: viteHost, port: vitePort }); + socket.once('connect', () => { + socket.destroy(); + resolveRequest(true); + }); + socket.once('error', () => resolveRequest(false)); + socket.setTimeout(1000, () => { + socket.destroy(); + resolveRequest(true); + }); + }); +} + function isAiGameCreatorServer(response) { return ( response && @@ -144,17 +160,6 @@ function isAiGameCreatorServer(response) { ); } -async function isExistingViteProxyReady() { - const response = await httpGetText(`${viteUrl}api/auth/me`, 2000); - return Boolean( - response && - response.statusCode >= 200 && - response.statusCode < 500 && - !response.body.includes('AI 游戏创作') && - !response.body.includes('/src/main.tsx'), - ); -} - async function readExistingViteMarker() { const response = await httpGetText(viteMarkerUrl, 2000); if (!response || response.statusCode !== 200) { @@ -167,24 +172,49 @@ async function readExistingViteMarker() { } } -async function isExistingVitePairedWithBackend(apiTarget) { - const marker = await readExistingViteMarker(); - return Boolean( - marker && - marker.schemaVersion === 1 && - marker.app === 'ai-game-creator-shell' && - marker.apiTarget === apiTarget, +async function preflightExistingVite({ + readServer = readExistingViteServer, + portListening = isVitePortListening, + readMarker = readExistingViteMarker, +} = {}) { + const existing = await readServer(); + if (!existing) { + if (await portListening()) { + throw new Error( + `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + ); + } + return { status: 'available', apiTarget: '' }; + } + + if (!isAiGameCreatorServer(existing)) { + throw new Error( + `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + ); + } + + const marker = await readMarker(); + const markerApiTarget = + marker?.schemaVersion === 1 && + marker?.app === 'ai-game-creator-shell' && + typeof marker?.apiTarget === 'string' + ? marker.apiTarget + : ''; + const actualTarget = markerApiTarget || 'unknown'; + throw new Error( + `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, ); } function spawnChild(command, args, options, spawnImpl = spawn) { - const useShell = process.platform === 'win32'; + const isPosix = process.platform !== 'win32'; + const useShell = options.shell ?? !isPosix; const child = spawnImpl(command, args, { ...options, shell: useShell, // POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、 // Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。 - detached: !useShell, + detached: isPosix, stdio: 'inherit', }); const lifecycle = { @@ -192,7 +222,7 @@ function spawnChild(command, args, options, spawnImpl = spawn) { promise: null, // detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后 // child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。 - processGroupId: !useShell && Number.isInteger(child.pid) ? child.pid : null, + processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null, }; lifecycle.promise = new Promise((resolveLifecycle) => { child.once('error', (error) => { @@ -270,6 +300,195 @@ function stopChild(child, signal = 'SIGTERM') { } } +function readLinuxProcessGroupAlive( + processGroupId, + { readdirImpl = readdirSync, readFileImpl = readFileSync } = {}, +) { + let processIds; + try { + processIds = readdirImpl('/proc'); + } catch { + return null; + } + + for (const processId of processIds) { + if (!/^\d+$/.test(processId)) { + continue; + } + let stat; + try { + stat = readFileImpl(`/proc/${processId}/stat`, 'utf8'); + } catch { + continue; + } + const commandEnd = stat.lastIndexOf(') '); + if (commandEnd < 0) { + continue; + } + const [state, , processGroup] = stat + .slice(commandEnd + 2) + .trim() + .split(/\s+/); + if ( + Number(processGroup) === processGroupId && + state !== 'Z' && + state !== 'X' + ) { + return true; + } + } + return false; +} + +function isProcessGroupAlive( + processGroupId, + { + platform = process.platform, + killImpl = process.kill, + readLinuxGroupAlive = readLinuxProcessGroupAlive, + } = {}, +) { + if (!Number.isInteger(processGroupId)) { + return false; + } + try { + killImpl(-processGroupId, 0); + } catch (error) { + return error?.code !== 'ESRCH'; + } + if (platform === 'linux') { + const linuxGroupAlive = readLinuxGroupAlive(processGroupId); + if (typeof linuxGroupAlive === 'boolean') { + return linuxGroupAlive; + } + } + return true; +} + +async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs)); + } + return check(); +} + +function runWindowsTaskkill( + processId, + { spawnImpl = spawn, timeoutMs = 5000 } = {}, +) { + return new Promise((resolveRequest) => { + const taskkill = spawnImpl( + 'taskkill.exe', + ['/PID', String(processId), '/T', '/F'], + { + shell: false, + stdio: 'ignore', + windowsHide: true, + }, + ); + let settled = false; + const timeout = setTimeout(() => { + try { + taskkill.kill('SIGKILL'); + } catch { + // ignore taskkill timeout races + } + finish({ timedOut: true, code: null, error: null }); + }, timeoutMs); + const finish = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveRequest(result); + }; + taskkill.once('error', (error) => + finish({ timedOut: false, code: null, error }), + ); + taskkill.once('exit', (code) => + finish({ timedOut: false, code: code ?? 0, error: null }), + ); + }); +} + +async function terminateChildTree( + child, + { + platform = process.platform, + gracefulTimeoutMs = 2500, + forceTimeoutMs = 2000, + killImpl = process.kill, + taskkillImpl = runWindowsTaskkill, + } = {}, +) { + if (!child) { + return { stopped: true, forced: false }; + } + + if (platform === 'win32') { + if (!Number.isInteger(child.pid)) { + stopChild(child, 'SIGTERM'); + return { stopped: true, forced: false }; + } + const result = await taskkillImpl(child.pid); + return { + stopped: + !result?.timedOut && + !result?.error && + [0, 128].includes(result?.code ?? 0), + forced: true, + result, + }; + } + + const processGroupId = childLifecycles.get(child)?.processGroupId; + if (!Number.isInteger(processGroupId)) { + stopChild(child, 'SIGTERM'); + const lifecycle = childLifecycles.get(child); + if (lifecycle) { + await Promise.race([ + lifecycle.promise, + new Promise((resolveWait) => + setTimeout(resolveWait, gracefulTimeoutMs), + ), + ]); + } + if (child.exitCode == null && child.signalCode == null) { + stopChild(child, 'SIGKILL'); + return { stopped: false, forced: true }; + } + return { stopped: true, forced: false }; + } + + stopChild(child, 'SIGTERM'); + if ( + await waitUntil( + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), + gracefulTimeoutMs, + ) + ) { + return { stopped: true, forced: false }; + } + + try { + killImpl(-processGroupId, 'SIGKILL'); + } catch (error) { + if (error?.code !== 'ESRCH') { + return { stopped: false, forced: true, error }; + } + } + const stopped = await waitUntil( + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), + forceTimeoutMs, + ); + return { stopped, forced: true }; +} + async function waitForBackendReady(backendChild, timeoutMs = 600_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -340,19 +559,9 @@ async function startVite(apiTarget) { const existing = await readExistingViteServer(); if (existing) { - if ( - isAiGameCreatorServer(existing) && - (await isExistingVitePairedWithBackend(apiTarget)) && - (await isExistingViteProxyReady()) - ) { - console.log( - `[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`, - ); - return null; - } if (isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`, + `${viteUrl} is already running and cannot be safely reused. Stop it before starting Tauri dev.`, ); } throw new Error( @@ -384,6 +593,7 @@ async function main() { } try { + await preflightExistingVite(); const backend = await ensureBackend({ onBackendChild(child) { backendChild = child; @@ -422,6 +632,10 @@ async function main() { ); return 1; } finally { + await Promise.all([ + terminateChildTree(viteChild), + terminateChildTree(backendChild), + ]); for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } @@ -439,10 +653,15 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, + isProcessGroupAlive, + preflightExistingVite, readChildFailure, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, + runWindowsTaskkill, spawnChild, stopChild, + terminateChildTree, waitForBackendReady, waitForChildTermination, }; diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs new file mode 100644 index 000000000..5c6bba17e --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -0,0 +1,128 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + preflightExistingVite, + spawnChild, + stopChild, + terminateChildTree, + waitForChildTermination, +} from './start-dev-stack.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = resolve(appRoot, '../..'); +const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); + +function parseLauncherArguments(argv) { + const args = [...argv]; + const gameChat = args[0] === '--game-chat'; + if (gameChat) { + args.shift(); + } + return { gameChat, args }; +} + +function buildTauriArguments(argv) { + const { gameChat, args } = parseLauncherArguments(argv); + if (gameChat) { + return ['dev', '--', '--', '--game-chat', ...args]; + } + return ['dev', ...args]; +} + +function spawnTauriCli(argv) { + return spawnChild(process.execPath, [tauriCliPath, ...argv], { + cwd: appRoot, + shell: false, + }); +} + +async function runTauriDev( + argv = process.argv.slice(2), + { + preflight = preflightExistingVite, + spawnCli = spawnTauriCli, + waitForCli = waitForChildTermination, + terminateTree = terminateChildTree, + } = {}, +) { + await preflight(); + + const tauriArguments = buildTauriArguments(argv); + const child = spawnCli(tauriArguments); + let resolveShutdown; + let shutdownSignal = ''; + let repeatedSignal = false; + const shutdownRequested = new Promise((resolveRequest) => { + resolveShutdown = resolveRequest; + }); + const signalHandlers = new Map(); + + for (const signal of ['SIGINT', 'SIGTERM']) { + const handler = () => { + if (!shutdownSignal) { + shutdownSignal = signal; + stopChild(child, 'SIGTERM'); + resolveShutdown(signal); + return; + } + repeatedSignal = true; + stopChild(child, 'SIGKILL'); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + const childResult = waitForCli(child); + const outcome = await Promise.race([ + childResult.then((failure) => ({ type: 'exit', failure })), + shutdownRequested.then((signal) => ({ type: 'signal', signal })), + ]); + const cleanup = await terminateTree(child, { + gracefulTimeoutMs: repeatedSignal ? 0 : 2500, + }); + if (!cleanup.stopped) { + console.error( + '[ai-game-creator-shell] Tauri dev exited, but its process tree could not be fully stopped.', + ); + return 1; + } + + if (outcome.type === 'signal') { + return 1; + } + const { failure } = outcome; + return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); + } finally { + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + } +} + +function isDirectModuleExecution() { + return Boolean( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url), + ); +} + +export { + buildTauriArguments, + isDirectModuleExecution, + parseLauncherArguments, + runTauriDev, + spawnTauriCli, +}; + +if (isDirectModuleExecution()) { + try { + process.exitCode = await runTauriDev(); + } catch (error) { + console.error( + `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index dc4f2819d..b5e4b8579 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1507,6 +1507,7 @@ dependencies = [ "tokio", "unicode-normalization", "url", + "uuid", "windows-sys 0.61.2", "zip", ] diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 5ef59f69a..990ddd864 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } url = "2" unicode-normalization = "0.1" +uuid = { version = "1", features = ["v4"] } zip = { version = "2", default-features = false, features = ["deflate"] } tauri-plugin-clipboard-manager = "2.3.2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 9046ab892..a643fc1d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -3,6 +3,7 @@ use super::*; mod canvas_generation; mod draft_validation; mod draft_writer; +mod external_generation_state; mod loop_orchestration; mod pass_artifacts; mod prompt_context; @@ -13,9 +14,22 @@ mod tests; mod trace; pub(in crate::agent) use canvas_generation::{ - commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at, + commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation, + request_platform_art_asset_with_runtime_options_at, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; +pub(in crate::agent) use external_generation_state::{ + game_creator_agent_runtime_external_generation_exists, + platform_art_generation_runtime_context_from_pending, + platform_art_generation_runtime_recovery_at, remove_platform_art_generation_runtime_state_at, + PlatformArtGenerationRuntimeContext, PlatformArtGenerationRuntimeRecovery, + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION, +}; +#[cfg(test)] +pub(crate) use external_generation_state::{ + setup_platform_art_generation_runtime_accepted_for_recovery_test, + write_platform_art_generation_runtime_accepted_for_test, +}; pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; pub(in crate::agent) use trace::game_creation_agent_group_id; 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 d2dc1f727..6f7f7d55f 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 @@ -1,5 +1,25 @@ +use super::external_generation_state::{ + mark_platform_art_generation_runtime_accepted, + mark_platform_art_generation_runtime_legacy_completed, + platform_art_generation_external_configuration_fingerprint, + platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result, + platform_art_generation_runtime_request_body_json, + platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, + platform_art_generation_runtime_submission_payload, + prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state, + validate_platform_art_generation_external_configuration, +}; use super::*; +const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); +const EXTERNAL_GENERATION_SUBMIT_TIMEOUT: Duration = EXTERNAL_GENERATION_POLL_TIMEOUT; +const EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS: u64 = 2_000; +const EXTERNAL_GENERATION_MIN_POLL_AFTER_MS: u64 = 250; +const EXTERNAL_GENERATION_MAX_POLL_AFTER_MS: u64 = 5_000; +const EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-result-unknown:"; +const EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX: &str = + "platform-generation-source-preserved-no-retry:"; + pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { read_manifest_for_project(root) .map(|manifest| { @@ -252,6 +272,96 @@ fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Va payload.get("data").unwrap_or(payload) } +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExternalGenerationInitialResponse { + LegacyCompleted(serde_json::Value), + AsyncSubmission(serde_json::Value), +} + +fn external_generation_result_has_download_reference(generated: &serde_json::Value) -> bool { + let has_download_reference = |value: &serde_json::Value| { + json_string_field(value, "objectKey").is_some() + || json_string_field(value, "imageSrc").is_some_and(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + }; + has_download_reference(generated) + || json_string_field(generated, "spritesheetImageSrc").is_some_and(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + || generated + .get("resource") + .is_some_and(has_download_reference) + || generated + .get("spritesheetResource") + .is_some_and(has_download_reference) +} + +fn external_generation_download_source( + generated: &serde_json::Value, + resource: &serde_json::Value, + is_canonical_art_spritesheet: bool, +) -> serde_json::Value { + if resource.is_object() { + return resource.clone(); + } + if is_canonical_art_spritesheet { + if let Some(image_src) = + json_string_field(generated, "spritesheetImageSrc").filter(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + { + return serde_json::json!({ "imageSrc": image_src }); + } + } + generated.clone() +} + +fn classify_external_generation_initial_response( + status: reqwest::StatusCode, + payload: &serde_json::Value, +) -> Result { + match status { + reqwest::StatusCode::OK => { + let generated = external_editor_response_data(payload); + if !external_generation_result_has_download_reference(generated) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果" + )); + } + Ok(ExternalGenerationInitialResponse::LegacyCompleted( + generated.clone(), + )) + } + reqwest::StatusCode::ACCEPTED => { + let submission = external_editor_response_data(payload); + if json_string_field(submission, "operationId").is_none() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台已接受图片生成请求,但响应缺少 operationId" + )); + } + Ok(ExternalGenerationInitialResponse::AsyncSubmission( + payload.clone(), + )) + } + _ => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", + status.as_u16() + )), + } +} + +pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool { + error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) + || error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX) +} + async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -270,6 +380,137 @@ async fn external_editor_json_request( .map_err(|error| format!("解析{action}响应失败:{error}")) } +fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { + external_editor_response_data(payload) + .get("pollAfterMs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS) + .max(EXTERNAL_GENERATION_MIN_POLL_AFTER_MS) + .min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS) +} + +fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCode) -> bool { + matches!( + status, + reqwest::StatusCode::BAD_REQUEST + | reqwest::StatusCode::UNAUTHORIZED + | reqwest::StatusCode::FORBIDDEN + ) +} + +async fn wait_for_external_generation_result( + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?; + let operation_id_path = + url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); + let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}"); + let started_at = tokio::time::Instant::now(); + let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); + + loop { + if started_at.elapsed() >= EXTERNAL_GENERATION_POLL_TIMEOUT { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}" + )); + } + if poll_after_ms > 0 { + tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; + } + let payload = match external_editor_json_request( + client.get(&status_url).bearer_auth(api_key), + "查询平台图片生成任务", + ) + .await + { + Ok(payload) => payload, + Err(error) + if !error.contains("HTTP ") + || [429, 502, 503, 504] + .iter() + .any(|status| error.contains(&format!("HTTP {status}"))) => + { + poll_after_ms = EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS; + continue; + } + Err(error) => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error};operationId={operation_id}" + )); + } + }; + let generation = external_editor_response_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("completed") => { + let result = generation + .get("result") + .filter(|result| !result.is_null()) + .cloned() + .ok_or_else(|| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" + ) + })?; + if !external_generation_result_has_download_reference(&result) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务完成结果缺少可下载媒体;operationId={operation_id}" + )); + } + return Ok(result); + } + Some("failed") => { + let error = json_string_field(generation, "error") + .or_else(|| json_string_field(generation, "phaseDetail")) + .unwrap_or_else(|| "生成任务失败".to_string()); + return Err(format!( + "平台图片生成任务失败:{error};operationId={operation_id}" + )); + } + Some("queued" | "running") => { + poll_after_ms = external_generation_poll_after_ms(&payload); + } + Some(status) => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )); + } + None => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )); + } + } + } +} + +async fn submit_external_generation_request( + client: &reqwest::Client, + api_base_url: &str, + endpoint: &str, + api_key: &str, + idempotency_key: &str, + request_body_json: &str, +) -> Result { + client + .post(format!("{api_base_url}{endpoint}")) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(request_body_json.to_string()) + .send() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}" + ) + }) +} + async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, @@ -405,6 +646,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { generated_prompt: Option, model: Option, provider: Option, + warning: Option, slice_warning: Option, generation_route: String, generation_kind: String, @@ -480,13 +722,26 @@ fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> .get("warning") .filter(|warning| !warning.is_null())?; let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + if code != "postprocess-failed-source-preserved" { + return None; + } let reason = json_string_field(warning, "reason") .unwrap_or_else(|| "透明背景后处理未生成可用衍生物".to_string()); Some(format!( - "平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" + "{EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX} 平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" )) } +fn platform_art_generation_warning(generated: &serde_json::Value) -> Option { + let warning = generated + .get("warning") + .filter(|warning| !warning.is_null())?; + let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + let reason = json_string_field(warning, "reason") + .unwrap_or_else(|| "平台生成结果包含非阻断降级".to_string()); + Some(format!("{code}:{reason}")) +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -505,99 +760,301 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( prompt: &str, briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, +) -> Result { + request_platform_art_asset_with_runtime_options_at(root, prompt, briefs, options, None).await +} + +pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, + runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { enforce_project_permission_policy(root, "canvas.asset_generate")?; - let prepared_output_path = prepare_platform_art_asset_output_path_for_mode( - root, - options.output_path.as_deref(), - options.replace_existing, - )?; + let persisted_runtime_state = runtime_context + .map(|context| { + read_platform_art_generation_runtime_state(root, context).map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法读取或校验 External Editor 生成账本:{error}" + ) + }) + }) + .transpose()? + .flatten(); + if persisted_runtime_state + .as_ref() + .is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared") + { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本停在 prepared,POST 是否已受理未知;禁止自动重放" + )); + } + // 首次提交必须在任何远端副作用前完成本地输出校验。accepted / legacy-completed + // 恢复则先读取已有持久结果,再校验本地安装目标,避免本地漂移阻断 GET-only 恢复。 + let prepared_output_path_before_submit = if persisted_runtime_state.is_none() { + Some(prepare_platform_art_asset_output_path_for_mode( + root, + options.output_path.as_deref(), + options.replace_existing, + )?) + } else { + None + }; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; + if let Some(state) = persisted_runtime_state.as_ref() { + validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; + } + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?; + let submit_client = reqwest::Client::builder() + .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) + .build() + .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let ( + generated, + canvas_context, + generation_route, + generation_kind, + is_canonical_art_spritesheet, + reference_resource_ids, + effective_generation_prompt, + ) = if let Some(state) = persisted_runtime_state { + let snapshot = platform_art_generation_runtime_request_snapshot(&state).map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" + ) + })?; + let generated = if platform_art_generation_runtime_status(&state) == "accepted" { + let submission = platform_art_generation_runtime_submission_payload(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } else if platform_art_generation_runtime_status(&state) == "legacy-completed" { + platform_art_generation_runtime_legacy_result(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))? + } else { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" + )); + }; + let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; + ( + generated, + ExternalCanvasGenerationContext { + project_id: snapshot.canvas_project_id, + asset_folder_id: snapshot.asset_folder_id, + canvas_name: snapshot.canvas_name, + }, + snapshot.endpoint, + snapshot.generation_kind, + is_canonical_art_spritesheet, + snapshot.reference_resource_ids, + snapshot.generation_prompt, + ) + } else { + let canvas_context = + prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) + .await?; + let generation_kind = match options.asset_kind.as_str() { + "ui-prototype" => "ui-design", + "art-spritesheet" => "icon-spritesheet", + _ => "spec", + }; + let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let canonical_reference = matches!( + options.asset_kind.as_str(), + "ui-prototype" | "art-spritesheet" + ) + .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) + .transpose()?; + let (endpoint, request_body) = if is_canonical_art_spritesheet { + let reference_image_src = canonical_reference + .as_deref() + .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; + ( + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": reference_image_src, + "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "screenColor": "auto", + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) + } else { + ( + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) + }; + let runtime_state = runtime_context + .map(|context| { + prepare_platform_art_generation_runtime_state( + root, + context, + endpoint, + &canvas_context.canvas_name, + &generation_prompt, + &request_body, + &external_configuration_fingerprint, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法建立 External Editor 生成账本:{error}" + ) + }) + }) + .transpose()?; + if runtime_state.as_ref().is_some_and(|(_, created)| !created) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本在首次恢复读取后发生变化;禁止自动 POST" + )); + } + let runtime_state = runtime_state.map(|(state, _)| state); + let idempotency_key = runtime_state + .as_ref() + .map(|state| platform_art_generation_runtime_idempotency_key(state).to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let request_body_json = runtime_state + .as_ref() + .map(|state| platform_art_generation_runtime_request_body_json(state).to_string()) + .unwrap_or_else(|| { + serde_json::to_string(&request_body) + .expect("External Editor request body Value must serialize") + }); + let response = submit_external_generation_request( + &submit_client, + &api_base_url, + endpoint, + &api_key, + &idempotency_key, + &request_body_json, + ) + .await?; + let status = response.status(); + if !status.is_success() { + if external_generation_submit_rejection_is_definitive(status) { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + if runtime_context.is_some() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成提交返回 HTTP {},服务端是否已产生副作用未知", + status.as_u16() + )); + } + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + let submission_payload = response + .json::() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析平台图片生成提交响应失败:{error}" + ) + })?; + let generated = match classify_external_generation_initial_response( + status, + &submission_payload, + )? { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => { + if let Some(state) = runtime_state { + mark_platform_art_generation_runtime_legacy_completed(root, state, &generated) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 旧同步生成结果无法持久化:{error}" + ) + })?; + } + generated + } + ExternalGenerationInitialResponse::AsyncSubmission(submission) => { + let operation_id = + json_string_field(external_editor_response_data(&submission), "operationId") + .expect("202 submission was classified with operationId"); + let poll_after_ms = external_generation_poll_after_ms(&submission); + if let Some(state) = runtime_state { + mark_platform_art_generation_runtime_accepted( + root, + state, + &operation_id, + poll_after_ms, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} operationId 无法持久化:{error}" + ) + })?; + } + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } + }; + ( + generated, + canvas_context, + endpoint.to_string(), + generation_kind.to_string(), + is_canonical_art_spritesheet, + canonical_reference.into_iter().collect::>(), + generation_prompt.clone(), + ) + }; + let prepared_output_path = match prepared_output_path_before_submit { + Some(prepared) => prepared, + None => prepare_platform_art_asset_output_path_for_mode( + root, + options.output_path.as_deref(), + options.replace_existing, + )?, + }; let requested_output_path = prepared_output_path .as_ref() .map(|(local_path, _, _)| local_path.clone()); let replacement_fingerprint = prepared_output_path.and_then(|(_, _, replacement_fingerprint)| replacement_fingerprint); - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; - let client = reqwest::Client::new(); - let canvas_context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); - let generation_kind = match options.asset_kind.as_str() { - "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - _ => "spec", - }; - let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = matches!( - options.asset_kind.as_str(), - "ui-prototype" | "art-spritesheet" - ) - .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) - .transpose()?; - let (endpoint, request_body) = if is_canonical_art_spritesheet { - let reference_image_src = canonical_reference - .as_deref() - .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; - ( - "/api/external/v1/editor/icon-spritesheets/generations", - serde_json::json!({ - "referenceImageSrc": reference_image_src, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "screenColor": "auto", - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - } else { - ( - "/api/external/v1/editor/images/generations", - serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - }; - let response = client - .post(format!("{api_base_url}{endpoint}")) - .bearer_auth(&api_key) - .json(&request_body) - .send() - .await - .map_err(|error| format!("请求平台图片生成失败:{error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); - } - let payload = response - .json::() - .await - .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; - let generated = payload.get("data").unwrap_or(&payload); + let generated = &generated; if let Some(error) = platform_art_generation_postprocess_failure(generated) { return Err(error); } @@ -612,13 +1069,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( } else { generated.get("asset").unwrap_or(&null) }; - let download_source = if resource.is_object() { - resource - } else { - generated - }; + let download_source = + external_generation_download_source(generated, resource, is_canonical_art_spritesheet); let download = - resolve_canvas_resource_download(&client, &api_base_url, &api_key, download_source) + resolve_canvas_resource_download(&client, &api_base_url, &api_key, &download_source) .await? .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) { @@ -631,6 +1085,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( .get("sliceWarning") .filter(|warning| !warning.is_null()) .and_then(|warning| json_string_field(warning, "reason")); + let warning = platform_art_generation_warning(generated); let resource_id = json_string_field(resource, "resourceId"); let task_id = json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); @@ -643,7 +1098,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( let generated_prompt = json_string_field(generated, "actualPrompt") .or_else(|| json_string_field(generated, "prompt")) .or_else(|| json_string_field(resource, "actualPrompt")) - .or_else(|| json_string_field(resource, "prompt")); + .or_else(|| json_string_field(resource, "prompt")) + .or_else(|| Some(effective_generation_prompt)); let model = json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); let provider = json_string_field(generated, "provider") @@ -666,10 +1122,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( generated_prompt, model, provider, + warning, slice_warning, - generation_route: endpoint.to_string(), - generation_kind: generation_kind.to_string(), - reference_resource_ids: canonical_reference.into_iter().collect(), + generation_route, + generation_kind, + reference_resource_ids, extension, }) } @@ -742,6 +1199,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generated_prompt, model, provider, + warning, slice_warning, generation_route, generation_kind, @@ -929,6 +1387,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "taskId": task_id.clone(), "model": model.clone(), "provider": provider.clone(), + "warning": warning.clone(), "assetFolderId": canvas_context.asset_folder_id, "canvasName": canvas_context.canvas_name, "sliceWarning": slice_warning.clone(), @@ -943,6 +1402,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( asset_object_id, task_id, model, + warning, slice_warning, }) } @@ -1023,6 +1483,49 @@ mod canvas_generation_tests { use super::*; use image::{codecs::png::PngEncoder, ColorType, ImageEncoder}; + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set request read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).expect("read request bytes"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if bytes.len() >= header_end + 4 + content_length { + break; + } + } + String::from_utf8(bytes).expect("request must be UTF-8") + } + + fn test_request_header<'a>(request: &'a str, expected_name: &str) -> &'a str { + request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case(expected_name) + .then_some(value.trim()) + }) + .expect("expected request header") + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { let mut bytes = Vec::new(); PngEncoder::new(&mut bytes) @@ -1034,6 +1537,819 @@ mod canvas_generation_tests { } } + #[tokio::test] + async fn generation_submit_response_loss_is_not_retried() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (sender, receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + listener + .set_nonblocking(true) + .expect("set retry fixture nonblocking"); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let request = read_test_http_request(&mut stream); + sender.send(request).expect("capture submit request"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept submit request: {error}"), + } + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build retry client"); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let body = serde_json::json!({ "prompt": "stable retry" }); + let body_json = serde_json::to_string(&body).expect("serialize stable request body"); + let error = submit_external_generation_request( + &client, + &base_url, + "/generation", + "test-key", + &idempotency_key, + &body_json, + ) + .await + .expect_err("response loss must remain outcome unknown"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + + let first = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first request"); + assert_eq!( + test_request_header(&first, "idempotency-key"), + &idempotency_key + ); + stop_sender.send(()).expect("stop retry fixture"); + server.join().expect("join retry fixture"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + } + + #[tokio::test] + async fn async_generation_202_polls_queued_running_and_completed_result() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind polling fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for (status, result) in [ + ("queued", serde_json::Value::Null), + ("running", serde_json::Value::Null), + ( + "completed", + serde_json::json!({ + "imageSrc": "https://example.invalid/generated.png", + "resource": { + "resourceId": "resource-async", + "imageSrc": "https://example.invalid/generated.png" + } + }), + ), + ] { + let (mut stream, _) = listener.accept().expect("accept polling request"); + let request = read_test_http_request(&mut stream); + sender.send(request).expect("capture polling request"); + let body = serde_json::json!({ + "data": { + "operationId": "task-async", + "status": status, + "pollAfterMs": 0, + "result": result, + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write polling response"); + } + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build polling client"); + let result = wait_for_external_generation_result( + &client, + &base_url, + "test-api-key", + &serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 0 + } + }), + ) + .await + .expect("poll completed result"); + assert_eq!(result["resource"]["resourceId"], "resource-async"); + for _ in 0..3 { + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("polling request"); + assert!(request.starts_with("GET /api/external/v1/generations/task-async ")); + } + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + } + + #[tokio::test] + async fn accepted_runtime_generation_resumes_with_operation_get_only_and_prepares_download() { + let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-recovery", "当前本地项目名") + .expect("init accepted recovery project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow accepted recovery generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted recovery fixture"); + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let download_url = format!("{base_url}/download.png"); + let png = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server_download_url = download_url.clone(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept accepted recovery request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted recovery request"); + if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") { + let body = serde_json::json!({ + "data": { + "operationId": "accepted-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "persisted-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": server_download_url + }, + "warning": { + "code": "unsupported-image-style", + "reason": "已保留可用原图" + }, + "sliceWarning": { + "reason": "测试切片告警" + } + } + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted operation response"); + } else if request.starts_with("GET /download.png ") { + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ); + stream + .write_all(headers.as_bytes()) + .and_then(|_| stream.write_all(&png)) + .expect("write accepted recovery download"); + } else { + let body = b"unexpected request"; + let response = format!( + "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(response.as_bytes()) + .and_then(|_| stream.write_all(body)) + .expect("reject unexpected accepted recovery request"); + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "accepted-recovery-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "accepted-recovery-session".to_string(), + run_id: "accepted-recovery-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "accepted-recovery-action".to_string(), + action_fingerprint: "accepted-recovery-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化的原始生成正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "accepted-recovery-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare accepted recovery ledger"); + assert!(created); + mark_platform_art_generation_runtime_accepted(root, state, "accepted-operation-1", 0) + .expect("mark accepted recovery ledger"); + + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "重启后已变化的输入不得覆盖账本", + &[], + &PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "game-art".to_string(), + asset_label: "当前标签".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + Some(&runtime_context), + ) + .await + .expect("resume accepted generation through GET-only path"); + stop_sender + .send(()) + .expect("stop accepted recovery fixture"); + server.join().expect("join accepted recovery fixture"); + + assert_eq!( + prepared.canvas_context.project_id, + "persisted-canvas-project" + ); + assert_eq!( + prepared.canvas_context.asset_folder_id, + "persisted-asset-folder" + ); + assert_eq!(prepared.canvas_context.canvas_name, "持久化画布名"); + assert_eq!( + prepared.generated_prompt.as_deref(), + Some("持久化的生成提示词") + ); + assert_eq!( + prepared.warning.as_deref(), + Some("unsupported-image-style:已保留可用原图") + ); + assert_eq!(prepared.slice_warning.as_deref(), Some("测试切片告警")); + assert_eq!(prepared.download.media_type, "image/png"); + let requests = std::iter::from_fn(|| { + request_receiver + .recv_timeout(Duration::from_millis(100)) + .ok() + }) + .collect::>(); + assert_eq!( + requests.len(), + 2, + "accepted recovery must only poll and download" + ); + assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 ")); + assert!(requests[1].starts_with("GET /download.png ")); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); + assert!(requests + .iter() + .all(|request| !request.contains("/api/external/v1/editor/projects"))); + assert!(requests + .iter() + .all(|request| !request.contains("/api/external/v1/editor/assets/library"))); + } + + #[tokio::test] + async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() { + let temporary = tempfile::tempdir().expect("create configuration drift project"); + let root = temporary.path(); + init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移") + .expect("init project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation recovery"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind configuration drift fixture"); + listener + .set_nonblocking(true) + .expect("set configuration drift fixture nonblocking"); + let current_base_url = + format!("http://{}", listener.local_addr().expect("fixture address")); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": current_base_url, + "apiKey": "current-editor-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "configuration-drift-session".to_string(), + run_id: "configuration-drift-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "configuration-drift-action".to_string(), + action_fingerprint: "configuration-drift-fingerprint".to_string(), + }; + let stale_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint( + "https://old-editor.example.test", + "old-editor-key", + ); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "旧画布", + "旧生成提示词", + &serde_json::json!({ + "prompt": "旧生成提示词", + "kind": "spec", + "projectId": "old-canvas-project", + "assetFolderId": "old-asset-folder", + "referenceImageSrcs": [] + }), + &stale_configuration_fingerprint, + ) + .expect("prepare stale configuration ledger"); + mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0) + .expect("mark stale operation accepted"); + + let error = match request_platform_art_asset_with_runtime_options_at( + root, + "不得覆盖旧请求", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("configuration drift must block GET-only recovery"), + }; + assert!(error.contains("baseUrl/API Key"), "{error}"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.run_id + )); + } + + #[tokio::test] + async fn accepted_runtime_generation_keeps_ledger_until_failed_observation_is_persisted() { + let temporary = tempfile::tempdir().expect("create accepted failure project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-failure", "External Editor 失败恢复") + .expect("init project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation recovery"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted failure fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept failed operation GET"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request) + .expect("capture failed operation GET"); + let body = serde_json::json!({ + "data": { + "operationId": "failed-operation", + "status": "failed", + "error": "provider rejected request" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write failed operation response"); + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { "baseUrl": base_url.clone(), "apiKey": "accepted-failure-key" } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "accepted-failure-session".to_string(), + run_id: "accepted-failure-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "accepted-failure-action".to_string(), + action_fingerprint: "accepted-failure-fingerprint".to_string(), + }; + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "accepted-failure-key", + ); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "失败恢复画布", + "失败恢复提示词", + &serde_json::json!({ + "prompt": "失败恢复提示词", + "kind": "spec", + "projectId": "failed-canvas-project", + "assetFolderId": "failed-asset-folder", + "referenceImageSrcs": [] + }), + &configuration_fingerprint, + ) + .expect("prepare accepted failure ledger"); + mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) + .expect("mark failed operation accepted"); + + let error = match request_platform_art_asset_with_runtime_options_at( + root, + "不得重新提交", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("explicit operation failure must be returned"), + }; + server.join().expect("join accepted failure fixture"); + assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("failed operation request") + .starts_with("GET /api/external/v1/generations/failed-operation ")); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.run_id + )); + } + + #[tokio::test] + async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { + let temporary = tempfile::tempdir().expect("create accepted scan project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") + .expect("init accepted scan project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow accepted scan recovery"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted scan fixture"); + listener + .set_nonblocking(true) + .expect("set accepted scan fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let image_url = format!("{base_url}/artifact.png"); + let image_bytes = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept accepted scan request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted scan request"); + if request.starts_with("GET /api/external/v1/generations/test-operation-id ") { + let body = serde_json::json!({ + "data": { + "operationId": "test-operation-id", + "status": "completed", + "result": { + "resource": { + "resourceId": "recovered-resource", + "projectId": "test-canvas-project", + "imageSrc": image_url + }, + "taskId": "recovered-task", + "model": "recovered-model" + } + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted scan operation response"); + } else if request.starts_with("GET /artifact.png ") { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + image_bytes.len(), + ); + stream + .write_all(response.as_bytes()) + .and_then(|_| stream.write_all(&image_bytes)) + .expect("write accepted scan image response"); + } else { + stream + .write_all(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .expect("reject unexpected accepted scan request"); + } + }); + let llm_base_url = crate::tests::spawn_mock_llm_server_responses(vec![ + crate::tests::final_tool_plan_response("已恢复视觉规范图。"), + ]); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { "baseUrl": base_url, "apiKey": "recovery-editor-key" }, + "agentLlm": { + "art-director": { + "apiKey": "recovery-llm-key", + "baseUrl": llm_base_url, + "model": "recovery-model", + "apiKind": "openai_responses" + } + } + }) + .to_string(), + ); + let run_id = "accepted-recovery-scan-run"; + setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) + .expect("setup accepted generation recovery state"); + + resume_game_creator_agent_background_tasks_at(root) + .expect("resume accepted generation through recovery scan"); + let first = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("operation GET after recovery scan"); + let second = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("download GET after recovery scan"); + for _ in 0..100 { + if root.join("assets/art-spec.png").is_file() + && !game_creator_agent_runtime_external_generation_exists( + root, + "art-director", + run_id, + ) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + stop_sender.send(()).expect("stop accepted scan fixture"); + server.join().expect("join accepted scan fixture"); + + assert!(first.starts_with("GET /api/external/v1/generations/test-operation-id ")); + assert!(second.starts_with("GET /artifact.png ")); + assert!(request_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + assert!(root.join("assets/art-spec.png").is_file()); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + "art-director", + run_id + )); + } + + #[tokio::test] + async fn legacy_200_spritesheet_top_level_image_src_downloads_without_resource() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind legacy spritesheet fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let signed_url = format!("{base_url}/signed/legacy-spritesheet.png"); + let expected_bytes = rgba_test_png(0).bytes; + let response_bytes = expected_bytes.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for index in 0..2 { + let (mut stream, _) = listener.accept().expect("accept legacy download request"); + let request = read_test_http_request(&mut stream); + sender + .send(request) + .expect("capture legacy download request"); + if index == 0 { + let body = serde_json::json!({ + "read": { + "signedUrl": signed_url, + "objectKey": "generated/legacy-spritesheet.png" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write legacy read URL response"); + } else { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response_bytes.len(), + ); + stream + .write_all(response.as_bytes()) + .expect("write legacy image header"); + stream + .write_all(&response_bytes) + .expect("write legacy image body"); + } + } + }); + + let generated = serde_json::json!({ + "spritesheetImageSrc": "/generated/legacy-spritesheet.png", + "spritesheetResource": null + }); + let download_source = + external_generation_download_source(&generated, &serde_json::Value::Null, true); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build legacy download client"); + let download = + resolve_canvas_resource_download(&client, &base_url, "test-api-key", &download_source) + .await + .expect("resolve legacy spritesheet download") + .expect("legacy spritesheet download"); + assert_eq!(download.bytes, expected_bytes); + assert_eq!(download.media_type, "image/png"); + let read_url_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("legacy read URL request"); + assert!(read_url_request.starts_with( + "GET /api/external/v1/assets/read-url?legacyPublicPath=%2Fgenerated%2Flegacy-spritesheet.png " + )); + let signed_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("legacy signed image request"); + assert!(signed_request.starts_with("GET /signed/legacy-spritesheet.png ")); + } + + #[test] + fn generation_initial_response_uses_status_for_legacy_and_async_contracts() { + let legacy = serde_json::json!({ + "data": { + "resource": { "resourceId": "resource-legacy" }, + "imageSrc": "/generated/legacy.png" + } + }); + assert_eq!( + classify_external_generation_initial_response(reqwest::StatusCode::OK, &legacy) + .expect("legacy 200 response"), + ExternalGenerationInitialResponse::LegacyCompleted(legacy["data"].clone()) + ); + let legacy_top_level = serde_json::json!({ + "resource": { "resourceId": "resource-legacy-top-level" }, + "imageSrc": "/generated/legacy-top-level.png" + }); + assert_eq!( + classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &legacy_top_level, + ) + .expect("legacy top-level 200 response"), + ExternalGenerationInitialResponse::LegacyCompleted(legacy_top_level) + ); + + let submission = serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 1 + } + }); + assert_eq!( + classify_external_generation_initial_response( + reqwest::StatusCode::ACCEPTED, + &submission, + ) + .expect("async 202 response"), + ExternalGenerationInitialResponse::AsyncSubmission(submission) + ); + + let error = classify_external_generation_initial_response( + reqwest::StatusCode::ACCEPTED, + &serde_json::json!({"data": {"status": "queued"}}), + ) + .expect_err("accepted response without operationId must not fall back to legacy"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + + let error = classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &serde_json::json!({"data": {}}), + ) + .expect_err("malformed legacy response must remain outcome unknown"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + + let legacy_spritesheet = serde_json::json!({ + "spritesheetImageSrc": "/generated/legacy-spritesheet.png", + "spritesheetResource": null, + "spritesheetAsset": null + }); + let generated = match classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &legacy_spritesheet, + ) + .expect("legacy spritesheet 200 response") + { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => generated, + ExternalGenerationInitialResponse::AsyncSubmission(_) => { + panic!("legacy 200 must not become an async submission") + } + }; + let download_source = + external_generation_download_source(&generated, &serde_json::Value::Null, true); + assert_eq!( + download_source, + serde_json::json!({"imageSrc": "/generated/legacy-spritesheet.png"}) + ); + } + #[test] fn canonical_art_spritesheet_requires_real_transparent_pixels() { assert!(platform_art_spritesheet_has_transparent_pixels( @@ -1075,6 +2391,72 @@ mod canvas_generation_tests { assert!(error.contains("抠图服务暂不可用,已保留源图。")); assert!(error.contains("不得登记为透明图集或自动重试")); assert!(!error.contains("不应覆盖通用告警")); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + } + + #[test] + fn nonblocking_generation_warning_remains_a_completed_result() { + for code in [ + "dimension-restore-fallback", + "unsupported-image-style", + "multiple-generation-warnings", + ] { + let payload = serde_json::json!({ + "warning": { + "code": code, + "reason": "非阻断降级" + } + }); + assert!(platform_art_generation_postprocess_failure(&payload).is_none()); + assert_eq!( + platform_art_generation_warning(&payload).as_deref(), + Some(format!("{code}:非阻断降级").as_str()) + ); + } + } + + #[test] + fn generation_poll_interval_clamps_to_openapi_bounds() { + assert_eq!( + external_generation_poll_after_ms(&serde_json::json!({"pollAfterMs": 0})), + EXTERNAL_GENERATION_MIN_POLL_AFTER_MS + ); + assert_eq!( + external_generation_poll_after_ms(&serde_json::json!({"pollAfterMs": 60_000})), + EXTERNAL_GENERATION_MAX_POLL_AFTER_MS + ); + } + + #[test] + fn only_contractual_pre_enqueue_rejections_can_discard_generation_ledger() { + for status in [ + reqwest::StatusCode::BAD_REQUEST, + reqwest::StatusCode::UNAUTHORIZED, + reqwest::StatusCode::FORBIDDEN, + ] { + assert!(external_generation_submit_rejection_is_definitive(status)); + } + for status in [ + reqwest::StatusCode::REQUEST_TIMEOUT, + reqwest::StatusCode::CONFLICT, + reqwest::StatusCode::TOO_MANY_REQUESTS, + reqwest::StatusCode::BAD_GATEWAY, + ] { + assert!(!external_generation_submit_rejection_is_definitive(status)); + } + } + + #[test] + fn invalid_legacy_spritesheet_image_src_does_not_hide_object_key() { + let generated = serde_json::json!({ + "objectKey": "stable/generated/spritesheet.png", + "spritesheetImageSrc": "not-a-download-reference", + "spritesheetResource": null + }); + assert_eq!( + external_generation_download_source(&generated, &serde_json::Value::Null, true), + generated + ); } fn replacement_options() -> PlatformArtAssetGenerationOptions { @@ -1111,6 +2493,7 @@ mod canvas_generation_tests { generated_prompt: Some("原创替换图集".to_string()), model: Some("test-image-model".to_string()), provider: Some("test-provider".to_string()), + warning: None, slice_warning: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), 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 new file mode 100644 index 000000000..16bcd0a62 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -0,0 +1,965 @@ +use super::*; + +pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str = + "agent-runtime-canvas-generation-request.v2"; +const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024; +const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared"; +const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted"; +const PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED: &str = "legacy-completed"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) struct PlatformArtGenerationRuntimeContext { + pub(in crate::agent) agent_id: String, + pub(in crate::agent) task_id: String, + pub(in crate::agent) session_id: String, + pub(in crate::agent) run_id: String, + pub(in crate::agent) source: String, + pub(in crate::agent) action_id: String, + pub(in crate::agent) action_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct PlatformArtGenerationRuntimeState { + schema_version: String, + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + action_id: String, + action_fingerprint: String, + external_configuration_fingerprint: String, + endpoint: String, + canvas_name: String, + generation_prompt: String, + request_body_sha256: String, + request_body_json: String, + idempotency_key: String, + status: String, + #[serde(default)] + operation_id: Option, + #[serde(default)] + poll_after_ms: Option, + #[serde(default)] + legacy_result: Option, + created_at: u64, + updated_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot { + pub(super) endpoint: String, + pub(super) canvas_project_id: String, + pub(super) asset_folder_id: String, + pub(super) canvas_name: String, + pub(super) generation_prompt: String, + pub(super) generation_kind: String, + pub(super) reference_resource_ids: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery { + Missing, + PreparedResultUnknown, + ResumeAccepted, + ResumeLegacyCompleted, +} + +fn platform_art_generation_runtime_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + ".agent/runtime/canvas-generation-requests/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +fn platform_art_generation_runtime_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + resolve_local_project_path( + root, + &platform_art_generation_runtime_relative_path(agent_id, run_id), + ) +} + +pub(in crate::agent) fn game_creator_agent_runtime_external_generation_exists( + root: &Path, + agent_id: &str, + run_id: &str, +) -> bool { + let Ok(path) = platform_art_generation_runtime_path(root, agent_id, run_id) else { + // Invalid control paths are reconciliation evidence, not proof that no durable + // generation exists. Fail closed so callers cannot downgrade to an ordinary retry. + return true; + }; + path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists() +} + +pub(in crate::agent) fn platform_art_generation_runtime_context_from_pending( + pending: &AgentRuntimePendingToolAction, +) -> PlatformArtGenerationRuntimeContext { + PlatformArtGenerationRuntimeContext { + agent_id: pending.agent_id.clone(), + task_id: pending.task_id.clone(), + session_id: pending.session_id.clone(), + run_id: pending.run_id.clone(), + source: pending.source.clone(), + action_id: pending.action_id.clone(), + action_fingerprint: pending.action_fingerprint.clone(), + } +} + +fn request_body_json_and_sha256( + request_body: &serde_json::Value, +) -> Result<(String, String), String> { + let request_body_json = serde_json::to_string(request_body) + .map_err(|error| format!("序列化 External Editor 生成请求失败:{error}"))?; + let request_body_sha256 = format!("{:x}", Sha256::digest(request_body_json.as_bytes())); + Ok((request_body_json, request_body_sha256)) +} + +pub(super) fn platform_art_generation_external_configuration_fingerprint( + api_base_url: &str, + api_key: &str, +) -> String { + let normalized_base_url = api_base_url.trim().trim_end_matches('/'); + let api_key_sha256 = format!("{:x}", Sha256::digest(api_key.as_bytes())); + format!( + "{:x}", + Sha256::digest(format!("{normalized_base_url}\n{api_key_sha256}").as_bytes()) + ) +} + +pub(super) fn validate_platform_art_generation_external_configuration( + state: &PlatformArtGenerationRuntimeState, + api_base_url: &str, + api_key: &str, +) -> Result<(), String> { + let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key); + if state.external_configuration_fingerprint != current { + return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string()); + } + Ok(()) +} + +fn validate_platform_art_generation_runtime_identity( + root: &Path, + state: &PlatformArtGenerationRuntimeState, + context: &PlatformArtGenerationRuntimeContext, +) -> Result<(), String> { + if state.schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + return Err(format!( + "不支持的 External Editor 生成账本版本:{}", + state.schema_version + )); + } + let project_id = game_creator_agent_runtime_context_project_id(root)?; + if state.project_id != project_id + || state.agent_id != context.agent_id + || state.task_id != context.task_id + || state.session_id != context.session_id + || state.run_id != context.run_id + || state.source != context.source + || state.action_id != context.action_id + || state.action_fingerprint != context.action_fingerprint + { + return Err("External Editor 生成账本与当前 pending action 身份不一致".to_string()); + } + if !matches!( + state.status.as_str(), + PLATFORM_ART_GENERATION_STATUS_PREPARED + | PLATFORM_ART_GENERATION_STATUS_ACCEPTED + | PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED + ) { + return Err("External Editor 生成账本状态无效".to_string()); + } + let request_body_sha256 = format!("{:x}", Sha256::digest(state.request_body_json.as_bytes())); + if state.request_body_sha256 != request_body_sha256 { + return Err("External Editor 生成账本请求正文指纹不匹配".to_string()); + } + platform_art_generation_runtime_request_snapshot(state)?; + if state.status == PLATFORM_ART_GENERATION_STATUS_ACCEPTED + && state.operation_id.as_deref().is_none_or(str::is_empty) + { + return Err("External Editor accepted 生成账本缺少 operationId".to_string()); + } + if state.status == PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED + && state.legacy_result.is_none() + { + return Err("External Editor 旧同步完成账本缺少 result".to_string()); + } + Ok(()) +} + +pub(super) fn read_platform_art_generation_runtime_state( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, +) -> Result, String> { + let relative_path = + platform_art_generation_runtime_relative_path(&context.agent_id, &context.run_id); + let state = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "External Editor 生成账本", + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )?; + if let Some(state) = state.as_ref() { + validate_platform_art_generation_runtime_identity(root, state, context)?; + } + Ok(state) +} + +fn write_platform_art_generation_runtime_state( + root: &Path, + state: &PlatformArtGenerationRuntimeState, +) -> Result<(), String> { + let relative_path = + platform_art_generation_runtime_relative_path(&state.agent_id, &state.run_id); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "External Editor 生成账本", + state, + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )?; + let context = PlatformArtGenerationRuntimeContext { + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + action_id: state.action_id.clone(), + action_fingerprint: state.action_fingerprint.clone(), + }; + let persisted = read_platform_art_generation_runtime_state(root, &context)? + .ok_or_else(|| "External Editor 生成账本写入后缺失".to_string())?; + if persisted != *state { + return Err("External Editor 生成账本写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(super) fn prepare_platform_art_generation_runtime_state( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, + endpoint: &str, + canvas_name: &str, + generation_prompt: &str, + request_body: &serde_json::Value, + external_configuration_fingerprint: &str, +) -> Result<(PlatformArtGenerationRuntimeState, bool), String> { + let (request_body_json, request_body_sha256) = request_body_json_and_sha256(request_body)?; + if let Some(existing) = read_platform_art_generation_runtime_state(root, context)? { + if existing.endpoint != endpoint + || existing.canvas_name != canvas_name + || existing.generation_prompt != generation_prompt + || existing.request_body_sha256 != request_body_sha256 + || existing.request_body_json != request_body_json + || existing.external_configuration_fingerprint != external_configuration_fingerprint + { + return Err("External Editor 生成账本请求与当前精确动作不一致".to_string()); + } + return Ok((existing, false)); + } + let now = unix_timestamp(); + let state = PlatformArtGenerationRuntimeState { + schema_version: PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION.to_string(), + project_id: game_creator_agent_runtime_context_project_id(root)?, + agent_id: context.agent_id.clone(), + task_id: context.task_id.clone(), + session_id: context.session_id.clone(), + run_id: context.run_id.clone(), + source: context.source.clone(), + action_id: context.action_id.clone(), + action_fingerprint: context.action_fingerprint.clone(), + external_configuration_fingerprint: external_configuration_fingerprint.to_string(), + endpoint: endpoint.to_string(), + canvas_name: canvas_name.to_string(), + generation_prompt: generation_prompt.to_string(), + request_body_sha256, + request_body_json, + idempotency_key: uuid::Uuid::new_v4().to_string(), + status: PLATFORM_ART_GENERATION_STATUS_PREPARED.to_string(), + operation_id: None, + poll_after_ms: None, + legacy_result: None, + created_at: now, + updated_at: now, + }; + write_platform_art_generation_runtime_state(root, &state)?; + Ok((state, true)) +} + +pub(super) fn platform_art_generation_runtime_request_snapshot( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + let request_body = serde_json::from_str::(&state.request_body_json) + .map_err(|error| format!("External Editor 生成账本请求正文无法解析:{error}"))?; + let canvas_project_id = json_string_field(&request_body, "projectId") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本请求缺少 projectId".to_string())?; + let asset_folder_id = json_string_field(&request_body, "assetFolderId") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本请求缺少 assetFolderId".to_string())?; + if state.canvas_name.trim().is_empty() { + return Err("External Editor 生成账本缺少 canvasName".to_string()); + } + if state.generation_prompt.trim().is_empty() { + return Err("External Editor 生成账本缺少 generationPrompt".to_string()); + } + let (generation_kind, reference_resource_ids) = match state.endpoint.as_str() { + "/api/external/v1/editor/images/generations" => { + let generation_kind = json_string_field(&request_body, "kind") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 图片生成账本请求缺少 kind".to_string())?; + let reference_resource_ids = request_body + .get("referenceImageSrcs") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + "External Editor 图片生成账本请求缺少 referenceImageSrcs".to_string() + })? + .iter() + .map(|value| { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "External Editor 图片生成账本引用资源 ID 无效".to_string()) + }) + .collect::, _>>()?; + (generation_kind, reference_resource_ids) + } + "/api/external/v1/editor/icon-spritesheets/generations" => { + let reference_resource_id = json_string_field(&request_body, "referenceImageSrc") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "External Editor 图集生成账本请求缺少 referenceImageSrc".to_string() + })?; + ("icon-spritesheet".to_string(), vec![reference_resource_id]) + } + _ => return Err("External Editor 生成账本 endpoint 不受支持".to_string()), + }; + Ok(PlatformArtGenerationRuntimeRequestSnapshot { + endpoint: state.endpoint.clone(), + canvas_project_id, + asset_folder_id, + canvas_name: state.canvas_name.clone(), + generation_prompt: state.generation_prompt.clone(), + generation_kind, + reference_resource_ids, + }) +} + +pub(super) fn mark_platform_art_generation_runtime_accepted( + root: &Path, + mut state: PlatformArtGenerationRuntimeState, + operation_id: &str, + poll_after_ms: u64, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_PREPARED { + return Err("External Editor 生成账本只有 prepared 可升级为 accepted".to_string()); + } + state.status = PLATFORM_ART_GENERATION_STATUS_ACCEPTED.to_string(); + state.operation_id = Some(operation_id.to_string()); + state.poll_after_ms = Some(poll_after_ms); + state.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &state)?; + Ok(state) +} + +pub(super) fn mark_platform_art_generation_runtime_legacy_completed( + root: &Path, + mut state: PlatformArtGenerationRuntimeState, + result: &serde_json::Value, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_PREPARED { + return Err("External Editor 旧同步完成账本必须来自 prepared".to_string()); + } + let durable_result = durable_legacy_generation_result(result)?; + state.status = PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED.to_string(); + state.legacy_result = Some(durable_result); + state.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &state)?; + Ok(state) +} + +fn safe_legacy_media_reference(value: &str) -> Option { + let value = value.trim(); + (value.starts_with('/') && !value.contains(['?', '#'])).then(|| value.to_string()) +} + +fn safe_legacy_object_key(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && !value.starts_with("http://") + && !value.starts_with("https://") + && !value.contains(['?', '#'])) + .then(|| value.to_string()) +} + +fn copy_legacy_string_field( + source: &serde_json::Value, + target: &mut serde_json::Map, + field: &str, +) { + if let Some(value) = json_string_field(source, field) { + target.insert(field.to_string(), serde_json::Value::String(value)); + } +} + +fn durable_legacy_generation_object( + source: &serde_json::Value, +) -> serde_json::Map { + let mut target = serde_json::Map::new(); + for field in [ + "resourceId", + "projectId", + "taskId", + "assetObjectId", + "actualPrompt", + "prompt", + "model", + "provider", + ] { + copy_legacy_string_field(source, &mut target, field); + } + for field in ["objectKey", "spritesheetObjectKey"] { + if let Some(value) = + json_string_field(source, field).and_then(|value| safe_legacy_object_key(&value)) + { + target.insert(field.to_string(), serde_json::Value::String(value)); + } + } + for field in ["imageSrc", "spritesheetImageSrc"] { + if let Some(value) = + json_string_field(source, field).and_then(|value| safe_legacy_media_reference(&value)) + { + target.insert(field.to_string(), serde_json::Value::String(value)); + } + } + target +} + +fn durable_legacy_generation_result( + result: &serde_json::Value, +) -> Result { + let mut durable = durable_legacy_generation_object(result); + for field in [ + "resource", + "spritesheetResource", + "asset", + "spritesheetAsset", + ] { + if result.get(field).is_some_and(serde_json::Value::is_object) { + let nested = durable_legacy_generation_object(&result[field]); + if !nested.is_empty() { + durable.insert(field.to_string(), serde_json::Value::Object(nested)); + } + } + } + for field in ["warning", "sliceWarning"] { + if let Some(value) = result.get(field).filter(|value| value.is_object()) { + let mut warning = serde_json::Map::new(); + for key in ["code", "reason"] { + copy_legacy_string_field(value, &mut warning, key); + } + if !warning.is_empty() { + durable.insert(field.to_string(), serde_json::Value::Object(warning)); + } + } + } + let durable = serde_json::Value::Object(durable); + let has_safe_download = |value: &serde_json::Value| { + json_string_field(value, "objectKey").is_some() + || json_string_field(value, "spritesheetObjectKey").is_some() + || json_string_field(value, "imageSrc").is_some() + || json_string_field(value, "spritesheetImageSrc").is_some() + }; + if !has_safe_download(&durable) + && !durable.get("resource").is_some_and(has_safe_download) + && !durable + .get("spritesheetResource") + .is_some_and(has_safe_download) + { + return Err( + "External Editor 旧同步结果缺少可安全持久化的 objectKey 或相对媒体路径".to_string(), + ); + } + Ok(durable) +} + +pub(super) fn platform_art_generation_runtime_submission_payload( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + match state.status.as_str() { + PLATFORM_ART_GENERATION_STATUS_ACCEPTED => Ok(serde_json::json!({ + "operationId": state.operation_id, + "status": "running", + "pollAfterMs": state.poll_after_ms.unwrap_or(2_000), + })), + _ => Err("External Editor 生成账本尚未 accepted,不能恢复轮询".to_string()), + } +} + +pub(super) fn platform_art_generation_runtime_legacy_result( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED { + return Err("External Editor 生成账本不是旧同步完成状态".to_string()); + } + state + .legacy_result + .clone() + .ok_or_else(|| "External Editor 旧同步完成账本缺少 result".to_string()) +} + +pub(super) fn platform_art_generation_runtime_idempotency_key( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.idempotency_key +} + +pub(super) fn platform_art_generation_runtime_request_body_json( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.request_body_json +} + +pub(super) fn platform_art_generation_runtime_status( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.status +} + +pub(in crate::agent) fn platform_art_generation_runtime_recovery_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool != "canvas.asset_generate" { + return Ok(PlatformArtGenerationRuntimeRecovery::Missing); + } + let context = platform_art_generation_runtime_context_from_pending(pending); + let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else { + return Ok(PlatformArtGenerationRuntimeRecovery::Missing); + }; + Ok(match state.status.as_str() { + PLATFORM_ART_GENERATION_STATUS_PREPARED => { + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + } + PLATFORM_ART_GENERATION_STATUS_ACCEPTED => { + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + } + PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED => { + PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted + } + _ => unreachable!("validated External Editor generation state status"), + }) +} + +pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = platform_art_generation_runtime_path(root, agent_id, run_id)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "External Editor 生成账本")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("External Editor 生成账本必须是普通文件".to_string()) + } + Ok(_) => { + fs::remove_file(&path).map_err(|error| { + format!( + "删除 External Editor 生成账本失败:{}: {error}", + path.display() + ) + })?; + sync_agent_runtime_sidecar_parent(&path, "External Editor 生成账本") + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "读取 External Editor 生成账本元数据失败:{}: {error}", + path.display() + )), + } +} + +#[cfg(test)] +pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let api_base_url = + resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); + let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "durable-test-canvas", + "durable test generation", + &serde_json::json!({ + "prompt": "durable test generation", + "kind": "spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "referenceImageSrcs": [] + }), + &external_configuration_fingerprint, + )?; + if !created { + return Err("External Editor 测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( + root: &Path, + run_id: &str, +) -> Result { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "恢复已受理视觉规范图", + run_id, + "agent-ready-task-scheduler", + "等待外部生成", + vec!["恢复外部生成".to_string()], + )?; + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复已受理视觉规范图".to_string()), + input: serde_json::json!({ + "prompt": "恢复已受理视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复平台生成".to_string(), + plan_update: None, + plan: vec!["恢复外部生成".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let repository_fingerprint = build_repository_startup_context_at(root)?.fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + )?; + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + write_platform_art_generation_runtime_accepted_for_test(root, &pending)?; + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "running".to_string(); + runtime.phase = "action".to_string(); + runtime.current_action = "等待已受理生成".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + Ok(pending) +} + +#[cfg(test)] +mod external_generation_state_tests { + use super::*; + + fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成视觉规范图", + "external-generation-ledger-run", + "agent-ready-task-scheduler", + "准备生成视觉规范图", + vec!["生成视觉规范图".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备生成".to_string(), + plan_update: None, + plan: vec!["生成视觉规范图".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("repository context") + .fingerprint; + build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending action") + } + + #[test] + fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() { + let temporary = tempfile::tempdir().expect("create generation ledger project"); + let root = temporary.path(); + init_local_game_project_at(root, "generation-ledger", "生成账本测试") + .expect("init project"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let endpoint = "/api/external/v1/editor/images/generations"; + let request_body = serde_json::json!({ + "prompt": "生成统一视觉规范图", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test", + "test-api-key", + ); + + let (prepared, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare generation ledger"); + assert!(created); + validate_platform_art_generation_external_configuration( + &prepared, + "https://editor.example.test/", + "test-api-key", + ) + .expect("matching External Editor configuration"); + assert!(validate_platform_art_generation_external_configuration( + &prepared, + "https://other-editor.example.test", + "test-api-key", + ) + .is_err()); + assert!(validate_platform_art_generation_external_configuration( + &prepared, + "https://editor.example.test", + "different-api-key", + ) + .is_err()); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read prepared recovery"), + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + ); + let stable_key = prepared.idempotency_key.clone(); + let (reloaded, created_again) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &request_body, + &configuration_fingerprint, + ) + .expect("reload generation ledger"); + assert!(!created_again); + assert_eq!(reloaded.idempotency_key, stable_key); + assert_eq!( + serde_json::from_str::(&reloaded.request_body_json) + .expect("parse persisted exact request body"), + request_body + ); + + let accepted = mark_platform_art_generation_runtime_accepted( + root, + reloaded, + "operation-durable-1", + 1_500, + ) + .expect("persist accepted operation"); + assert_eq!( + accepted.operation_id.as_deref(), + Some("operation-durable-1") + ); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read accepted recovery"), + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + ); + assert!(prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &serde_json::json!({"prompt": "different request"}), + &configuration_fingerprint, + ) + .is_err()); + + remove_platform_art_generation_runtime_state_at(root, &pending.agent_id, &pending.run_id) + .expect("remove generation ledger"); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + } + + #[test] + fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { + let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let root = temporary.path(); + init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") + .expect("init project"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let fingerprint = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test", + "test-api-key", + ); + let request_body = serde_json::json!({ + "prompt": "生成统一视觉规范图", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-generation-canvas", + "生成统一视觉规范图", + &request_body, + &fingerprint, + ) + .expect("prepare legacy generation ledger"); + let completed = mark_platform_art_generation_runtime_legacy_completed( + root, + state, + &serde_json::json!({ + "resource": { + "resourceId": "legacy-resource", + "objectKey": "generated/legacy.png", + "imageSrc": "https://signed.example.test/legacy.png?token=secret" + }, + "warning": { "code": "source-only", "reason": "保留原图", "secret": "drop" }, + "unknownSensitiveField": "drop-me" + }), + ) + .expect("persist allowlisted legacy result"); + let durable = platform_art_generation_runtime_legacy_result(&completed) + .expect("read durable legacy result"); + assert_eq!(durable["resource"]["resourceId"], "legacy-resource"); + assert_eq!(durable["resource"]["objectKey"], "generated/legacy.png"); + assert!(durable["resource"].get("imageSrc").is_none()); + assert!(durable.get("unknownSensitiveField").is_none()); + assert!(durable["warning"].get("secret").is_none()); + + remove_platform_art_generation_runtime_state_at(root, &pending.agent_id, &pending.run_id) + .expect("remove completed legacy ledger"); + let (unsafe_state, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-generation-canvas", + "生成统一视觉规范图", + &request_body, + &fingerprint, + ) + .expect("prepare unsafe legacy generation ledger"); + assert!(mark_platform_art_generation_runtime_legacy_completed( + root, + unsafe_state, + &serde_json::json!({ + "imageSrc": "https://signed.example.test/legacy.png?token=secret" + }), + ) + .is_err()); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read prepared unsafe legacy recovery"), + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + ); + } + + #[cfg(unix)] + #[test] + fn generation_ledger_removal_rejects_symlinked_parent_directory() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("create symlink removal project"); + let root = project.path(); + init_local_game_project_at(root, "generation-symlink-removal", "生成账本符号链接测试") + .expect("init project"); + let outside = tempfile::tempdir().expect("create outside ledger directory"); + let agent_id = "art-director"; + let run_id = "symlinked-generation-run"; + let outside_agent = outside + .path() + .join(agent_runtime_confirmation_path_component(agent_id, "agent")); + fs::create_dir_all(&outside_agent).expect("create outside agent directory"); + let outside_ledger = outside_agent.join(format!( + "{}.json", + agent_runtime_confirmation_path_component(run_id, "run") + )); + fs::write(&outside_ledger, b"outside-sentinel").expect("write outside sentinel"); + let runtime_directory = root.join(".agent/runtime"); + fs::create_dir_all(&runtime_directory).expect("create runtime directory"); + let linked_directory = runtime_directory.join("canvas-generation-requests"); + if linked_directory.exists() { + fs::remove_dir_all(&linked_directory).expect("remove existing ledger directory"); + } + symlink(outside.path(), &linked_directory).expect("link outside ledger directory"); + + let error = remove_platform_art_generation_runtime_state_at(root, agent_id, run_id) + .expect_err("symlinked ledger parent must be rejected"); + assert!(error.contains("符号链接"), "{error}"); + assert_eq!( + fs::read(&outside_ledger).expect("outside sentinel remains"), + b"outside-sentinel" + ); + assert!(game_creator_agent_runtime_external_generation_exists( + root, agent_id, run_id + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 2149ad925..ddf2f9d82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -283,7 +283,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action_fingerprint, pending_action, false, - || observe_agent_runtime_task_list(root), + || observe_agent_runtime_task_list(root, agent_id, run_id), ), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), @@ -382,6 +382,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ run_id, task, &action.input, + pending_action, ) .await } @@ -414,7 +415,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ action_id, &action.input, ), - "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), + "agent.schedule_ready" => { + observe_agent_runtime_schedule_ready_tasks(root, agent_id, run_id, &action.input) + } "agent.action_history" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 02afd4148..d2c62d7d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -262,11 +262,15 @@ fn autonomous_initial_delegate_expected_artifacts( pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract( plan: &AgentRuntimeToolPlan, ) -> Result<(), String> { - let mut code_prototype = None; - let mut quality_review = None; + let mut design_director = None; let mut art_director = None; - let mut art_asset_plan = None; + let mut code_director = None; for action in &plan.actions { + if action.tool.trim() == "agent.spawn_isolated" { + return Err(autonomous_initial_collaboration_contract_error( + "首批只允许激活 design-director、art-director、code-director,不得启动 isolated child", + )); + } let Some(input) = autonomous_initial_delegate_input(action)? else { continue; }; @@ -284,11 +288,14 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ )); }; let slot = match target_agent_id { - "code-prototype" => &mut code_prototype, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + "design-director" => &mut design_director, "art-director" => &mut art_director, - "art-asset-plan" => &mut art_asset_plan, - _ => continue, + "code-director" => &mut code_director, + _ => { + return Err(autonomous_initial_collaboration_contract_error(format!( + "首批只允许激活 design-director、art-director、code-director,不得委派底层 Agent:{target_agent_id}" + ))); + } }; if slot.replace(input).is_some() { return Err(autonomous_initial_collaboration_contract_error(format!( @@ -297,111 +304,80 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ } } - let code_prototype = code_prototype.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派") + let design_director = design_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 design-director 委派") })?; - let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?; - let code_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(code_prototype.clone()), + let design_task = autonomous_initial_delegate_task(design_director, "design-director")?; + let design_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(design_director.clone()), &["acceptanceCriteria", "acceptance_criteria", "criteria"], ); - if std::iter::once(code_task.as_str()) + if !std::iter::once(design_task.as_str()) + .chain(design_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + )); + } + let design_artifacts = + autonomous_initial_delegate_expected_artifacts(design_director, "design-director")?; + if !design_artifacts.is_empty() { + return Err(autonomous_initial_collaboration_contract_error( + "首批 design-director 的 expectedArtifacts 必须为 []", + )); + } + + let art_director = art_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 art-director 委派") + })?; + let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; + let art_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(art_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(art_task.as_str()) + .chain(art_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 必须是非只读规范图生成任务", + )); + } + let art_artifacts = + autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; + if !art_artifacts + .iter() + .any(|path| path == "assets/art-spec.png") + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", + )); + } + + let code_director = code_director.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") + })?; + let code_task = autonomous_initial_delegate_task(code_director, "code-director")?; + let code_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(code_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if !std::iter::once(code_task.as_str()) .chain(code_criteria.iter().map(String::as_str)) .any(agent_runtime_task_explicitly_requires_read_only_delivery) { return Err(autonomous_initial_collaboration_contract_error( - "首批 code-prototype 必须是非只读实现任务", + "首批 code-director task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", )); } let code_artifacts = - autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?; - if !code_artifacts - .iter() - .any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH) - { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}" - ))); - } - - let quality_review = quality_review.ok_or_else(|| { - autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派") - })?; - let quality_task = - autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?; - let quality_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(quality_review.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if !std::iter::once(quality_task.as_str()) - .chain(quality_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { + autonomous_initial_delegate_expected_artifacts(code_director, "code-director")?; + if !code_artifacts.is_empty() { return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + "首批 code-director 的 expectedArtifacts 必须为 []", )); } - let quality_artifacts = autonomous_initial_delegate_expected_artifacts( - quality_review, - AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID, - )?; - if !quality_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error( - "首批 quality-review 的 expectedArtifacts 必须为 []", - )); - } - if let Some(art_director) = art_director { - let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_director.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 必须是非只读规范图生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } - } - if let Some(art_asset_plan) = art_asset_plan { - let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?; - let art_criteria = agent_runtime_tool_input_string_list( - &serde_json::Value::Object(art_asset_plan.clone()), - &["acceptanceCriteria", "acceptance_criteria", "criteria"], - ); - if std::iter::once(art_task.as_str()) - .chain(art_criteria.iter().map(String::as_str)) - .any(agent_runtime_task_explicitly_requires_read_only_delivery) - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-asset-plan 必须是非只读美术生成任务", - )); - } - let art_artifacts = - autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?; - let missing_artifacts = ["assets/manifest.art.json", "assets/art-spritesheet.png"] - .into_iter() - .filter(|required| !art_artifacts.iter().any(|path| path == required)) - .collect::>(); - if !missing_artifacts.is_empty() { - return Err(autonomous_initial_collaboration_contract_error(format!( - "首批 art-asset-plan 的 expectedArtifacts 缺少:{}", - missing_artifacts.join(", ") - ))); - } - } Ok(()) } @@ -584,7 +560,9 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at( }) }) }) - .unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string()); + .ok_or_else(|| { + "无法解析当前自主构建根 Run 的可信 source,拒绝按 GUI 完整 DAG 回退".to_string() + })?; let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source) .into_iter() .map(|task| task.id) @@ -1504,6 +1482,24 @@ pub(in crate::agent) fn restrict_agent_runtime_supervisor_collaboration_repair_t Ok(()) } +pub(in crate::agent) fn restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let delegate_function = native_runtime_function_name("agent.delegate") + .ok_or_else(|| "无法生成 autonomous 首批协作修复工具名:agent.delegate".to_string())?; + request + .function_tools + .retain(|tool| tool.name == delegate_function); + if !request + .function_tools + .iter() + .any(|tool| tool.name == delegate_function) + { + return Err("autonomous 首批协作修复工具目录缺少 agent.delegate".to_string()); + } + Ok(()) +} + pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collaboration_repair( error: &str, ) -> bool { @@ -1520,12 +1516,117 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo mod tests { use super::*; + fn autonomous_initial_delegate( + agent_id: &str, + expected_artifacts: &[&str], + ) -> AgentRuntimeToolAction { + let read_only_planner = matches!(agent_id, "design-director" | "code-director"); + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("建立首批 Leader 规划".to_string()), + input: serde_json::json!({ + "agentId": agent_id, + "task": if read_only_planner { + format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目") + } else { + format!("由 {agent_id} 完成首轮专业交付") + }, + "acceptanceCriteria": if read_only_planner { + vec!["只读给出可供后续底层 Agent 按需执行的规划,不得修改项目"] + } else { + vec!["视觉规范可供后续底层 Agent 按需执行"] + }, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": null, + "runId": null, + }), + } + } + + fn autonomous_initial_leader_plan() -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首批只激活程策美 Leader".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![ + autonomous_initial_delegate("design-director", &[]), + autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("code-director", &[]), + ], + response: String::new(), + } + } + + #[test] + fn autonomous_initial_collaboration_accepts_only_three_leaders() { + validate_agent_runtime_autonomous_initial_collaboration_contract( + &autonomous_initial_leader_plan(), + ) + .expect("three leader initial contract"); + } + + #[test] + fn autonomous_initial_collaboration_rejects_bottom_agent() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions[2] = + autonomous_initial_delegate("code-prototype", &[AGENT_RUNTIME_GAME_INDEX_PATH]); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("bottom agent must be rejected"); + + assert!(error.contains("不得委派底层 Agent:code-prototype")); + } + + #[test] + fn autonomous_initial_collaboration_rejects_isolated_child() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions.push(AgentRuntimeToolAction { + tool: "agent.spawn_isolated".to_string(), + reason: None, + input: serde_json::json!({"children": [], "joinMode": "all"}), + }); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("isolated child must be rejected"); + + assert!(error.contains("不得启动 isolated child")); + } + + #[test] + fn autonomous_initial_collaboration_requires_leader_artifacts() { + let mut plan = autonomous_initial_leader_plan(); + plan.actions[1] = autonomous_initial_delegate("art-director", &[]); + + let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan) + .expect_err("art artifact must be required"); + + assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png")); + } + #[test] fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() { let temporary = tempfile::tempdir().expect("create manifest DAG policy root"); let root = temporary.path().join("project"); init_local_game_project_at(&root, "manifest-dag-policy", "测试正式任务图等待") .expect("init manifest DAG policy project"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("resolve manifest DAG Supervisor session"); + append_unique_game_creator_agent_runtime_pending_task( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &session_id, + "测试正式任务图等待", + "manifest-dag-policy-run", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("queue trusted autonomous root task"); assert!(!autonomous_manifest_dag_in_progress_at(&root).expect("read pending DAG")); update_manifest_task_status_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index ed94dd930..a70863b64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -235,6 +235,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_has_pending_action_ledger( game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id) || game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id) || game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) + || game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id) } pub(in crate::agent) fn agent_runtime_parallel_read_batch_id( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index 19de323b5..a85194f36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -115,7 +115,7 @@ pub(in crate::agent) fn execute_game_creator_agent_runtime_parallel_safe_read_at "git.inspect" => observe_agent_runtime_git_inspect(root, &action.input), "file.list" => observe_agent_runtime_file_list(root, &action.input), "file.read" => observe_agent_runtime_file(root, &action.input), - "task.list" => observe_agent_runtime_task_list(root), + "task.list" => observe_agent_runtime_task_list(root, agent_id, run_id), _ => AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index 82b6476b8..fa5493585 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -529,6 +529,10 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action( { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); } + // pending action 是 generation / parallel sidecar 的 durable 身份锚点。先收束附属账本, + // 确保任何清理失败或进程中断都不会留下无法归属、却持续触发恢复扫描的孤儿。 + remove_platform_art_generation_runtime_state_at(root, agent_id, run_id)?; + remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)?; let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); let backup_path = agent_runtime_json_sidecar_backup_path(&path); remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?; @@ -547,8 +551,7 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action( "读取 Agent Runtime 待确认动作元数据失败:{}: {error}", path.display() )), - }?; - remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id) + } } pub(in crate::agent) fn remove_game_creator_agent_runtime_confirmations( @@ -682,9 +685,61 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( #[cfg(test)] mod tests { - use super::validate_agent_runtime_pending_serialized_content; + use super::*; use std::path::Path; + fn pending_external_generation_action( + root: &Path, + run_id: &str, + ) -> AgentRuntimePendingToolAction { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成视觉规范图", + run_id, + "agent-ready-task-scheduler", + "准备生成视觉规范图", + vec!["生成视觉规范图".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备生成".to_string(), + plan_update: None, + plan: vec!["生成视觉规范图".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("repository context") + .fingerprint; + build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending external generation action") + } + #[test] fn pending_content_allows_api_key_security_guidance_without_secret_material() { for task in [ @@ -732,4 +787,59 @@ mod tests { assert!(error.contains(&format!("#{rule}")), "{content}: {error}"); } } + + #[test] + fn generation_cleanup_failure_preserves_pending_identity_anchor() { + let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let root = temporary.path(); + let run_id = "generation-cleanup-order-run"; + init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") + .expect("init project"); + let pending = pending_external_generation_action(root, run_id); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write pending action"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write accepted generation state"); + + let generation_path = root.join(format!( + ".agent/runtime/canvas-generation-requests/art-director/{run_id}.json" + )); + fs::remove_file(&generation_path).expect("remove generation state fixture"); + fs::create_dir(&generation_path).expect("replace generation state with invalid directory"); + + let error = remove_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect_err("generation cleanup failure must stop pending removal"); + assert!(error.contains("External Editor 生成账本必须是普通文件")); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert_eq!( + read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read preserved pending identity"), + pending + ); + + fs::remove_dir(&generation_path).expect("remove invalid generation fixture"); + remove_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("retry cleanup after generation state is absent"); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + root, + &pending.agent_id, + &pending.run_id + )); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 18fc74e29..804deadb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -655,6 +655,20 @@ pub(in crate::agent) fn supervisor_collaboration_policy_completion_blocker_at_lo if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return None; } + if read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) + .ok() + .flatten() + .is_some_and(|binding| { + binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + }) + { + // game-chat 首版由 source-aware manifest scheduler 固定编排 + // code -> static smoke -> playtest,不再要求 Provider 建立额外委派波。 + return None; + } let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { Ok(resolution) => resolution.policy, Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 0084481cb..383a7c415 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -2,6 +2,43 @@ use super::*; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; +const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。"; + +fn game_chat_fast_path_prompt_for_root_source( + agent_id: &str, + root_source: &str, +) -> Option<&'static str> { + (agent_id.trim() == "code-prototype" + && root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT) +} + +pub(in crate::agent) fn agent_runtime_root_source_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "Agent Runtime 缺少 Run Profile 绑定,无法解析 root source".to_string())?; + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + return Ok(binding.source); + } + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "Agent Runtime 缺少 root Run Profile 绑定".to_string())?; + if root_binding.agent_id != binding.root_agent_id + || root_binding.run_id != binding.root_run_id + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("Agent Runtime root Run Profile 绑定身份不一致".to_string()); + } + Ok(root_binding.source) +} + fn game_creator_agent_context_preload_notice(agent_id: &str) -> &'static str { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { "下方已预加载有界仓库启动上下文、Supervisor 当前 Session、legacy 项目对话、项目记忆、黑板和资产摘要;源码正文仍只能通过已获准工具读取" @@ -142,6 +179,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str(playtest_contract); system_prompt.push_str(" 只有当前 revision 通过 game.static_smoke,并由 preview.validate 对上述固定状态面和控件完成真实浏览器动作后,Runtime 才允许最终回复;不要伪造已通过 observation。"); } + if autonomous_game_build { + let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; + if let Some(fast_path_prompt) = + game_chat_fast_path_prompt_for_root_source(agent_id, &root_source) + { + system_prompt.push_str("\n\n"); + system_prompt.push_str(fast_path_prompt); + } + } let mut request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(prompt), @@ -349,10 +395,13 @@ pub(in crate::agent) fn build_game_creator_background_agent_context( #[cfg(test)] mod tests { use super::{ + agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, build_game_creator_agent_background_tool_plan_request, - game_creator_agent_context_preload_notice, init_local_game_project_at, - start_game_creator_agent_runtime_task_at, GameCreatorMcpCatalog, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice, + init_local_game_project_at, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, + GameCreatorMcpCatalog, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; @@ -456,4 +505,87 @@ mod tests { assert!(protocol.contains("不得反复提交 final response")); assert!(protocol.contains("不得按项目正文硬编码")); } + + #[test] + fn game_chat_fast_path_prompt_forces_one_shot_playable_write_only_for_code_agent() { + let prompt = game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .expect("game-chat code fast path prompt"); + + assert!(prompt.contains("五分钟快车道")); + assert!(prompt.contains("一次 Provider planning")); + assert!(prompt.contains("直接调用一次 file.write")); + assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派")); + assert!(prompt.contains("../assets/art-spec.png")); + assert!(prompt.contains("平台视觉规范图")); + assert!(prompt.contains("主要背景")); + assert!(prompt.contains("玩家角色和目标实体")); + assert!(prompt.contains("禁止仅放置隐藏 img")); + assert!(prompt.contains("不能退回纯 Canvas 几何兜底")); + assert!(!prompt.contains("art-spritesheet.png")); + assert!(game_chat_fast_path_prompt_for_root_source( + "quality-review", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + ) + .is_none()); + assert!(game_chat_fast_path_prompt_for_root_source( + "code-prototype", + "agent-background-task", + ) + .is_none()); + } + + #[test] + fn root_source_resolver_uses_root_binding_for_game_chat_child() { + let temporary = tempfile::tempdir().expect("temporary project root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "root-source-project", "root source test") + .expect("project init"); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "root-source-game-chat-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat root profile"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("root-source-game-chat-child-delegation".to_string()), + }; + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + "code-prototype", + "root-source-game-chat-child", + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("bind game-chat child profile"); + + assert_eq!( + agent_runtime_root_source_at(&root, &parent.agent_id, &parent.run_id) + .expect("resolve root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + assert_eq!( + agent_runtime_root_source_at(&root, &child.agent_id, &child.run_id) + .expect("resolve child root source"), + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 21336aba1..556316ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -52,7 +52,7 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { .collect::>() }) .unwrap_or_default(); - for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] { + for agent_id in ["design-director", "art-director", "code-director"] { if error.contains(&format!("缺少 {agent_id} 委派")) && !missing.iter().any(|value| value == agent_id) { @@ -62,6 +62,31 @@ fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { missing } +fn is_autonomous_initial_leader_delegate_action(action: &AgentRuntimeToolAction) -> bool { + if action.tool.trim() != "agent.delegate" { + return false; + } + let Some(input) = action.input.as_object() else { + return false; + }; + if input + .get("repairOfDelegationId") + .or_else(|| input.get("repair_of_delegation_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + return false; + } + matches!( + input + .get("agentId") + .or_else(|| input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim), + Some("design-director" | "art-director" | "code-director") + ) +} + fn restrict_supervisor_collaboration_repair_to_missing_agents( request: &mut LlmRunRequest, error: &str, @@ -843,9 +868,22 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at if force_supervisor_initial_collaboration { supervisor_collaboration_repair_active = true; if let Some(actions) = supervisor_collaboration_candidate_actions.take() { - supervisor_collaboration_repair_actions = actions; + supervisor_collaboration_repair_actions = + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + actions + .into_iter() + .filter(is_autonomous_initial_leader_delegate_action) + .collect() + } else { + actions + }; } restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + restrict_agent_runtime_autonomous_initial_collaboration_repair_tools( + &mut request, + )?; + } restrict_supervisor_collaboration_repair_to_missing_agents( &mut request, &protocol_error, @@ -854,7 +892,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留 agent.delegate。必须在同一响应一次性建立完整首批合同,且只允许以下三个非 repair 委派,各出现一次:design-director 与 code-director 的 task 或 acceptanceCriteria 必须显式声明只读且不得修改项目,expectedArtifacts 必须为 [];art-director 必须是非只读规范图生成任务,expectedArtifacts 必须包含 assets/art-spec.png。三者都必须提供非空 task、1-8 条 acceptanceCriteria,并设置 repairOfDelegationId=null、runId=null。不得委派 code-prototype、quality-review、design-foundation、art-asset-plan 或其它底层 Agent,不得调用 agent.spawn_isolated,不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" ) } else { format!( @@ -1081,12 +1119,42 @@ mod supervisor_collaboration_repair_tests { .is_empty()); assert_eq!( supervisor_collaboration_missing_agent_ids( - "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0" + "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=design-director,art-director,code-director · isolatedChildrenTotal=0" ), - vec!["code-prototype".to_string(), "quality-review".to_string()] + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] ); } + #[test] + fn autonomous_initial_repair_keeps_only_leader_delegates() { + let actions = [ + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "design-director", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.delegate", + serde_json::json!({"agentId": "code-prototype", "repairOfDelegationId": null}), + ), + collaboration_action( + "agent.spawn_isolated", + serde_json::json!({"children": [], "joinMode": "all"}), + ), + ]; + + let kept = actions + .iter() + .filter(|action| is_autonomous_initial_leader_delegate_action(action)) + .map(|action| action.input["agentId"].as_str().expect("leader id")) + .collect::>(); + + assert_eq!(kept, vec!["design-director"]); + } + #[test] fn isolated_repair_replaces_the_single_accumulated_slot() { let accumulated = vec![collaboration_action( 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 ccba17a4f..ae7de3fc0 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 @@ -501,6 +501,91 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { assert_eq!(assistants, vec![response]); } +#[test] +fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + assert!( + !GameCreatorLlmConfig::default().stream, + "the production default exercises the non-stream final-reply path" + ); + let project = tempfile::tempdir().expect("create non-stream professional reply project"); + let root = project.path(); + init_local_game_project_at( + root, + "non-stream-professional-reply", + "非流式专业 Agent 回复", + ) + .expect("initialize non-stream professional reply project"); + let mut state = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成 game-chat 首版统一视觉规范", + "non-stream-art-director-run", + "agent-delegate", + "整理专业 Agent 最终回复", + vec!["生成并登记统一视觉规范图".to_string()], + ) + .expect("start non-stream professional runtime"); + state.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + state.parent_run_id = Some("game-chat-parent-run".to_string()); + state.delegation_id = Some("game-chat-art-director-delegation".to_string()); + state.loop_iteration = 1; + state.status = "running".to_string(); + state.phase = "response".to_string(); + state.current_action = "直接采用非流式最终回复".to_string(); + state.waiting_on = "finalization 持久化".to_string(); + state.next_step = "提交 durable response stream 投影".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append non-stream professional runtime task"); + write_game_creator_agent_runtime_state(root, &state) + .expect("write non-stream professional runtime state"); + + let response_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read non-stream response revision") + .revision; + assert!( + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id,) + .expect("read absent pre-finalization response stream") + .is_none(), + "stream=false must enter finalization without a pre-existing stream sidecar" + ); + + let response = "美术 Agent:统一视觉规范图已生成并登记。"; + let completed = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + response, + response_revision, + &[], + ) + .expect("finalize non-stream professional reply"); + assert!( + matches!(completed, AgentBackgroundFinalizationOutcome::Completed(_)), + "unexpected finalization outcome: {completed:?}" + ); + + let mut later_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before later stage mutation"); + later_revision.revision = later_revision.revision.saturating_add(1); + later_revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, &later_revision) + .expect("simulate a later game-chat stage advancing project revision"); + + let queried = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("query completed professional runtime after revision advance"); + let stream = queried + .response_stream + .expect("durable professional final reply remains queryable"); + assert_eq!( + stream.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(stream.request_kind, "final-reply"); + assert_eq!(stream.response_revision, response_revision); + assert_eq!(stream.accumulated_text, response); +} + #[test] fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { let (project, state, response_revision, snapshot) = 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 791aca0bb..bb36a99d4 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 @@ -206,10 +206,13 @@ pub(super) struct AgentRuntimeAutonomousPlaytestReceipt { mod entrypoints; mod finalization; +mod game_chat_fast_path; mod interaction; mod lifecycle_control; mod main_loop; #[cfg(test)] +mod main_loop_deadline_tests; +#[cfg(test)] mod main_loop_tests; mod pending_execution; mod pending_recovery; @@ -220,6 +223,7 @@ mod task_start; pub(in crate::agent) use entrypoints::*; pub(in crate::agent) use finalization::*; +pub(in crate::agent) use game_chat_fast_path::*; pub(in crate::agent) use interaction::*; pub(in crate::agent) use lifecycle_control::*; pub(in crate::agent) use main_loop::*; @@ -278,6 +282,7 @@ pub(crate) use provider_recovery::{ }; pub(crate) use recovery_scan::{ cleanup_game_creator_agent_runtime_completed_finalizations_at, + has_recoverable_game_creator_agent_background_tasks_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_pending_action_for_agent_at, wake_pending_game_creator_agent_background_tasks_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs new file mode 100644 index 000000000..8a8f51cdc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/game_chat_fast_path.rs @@ -0,0 +1,801 @@ +//! A deterministic, dependency-free game-chat fallback. +//! +//! This module deliberately does not start the runtime or write project files. It only +//! renders a small, self-contained HTML document that the runtime can use when it needs to +//! make a first playable version available before the normal generation pass finishes. + +use super::*; + +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS: u64 = 240; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS: u64 = 300; +pub(crate) const GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX: &str = + "game-chat-first-playable-hard-budget-exhausted"; + +const FALLBACK_THEME_MARKER: &str = "__GAME_CHAT_THEME__"; +const FALLBACK_PLATFORM_ART_MARKER: &str = "__GAME_CHAT_PLATFORM_ART__"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GameChatFastPathBudget { + pub(crate) root_agent_id: String, + pub(crate) root_run_id: String, + pub(crate) baseline_revision: u64, + pub(crate) elapsed_seconds: u64, +} + +pub(crate) fn game_chat_fast_path_budget_at( + root: &Path, + agent_id: &str, + run_id: &str, + now: u64, +) -> Result, String> { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "game-chat 快车道缺少当前 Run Profile 绑定".to_string())?; + let root_binding = + if binding.root_agent_id == binding.agent_id && binding.root_run_id == binding.run_id { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "game-chat 快车道缺少 root Run Profile 绑定".to_string())? + }; + if root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + { + return Ok(None); + } + if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || root_binding.root_agent_id != root_binding.agent_id + || root_binding.root_run_id != root_binding.run_id + { + return Err("game-chat 快车道 root Run Profile 绑定身份不一致".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &root_binding.agent_id, &root_binding.run_id)? + .ok_or_else(|| "game-chat 快车道缺少自主构建完成合同".to_string())?; + if contract.run_profile_binding_fingerprint != root_binding.binding_fingerprint { + return Err("game-chat 快车道完成合同与 root binding 不匹配".to_string()); + } + Ok(Some(GameChatFastPathBudget { + root_agent_id: root_binding.agent_id, + root_run_id: root_binding.run_id, + baseline_revision: contract.baseline_revision, + elapsed_seconds: now.saturating_sub(root_binding.bound_at), + })) +} + +pub(crate) fn game_chat_fast_path_provider_timeout( + budget: &GameChatFastPathBudget, +) -> Option { + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + .checked_sub(budget.elapsed_seconds) + .filter(|remaining| *remaining > 0) + .map(std::time::Duration::from_secs) +} + +fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "game-chat 首版快车道正在按固定最短路径推进。".to_string(), + plan_update: None, + plan: Vec::new(), + actions: vec![AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("在五分钟预算内形成并验证首个可玩版本".to_string()), + input, + }], + response: String::new(), + } +} + +fn game_chat_fast_path_fallback_write_plan_for_root( + root: &Path, + task: &str, +) -> Result { + if !game_chat_fast_path_has_platform_art_asset(root) { + return Err( + "game-chat 首版缺少已登记且可验证的平台视觉规范图,拒绝退回纯几何 Canvas".to_string(), + ); + } + Ok(game_chat_fast_path_action( + "file.write", + serde_json::json!({ + "path": AGENT_RUNTIME_GAME_INDEX_PATH, + "content": render_game_chat_fast_path_html(task), + }), + )) +} + +fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + validate_manifest_required_visual_asset(root, &manifest, "art-director").is_ok() +} + +fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool { + read_manifest_for_project(root).is_ok_and(|manifest| { + validate_manifest_required_visual_asset(root, &manifest, task_id).is_ok() + }) +} + +fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool { + runtime.observations.iter().rev().any(|observation| { + [ + "canvas.asset_generate:failed", + "canvas.asset_generate:blocked", + "canvas.asset_generate:rejected", + "canvas.asset_generate:needs-reconciliation", + ] + .iter() + .any(|prefix| observation.starts_with(prefix)) + }) +} + +fn game_chat_fast_path_root_task( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let root_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &budget.root_agent_id, + &budget.root_run_id, + )? + .ok_or_else(|| "game-chat 首版快车道缺少 root 任务记录".to_string())? + .task; + if root_task.trim().is_empty() { + return Err("game-chat 首版快车道 root 任务为空".to_string()); + } + Ok(root_task) +} + +fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> AgentRuntimeToolPlan { + let theme = safe_theme_summary(root_task); + let (prompt, output_path, aspect_ratio, image_size, asset_kind, asset_label) = match task_id { + "art-director" => ( + format!( + "为原创小游戏“{theme}”生成统一视觉规范图:清晰展示玩家主体、目标物、场景地块、障碍、UI 图标、状态反馈、统一色板和材质规则;同一张图必须可直接作为首版主要背景、玩家和目标的可见绘制来源,不得使用现有知名游戏角色或标识。" + ), + AGENT_RUNTIME_ART_SPEC_PATH, + "1:1", + "1K", + "icon-spec", + "游戏统一视觉规范图", + ), + _ => unreachable!("only deterministic game-chat art tasks use this helper"), + }; + game_chat_fast_path_action( + "canvas.asset_generate", + serde_json::json!({ + "prompt": prompt, + "outputPath": output_path, + "aspectRatio": aspect_ratio, + "imageSize": image_size, + "assetKind": asset_kind, + "assetLabel": asset_label, + "replaceExisting": false, + }), + ) +} + +pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at( + root: &Path, + budget: &GameChatFastPathBudget, + _fallback_task: &str, +) -> Result { + let root_task = game_chat_fast_path_root_task(root, budget)?; + game_chat_fast_path_fallback_write_plan_for_root(root, &root_task) +} + +fn game_chat_fast_path_verified_delivery_plan( + runtime: &AgentRuntimeState, + response: &str, +) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: "首版快车道已取得当前 revision 的验证证据。".to_string(), + plan_update: agent_runtime_verified_delivery_completion_plan_update(runtime), + plan: Vec::new(), + actions: Vec::new(), + response: response.to_string(), + } +} + +fn game_chat_fast_path_current_revision_is_verified( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + Ok(revision.revision > 0 + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_tool.as_deref() == Some("game.static_smoke") + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)) +} + +fn game_chat_fast_path_current_revision_has_playtest_receipt( + root: &Path, + budget: &GameChatFastPathBudget, +) -> Result { + let contract = + read_autonomous_completion_contract(root, &budget.root_agent_id, &budget.root_run_id)? + .ok_or_else(|| "game-chat 首版快车道缺少 root 完成合同".to_string())?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + Ok(read_autonomous_playtest_receipt(root, &contract)? + .is_some_and(|receipt| receipt.revision == revision.revision)) +} + +pub(crate) fn game_chat_fast_path_plan_at( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + now: u64, +) -> Result, String> { + if runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let Some(budget) = + game_chat_fast_path_budget_at(root, &runtime.agent_id, &runtime.run_id, now)? + else { + return Ok(None); + }; + match runtime.agent_id.as_str() { + "art-director" => { + if game_chat_fast_path_has_visual_asset(root, "art-director") { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "统一视觉规范图已生成并登记。", + ))); + } + if !editor_api_key_is_configured() { + return Err( + "game-chat 首版必须配置 External Editor API Key 才能生成平台美术资源" + .to_string(), + ); + } + if game_chat_fast_path_canvas_generation_failed(runtime) { + return Err( + "game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版" + .to_string(), + ); + } + let root_task = game_chat_fast_path_root_task(root, &budget)?; + Ok(Some(game_chat_fast_path_canvas_asset_plan( + "art-director", + &root_task, + ))) + } + "preview-readiness" => { + if game_chat_fast_path_current_revision_is_verified(root, runtime)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过静态自检。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))) + } + } + "preview-playtest" => { + if game_chat_fast_path_current_revision_has_playtest_receipt(root, &budget)? { + Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本已通过桌面和移动端试玩。", + ))) + } else { + Ok(Some(game_chat_fast_path_action( + "preview.validate", + serde_json::json!({ + "viewports": ["desktop", "mobile"], + "expectedText": [], + "settleMs": 400, + "failOnConsoleError": true, + "playtestScenario": null, + }), + ))) + } + } + "code-prototype" => { + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + let owns_current_mutation = gate.mutation_revision == Some(revision.revision); + let current_revision_verified = owns_current_mutation + && gate.verified_revision == Some(revision.revision) + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED); + let current_revision_failed = owns_current_mutation + && gate.last_verification_status.as_deref() + == Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED); + + if current_revision_verified { + return Ok(Some(game_chat_fast_path_verified_delivery_plan( + runtime, + "首个可玩版本代码已生成并通过静态自检。", + ))); + } + if owns_current_mutation && !current_revision_failed { + return Ok(Some(game_chat_fast_path_action( + "command.run_limited", + serde_json::json!({ "commandId": "game.static_smoke" }), + ))); + } + if current_revision_failed + || runtime.loop_iteration > 1 + || budget.elapsed_seconds >= GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + { + return Ok(Some(game_chat_fast_path_fallback_write_plan_for_budget_at( + root, &budget, task, + )?)); + } + Ok(None) + } + _ => Ok(None), + } +} + +/// Render a safe title/theme summary from the user's request. +/// +/// The summary is escaped before it is inserted into HTML. It is only placed in a data +/// attribute and text nodes; it is never interpolated into JavaScript source. +pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String { + let theme = html_escape(&safe_theme_summary(prompt)); + let platform_art = + r#"平台生成的统一视觉规范图"#; + FALLBACK_GAME_HTML + .replace(FALLBACK_THEME_MARKER, &theme) + .replace(FALLBACK_PLATFORM_ART_MARKER, platform_art) +} + +fn safe_theme_summary(prompt: &str) -> String { + let mut summary = String::new(); + let mut previous_was_space = false; + for character in prompt.trim().chars() { + if character.is_control() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + if character.is_whitespace() { + if !previous_was_space { + summary.push(' '); + previous_was_space = true; + } + continue; + } + summary.push(character); + previous_was_space = false; + if summary.chars().count() >= 56 { + break; + } + } + let summary = summary.trim(); + if summary.is_empty() { + "轻量互动挑战".to_string() + } else { + summary.to_string() + } +} + +fn html_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +const FALLBACK_GAME_HTML: &str = r###" + + + + + Genarrative · __GAME_CHAT_THEME__ + + + +
+
+
+

首版可试玩 · __GAME_CHAT_THEME__

+

目标:收集能量并保持推进。胜利和失败都可以重开,当前版本不会自动结束。

+
+
得分 0准备就绪
+
+
+ __GAME_CHAT_PLATFORM_ART__ + +
点击开始,然后操作收集能量准备就绪
+ +
+ +
+ + + +"###; + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{validate_game_html_smoke, validate_playable_game_html}; + + fn write_visual_png(path: &Path, alpha: u8) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 160, 220, alpha])) + .save(path) + .expect("write valid PNG fixture"); + } + + fn register_platform_art_spec(root: &Path) { + write_visual_png(&root.join(AGENT_RUNTIME_ART_SPEC_PATH), u8::MAX); + register_local_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("platform-art-canvas".to_string()), + resource_id: Some("platform-art-spec-resource".to_string()), + asset_object_id: Some("platform-art-spec-object".to_string()), + task_id: Some("art-director".to_string()), + prompt: None, + model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register platform art spec fixture"); + } + + #[test] + fn budgets_leave_a_soft_and_hard_window() { + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240); + assert_eq!(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, 300); + assert!( + GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS + < GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ); + } + + #[test] + fn fallback_html_satisfies_playable_contract() { + let html = render_game_chat_fast_path_html("星河收集挑战"); + validate_playable_game_html(&html, "game-chat fast path").expect("playable contract"); + validate_game_html_smoke(&html).expect("static smoke contract"); + for marker in [ + "requestAnimationFrame", + "playable-web-game-state.v1", + "data-playtest-id=\"start\"", + "data-playtest-id=\"primary-action\"", + "data-playtest-id=\"restart\"", + "pointerdown", + "keydown", + ] { + assert!(html.contains(marker), "missing fallback marker: {marker}"); + } + assert!(html.contains("src=\"../assets/art-spec.png\"")); + assert!(!html.contains("art-spritesheet.png")); + assert!(html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)")); + assert!(html.contains("playerX - 18")); + assert!(html.contains("targetX - 52")); + assert!(!html.contains("opacity: 0")); + assert!(!html.contains("width: 1px")); + } + + #[test] + fn fallback_html_requires_registered_platform_art_and_uses_it_prominently() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-platform-art", "platform art") + .expect("initialize project"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "没有美术资源时必须失败关闭", + ) + .is_err()); + + register_platform_art_spec(&root); + + let with_asset = + game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台规范图") + .expect("render art-backed fallback"); + let with_html = with_asset.actions[0].input["content"] + .as_str() + .expect("fallback html with asset"); + assert!(with_html.contains("src=\"../assets/art-spec.png\"")); + assert!(!with_html.contains("art-spritesheet.png")); + assert!( + with_html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)") + ); + assert!(with_html.contains("playerX - 18")); + assert!(with_html.contains("targetX - 52")); + } + + #[test] + fn fallback_html_ignores_registered_art_when_file_is_missing() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at( + &root, + "game-chat-platform-art-missing", + "platform art missing", + ) + .expect("initialize project"); + register_platform_art_spec(&root); + let asset_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH); + fs::remove_file(asset_path).expect("remove platform art fixture"); + + assert!(game_chat_fast_path_fallback_write_plan_for_root( + &root, + "缺图时拒绝 Canvas fallback", + ) + .is_err()); + } + + #[test] + fn current_revision_is_verified_requires_static_smoke_tool() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-verification", "verification") + .expect("initialize project"); + let runtime = default_game_creator_agent_runtime_state("preview-readiness", "verify-run"); + + let mut revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + revision.revision = 1; + write_game_creator_agent_runtime_project_revision(&root, &revision) + .expect("write project revision"); + let mut gate = + default_agent_runtime_verification_gate(&root, &runtime.agent_id, &runtime.run_id) + .expect("default verification gate"); + gate.verified_revision = Some(1); + gate.last_verification_status = Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED.to_string()); + gate.last_verification_tool = Some("preview.validate".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write verification gate"); + assert!( + !game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check preview verification") + ); + + gate.last_verification_tool = Some("game.static_smoke".to_string()); + write_game_creator_agent_runtime_verification_gate(&root, &gate) + .expect("write static smoke gate"); + assert!( + game_chat_fast_path_current_revision_is_verified(&root, &runtime) + .expect("check static smoke verification") + ); + } + + #[test] + fn fallback_budget_uses_root_user_task_instead_of_child_manifest_prompt() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-test", "fallback test") + .expect("initialize project"); + let run_id = "game-chat-fallback-root-task"; + let mut root_state = default_game_creator_agent_runtime_state( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + root_state.current_task = "制作星空飞船收集能量小游戏".to_string(); + append_game_creator_agent_runtime_task(&root, &root_state).expect("append root task"); + let budget = GameChatFastPathBudget { + root_agent_id: root_state.agent_id.clone(), + root_run_id: root_state.run_id.clone(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + register_platform_art_spec(&root); + + let plan = game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "处理 manifest ready 任务:任务 ID:code-prototype;专业组:code", + ) + .expect("render fallback from root task"); + let content = plan.actions[0].input["content"] + .as_str() + .expect("fallback html content"); + assert!(content.contains("星空飞船收集能量小游戏")); + assert!(!content.contains("任务 ID:code-prototype")); + } + + #[test] + fn fallback_budget_fails_closed_without_root_task_journal() { + let temporary = tempfile::tempdir().expect("temporary project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "game-chat-fallback-missing", "fallback missing") + .expect("initialize project"); + let budget = GameChatFastPathBudget { + root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + root_run_id: "missing-root-run".to_string(), + baseline_revision: 0, + elapsed_seconds: 240, + }; + assert!(game_chat_fast_path_fallback_write_plan_for_budget_at( + &root, + &budget, + "任务 ID:code-prototype", + ) + .is_err()); + } + + #[test] + fn prompt_is_html_escaped_and_never_becomes_script() { + let html = render_game_chat_fast_path_html(" & \"主题\""); + assert!(!html.contains("") else { + return output; + }; + cursor = body_start + end_offset + "".len(); + } + output.push_str(&content[cursor..]); + output +} + +fn relative_visual_url_resolves_to_asset(value: &str, asset_path: &str) -> bool { + let value = value + .trim() + .trim_matches(|character| matches!(character, '\'' | '"')); + let path = value.split(['?', '#']).next().unwrap_or_default().trim(); + if path.is_empty() + || path.starts_with('/') + || path.contains(['\\', '%', ':']) + || asset_path.starts_with('/') + { + return false; + } + let mut components = vec!["game"]; + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + if components.pop().is_none() { + return false; + } + } + value => components.push(value), + } + } + components.join("/") == asset_path +} + +fn html_attribute_value<'a>(tag: &'a str, attribute: &str) -> Option<&'a str> { + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0; + while cursor + attribute_bytes.len() <= bytes.len() { + let offset = tag[cursor..].find(attribute)?; + let start = cursor + offset; + let end = start + attribute_bytes.len(); + let left_boundary = + start == 0 || matches!(bytes[start - 1], b'<' | b' ' | b'\t' | b'\r' | b'\n'); + let right_boundary = + end == bytes.len() || matches!(bytes[end], b'=' | b' ' | b'\t' | b'\r' | b'\n'); + if !left_boundary || !right_boundary { + cursor = end; + continue; + } + let mut value_start = end; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + if bytes.get(value_start) != Some(&b'=') { + cursor = end; + continue; + } + value_start += 1; + while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() { + value_start += 1; + } + let quote = bytes.get(value_start).copied(); + if matches!(quote, Some(b'\'' | b'"')) { + value_start += 1; + let value_end = bytes[value_start..] + .iter() + .position(|byte| Some(*byte) == quote) + .map(|offset| value_start + offset)?; + return Some(&tag[value_start..value_end]); + } + let value_end = bytes[value_start..] + .iter() + .position(|byte| byte.is_ascii_whitespace() || *byte == b'>') + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + return (value_end > value_start).then(|| &tag[value_start..value_end]); + } + None +} + +fn css_numeric_property(value: &str, property: &str) -> Option { + let compact = value + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let marker = format!("{property}:"); + let start = compact.find(&marker)? + marker.len(); + compact[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || matches!(character, '.' | '-')) + .collect::() + .parse() + .ok() +} + +fn tag_dimension(tag: &str, attribute: &str, property: &str) -> Option { + html_attribute_value(tag, attribute) + .and_then(|value| value.trim_end_matches("px").parse().ok()) + .or_else(|| { + html_attribute_value(tag, "style") + .and_then(|style| css_numeric_property(style, property)) + }) +} + +fn tag_is_obviously_hidden_or_tiny(tag: &str, default_dimensions: (u32, u32)) -> bool { + let compact = tag + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let hidden_attribute = tag + .split(|character: char| character.is_ascii_whitespace() || matches!(character, '<' | '>')) + .any(|token| token == "hidden" || token.starts_with("hidden=")); + if hidden_attribute + || compact.contains("display:none") + || compact.contains("visibility:hidden") + || compact.contains("left:-999") + || compact.contains("top:-999") + || compact.contains("translate(-999") + || css_numeric_property(tag, "opacity").is_some_and(|opacity| opacity < 0.25) + { + return true; + } + let width = tag_dimension(tag, "width", "width").unwrap_or(default_dimensions.0 as f64); + let height = tag_dimension(tag, "height", "height").unwrap_or(default_dimensions.1 as f64); + width < 24.0 || height < 24.0 +} + +fn css_contains_resolving_url(style: &str, asset_path: &str) -> bool { + let mut cursor = 0; + while let Some(offset) = style[cursor..].find("url(") { + let start = cursor + offset + "url(".len(); + let Some(end_offset) = style[start..].find(')') else { + return false; + }; + let end = start + end_offset; + if relative_visual_url_resolves_to_asset(&style[start..end], asset_path) { + return true; + } + cursor = end + 1; + } + false +} + +fn selector_is_bound_to_markup(selector: &str, markup: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !class_name.is_empty() + && markup.split('<').any(|tag| { + html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return !id.is_empty() + && markup + .split('<') + .any(|tag| html_attribute_value(tag, "id") == Some(id)); + } + let tag_name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !tag_name.is_empty() + && markup + .split('<') + .any(|tag| tag.trim_start().starts_with(tag_name)) + }) +} + +fn selector_matches_tag(selector: &str, tag: &str) -> bool { + selector.split(',').any(|selector| { + let selector = selector.trim(); + if let Some(class_name) = selector.strip_prefix('.') { + let class_name = class_name + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "class").is_some_and(|classes| { + classes + .split_ascii_whitespace() + .any(|value| value == class_name) + }); + } + if let Some(id) = selector.strip_prefix('#') { + let id = id + .split(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }) + .next() + .unwrap_or_default(); + return html_attribute_value(tag, "id") == Some(id); + } + let name = selector + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .unwrap_or_default(); + !name.is_empty() + && tag + .trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with(name) + }) +} + +fn tag_is_hidden_by_stylesheet(tag: &str, markup: &str, default_dimensions: (u32, u32)) -> bool { + markup.split("').map(|offset| offset + 1) else { + return false; + }; + let Some(body_end) = tail[body_start..].find("") else { + return false; + }; + tail[body_start..body_start + body_end] + .split('}') + .any(|rule| { + rule.rsplit_once('{') + .is_some_and(|(selector, declarations)| { + selector_matches_tag(selector, tag) + && tag_is_obviously_hidden_or_tiny( + &format!("
"), + default_dimensions, + ) + }) + }) + }) +} + +fn position_is_inside_javascript_string(content: &str, position: usize) -> bool { + let mut quote = None; + let mut escaped = false; + for byte in content.as_bytes().iter().copied().take(position) { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } + } + quote.is_some() +} + +fn matching_javascript_brace(content: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[open..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'{' { + depth += 1; + } else if byte == b'}' { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn named_javascript_function_ranges(content: &str) -> Vec<(String, usize, usize)> { + let mut ranges = Vec::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find("function ") { + let definition_start = cursor + offset; + cursor = definition_start + "function ".len(); + if position_is_inside_javascript_string(content, definition_start) { + continue; + } + while content + .as_bytes() + .get(cursor) + .is_some_and(u8::is_ascii_whitespace) + { + cursor += 1; + } + let name_start = cursor; + while content + .as_bytes() + .get(cursor) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'$') + { + cursor += 1; + } + if cursor == name_start { + continue; + } + let name = content[name_start..cursor].to_string(); + let Some(open_offset) = content[cursor..].find('{') else { + break; + }; + let open = cursor + open_offset; + let Some(end) = matching_javascript_brace(content, open) else { + break; + }; + ranges.push((name, definition_start, end + 1)); + cursor = open + 1; + } + ranges +} + +fn javascript_named_function_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + function_index: usize, + visiting: &mut BTreeSet, +) -> bool { + if !visiting.insert(function_index) { + return false; + } + let (name, definition_start, definition_end) = &ranges[function_index]; + let invocation_markers = [ + format!("{name}("), + format!("requestanimationframe({name})"), + format!(",{name})"), + format!(", {name})"), + ]; + for marker in invocation_markers { + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(&marker) { + let call = cursor + offset; + cursor = call + marker.len(); + if (*definition_start..*definition_end).contains(&call) + || position_is_inside_javascript_string(content, call) + { + continue; + } + let parent = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&call)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + if parent.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, visiting) + }) { + visiting.remove(&function_index); + return true; + } + } + } + visiting.remove(&function_index); + false +} + +fn javascript_position_is_reachable( + content: &str, + ranges: &[(String, usize, usize)], + position: usize, +) -> bool { + let enclosing = ranges + .iter() + .enumerate() + .filter(|(_, (_, start, end))| (*start..*end).contains(&position)) + .min_by_key(|(_, (_, start, end))| end - start) + .map(|(index, _)| index); + enclosing.is_none_or(|index| { + javascript_named_function_is_reachable(content, ranges, index, &mut BTreeSet::new()) + }) +} + +fn identifier_before(content: &str, position: usize) -> Option { + let bytes = content.as_bytes(); + let mut end = position; + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + let mut start = end; + while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') { + start -= 1; + } + (start < end).then(|| content[start..end].to_string()) +} + +fn canvas_visual_identifiers(content: &str, markup: &str, asset_path: &str) -> BTreeSet { + let mut identifiers = BTreeSet::new(); + let mut cursor = 0; + while let Some(offset) = content[cursor..].find(".src") { + let dot = cursor + offset; + cursor = dot + 4; + if position_is_inside_javascript_string(content, dot) { + continue; + } + let Some(identifier) = identifier_before(content, dot) else { + continue; + }; + let mut value_start = cursor; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + if content.as_bytes().get(value_start) != Some(&b'=') { + continue; + } + value_start += 1; + while content + .as_bytes() + .get(value_start) + .is_some_and(u8::is_ascii_whitespace) + { + value_start += 1; + } + let Some(quote @ (b'\'' | b'"' | b'`')) = content.as_bytes().get(value_start).copied() + else { + continue; + }; + value_start += 1; + let Some(end_offset) = content.as_bytes()[value_start..] + .iter() + .position(|byte| *byte == quote) + else { + continue; + }; + if relative_visual_url_resolves_to_asset( + &content[value_start..value_start + end_offset], + asset_path, + ) { + identifiers.insert(identifier); + } + } + + for tag in markup.split('>').filter(|tag| tag.contains(asset_path)) { + let Some(id) = html_attribute_value(tag, "id") else { + continue; + }; + let source_matches = ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + if !source_matches { + continue; + } + for marker in ["getelementbyid(", "queryselector("] { + let mut binding_cursor = 0; + while let Some(offset) = content[binding_cursor..].find(marker) { + let call = binding_cursor + offset; + binding_cursor = call + marker.len(); + if position_is_inside_javascript_string(content, call) { + continue; + } + let argument_tail = &content[binding_cursor..]; + let Some(argument_end) = argument_tail.find(')') else { + continue; + }; + let argument = argument_tail[..argument_end] + .trim() + .trim_matches(|character| matches!(character, '\'' | '"' | '#')); + if argument != id { + continue; + } + let Some(equals) = content[..call].rfind('=') else { + continue; + }; + if call.saturating_sub(equals) > 24 { + continue; + } + if let Some(identifier) = identifier_before(content, equals) { + identifiers.insert(identifier); + } + } + } + } + identifiers +} + +fn split_javascript_arguments(arguments: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (index, byte) in arguments.bytes().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + depth = depth.saturating_sub(1); + } else if byte == b',' && depth == 0 { + result.push(arguments[start..index].trim()); + start = index + 1; + } + } + result.push(arguments[start..].trim()); + result +} + +fn javascript_call_arguments_end(content: &str, start: usize) -> Option { + let mut depth: usize = 0; + let mut quote = None; + let mut escaped = false; + for (offset, byte) in content.as_bytes()[start..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if let Some(active) = quote { + if byte == active { + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'(' { + depth += 1; + } else if byte == b')' { + if depth == 0 { + return Some(start + offset); + } + depth -= 1; + } + } + None +} + +fn draw_image_metrics(arguments: &[&str], asset_dimensions: (u32, u32)) -> (bool, bool, bool) { + let destination = match arguments.len() { + 3 => { + let significant = asset_dimensions.0 >= 32 && asset_dimensions.1 >= 32; + return (significant, significant, false); + } + 5 => arguments.get(3).zip(arguments.get(4)), + 9 => arguments.get(7).zip(arguments.get(8)), + _ => None, + }; + let Some((width, height)) = destination else { + return (false, false, false); + }; + let compact_width = width.split_ascii_whitespace().collect::(); + let compact_height = height.split_ascii_whitespace().collect::(); + let background = compact_width.ends_with(".width") + && compact_height.ends_with(".height") + && compact_width.trim_end_matches(".width") == compact_height.trim_end_matches(".height"); + let parse = |value: &str| value.trim().parse::().ok(); + let numeric = parse(width).zip(parse(height)); + let entity = numeric.is_some_and(|(width, height)| { + width.abs() >= 32.0 && height.abs() >= 32.0 && width.abs() * height.abs() >= 2048.0 + }); + let numeric_background = numeric.is_some_and(|(width, height)| { + width.abs() >= 320.0 && height.abs() >= 180.0 && width.abs() * height.abs() >= 100_000.0 + }); + let background = background || numeric_background; + (background || entity, background, entity) +} + +fn tag_visibly_uses_visual_asset( + tag: &str, + asset_path: &str, + asset_dimensions: (u32, u32), +) -> bool { + let tag = tag.to_ascii_lowercase(); + if !tag.contains(asset_path) || tag_is_obviously_hidden_or_tiny(&tag, asset_dimensions) { + return false; + } + let trimmed = tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']); + let visual_element = ["img", "image", "object", "embed", "video", "input"] + .iter() + .any(|name| { + trimmed.starts_with(name) + && trimmed + .as_bytes() + .get(name.len()) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'>') + }); + let direct_source = visual_element + && ["src", "href", "data", "poster"].iter().any(|attribute| { + html_attribute_value(&tag, attribute) + .is_some_and(|url| relative_visual_url_resolves_to_asset(url, asset_path)) + }); + direct_source + || html_attribute_value(&tag, "style") + .is_some_and(|style| css_contains_resolving_url(style, asset_path)) +} + +fn game_index_visibly_uses_visual_asset( + html: &[u8], + asset_path: &str, + asset_dimensions: (u32, u32), + require_canvas_composition: bool, +) -> bool { + let asset_path = asset_path.to_ascii_lowercase(); + let Ok(html) = std::str::from_utf8(html) else { + return false; + }; + let content = strip_art_reference_comments(html).to_ascii_lowercase(); + if !content.contains(&asset_path) { + return false; + } + let markup = strip_script_blocks(&content); + + let mut tag_cursor = 0; + while let Some(start_offset) = markup[tag_cursor..].find('<') { + let start = tag_cursor + start_offset; + let Some(end_offset) = markup[start..].find('>') else { + break; + }; + let end = start + end_offset + 1; + let tag = &markup[start..end]; + if !require_canvas_composition + && tag_visibly_uses_visual_asset(tag, &asset_path, asset_dimensions) + && !tag_is_hidden_by_stylesheet(tag, &markup, asset_dimensions) + { + return true; + } + tag_cursor = end; + } + + let mut style_cursor = 0; + while let Some(start_offset) = markup[style_cursor..].find("') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(end_offset) = markup[body_start..].find("") else { + break; + }; + let body_end = body_start + end_offset; + let style = &markup[body_start..body_end]; + for rule in style.split('}') { + let Some((selector, declarations)) = rule.rsplit_once('{') else { + continue; + }; + if !require_canvas_composition + && css_contains_resolving_url(declarations, &asset_path) + && selector_is_bound_to_markup(selector, &markup) + && !tag_is_obviously_hidden_or_tiny( + &format!("
"), + (0, 0), + ) + { + return true; + } + } + style_cursor = body_end + "".len(); + } + + let active_canvas = markup.split('>').any(|tag| { + tag.trim_start_matches(['<', ' ', '\t', '\r', '\n']) + .starts_with("canvas") + && !tag_is_obviously_hidden_or_tiny(tag, (300, 150)) + && !tag_is_hidden_by_stylesheet(tag, &markup, (300, 150)) + }); + if !active_canvas { + return false; + } + let identifiers = canvas_visual_identifiers(&content, &markup, &asset_path); + if identifiers.is_empty() { + return false; + } + let mut significant_draws = 0usize; + let mut background_draws = 0usize; + let mut entity_draws = 0usize; + let function_ranges = named_javascript_function_ranges(&content); + let mut draw_cursor = 0; + while let Some(offset) = content[draw_cursor..].find("drawimage(") { + let call = draw_cursor + offset; + let arguments_start = call + "drawimage(".len(); + draw_cursor = arguments_start; + if position_is_inside_javascript_string(&content, call) + || !javascript_position_is_reachable(&content, &function_ranges, call) + { + continue; + } + let Some(arguments_end) = javascript_call_arguments_end(&content, arguments_start) else { + break; + }; + let arguments = split_javascript_arguments(&content[arguments_start..arguments_end]); + if arguments + .first() + .is_some_and(|identifier| identifiers.contains(*identifier)) + { + let (significant, background, entity) = + draw_image_metrics(&arguments, asset_dimensions); + significant_draws += usize::from(significant); + background_draws += usize::from(background); + entity_draws += usize::from(entity); + if !require_canvas_composition && significant { + return true; + } + } + draw_cursor = arguments_end + 1; + } + require_canvas_composition + && significant_draws >= 3 + && background_draws >= 1 + && entity_draws >= 2 +} + pub(in crate::agent) fn game_creation_app_task_status_label( status: &GameCreationAppTaskStatus, ) -> String { @@ -438,6 +1236,13 @@ fn autonomous_manifest_parent_completion_gaps_at( )? .ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?; let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source); + let required_visual_task_id = if binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; let mut missing_tasks = Vec::new(); let mut missing_paths = Vec::new(); for seed_task in &seed_tasks { @@ -456,10 +1261,23 @@ fn autonomous_manifest_parent_completion_gaps_at( contract.baseline_index_sha256.as_deref(), &contract.baseline_artifacts, )?); - if seed_task.id == "code-prototype" { - if let Some(gap) = - autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)? + if required_visual_task_id == Some(seed_task.id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &seed_task.id) { + missing_paths.push(AutonomousManifestArtifactGap::new(format!( + "{}(canvas-registration-invalid:{})", + seed_task.id, + sanitize_agent_runtime_text(&error, 240) + ))); + } + } + if seed_task.id == "code-prototype" { + if let Some(gap) = autonomous_code_prototype_art_asset_reference_gap_at( + root, + &seed_task.id, + required_visual_task_id, + )? { missing_paths.push(AutonomousManifestArtifactGap::new(gap)); } } @@ -498,10 +1316,31 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( format!("missingTask={}", state.agent_id), )); }; + let root_source = match agent_runtime_root_source_at(root, &state.agent_id, &state.run_id) { + Ok(source) => source, + Err(error) => { + return Some(autonomous_completion_blocker( + "autonomous ready-task 无法解析 root source", + error, + )); + } + }; + // game-chat 可能在 UI 完成项目 hydration 前并行启动 source-aware lane 的首波 + // ready child,随后初始化写回会短暂把这些零依赖任务恢复成 Pending。child binding、owner + // artifact 和验证门仍能确认当前 run,因此仅允许当前 source 的零依赖首波收束, + // 再由 terminal projection 写入权威 Completed 状态。后续 preview 与中间任务继续 + // 严格要求 Running/Completed,不得借 hydration 例外越过依赖。 + // GUI/CLI 以及后续 preview 任务继续严格要求 Running/Completed。 + let game_chat_hydration_pending = root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + && task.status == GameCreationAppTaskStatus::Pending + && crate::agent::autonomous_manifest_seed_tasks_for_source(&root_source) + .iter() + .any(|seed_task| seed_task.id == state.agent_id && seed_task.dependencies.is_empty()); if !matches!( task.status, GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::Completed - ) { + ) && !game_chat_hydration_pending + { return Some(autonomous_completion_blocker( "autonomous ready-task manifest 状态不允许完成", format!( @@ -643,12 +1482,37 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( )); } }; - match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) { + let required_visual_task_id = if root_source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + Some("art-director") + } else if editor_api_key_is_configured() { + Some("art-asset-plan") + } else { + None + }; + if required_visual_task_id == Some(state.agent_id.as_str()) { + if let Err(error) = + validate_manifest_required_visual_asset(root, &manifest, &state.agent_id) + { + return Some(autonomous_completion_blocker( + "autonomous ready-task 缺少有效的 Canvas 美术资产登记", + format!( + "task={} validationError={}", + state.agent_id, + sanitize_agent_runtime_text(&error, 240) + ), + )); + } + } + match autonomous_code_prototype_art_asset_reference_gap_at( + root, + &state.agent_id, + required_visual_task_id, + ) { Ok(Some(gap)) => { return Some(autonomous_completion_blocker( "code-prototype 必须实际使用平台生成的美术资源", format!( - "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png", + "task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中可见使用对应平台美术资源", state.agent_id, gap ), )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 9c000fef7..c38d8e71e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -71,6 +71,97 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() { )); } +#[test] +fn game_chat_manifest_seed_projection_starts_three_directors_then_runs_three_task_lane() { + let game_chat_tasks = + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE); + assert_eq!( + game_chat_tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + [ + "design-director", + "art-director", + "code-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] + ); + assert_eq!(game_chat_tasks[0].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[1].dependencies, Vec::::new()); + assert_eq!(game_chat_tasks[2].dependencies, Vec::::new()); + assert_eq!( + game_chat_tasks[3].dependencies, + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] + ); + assert_eq!( + game_chat_tasks[4].dependencies, + vec!["code-prototype".to_string()] + ); + assert_eq!( + game_chat_tasks[5].dependencies, + vec!["preview-readiness".to_string()] + ); + + let full_seed_tasks = new_game_creation_app_seed_tasks(); + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + assert_eq!( + autonomous_manifest_seed_tasks_for_source(source), + full_seed_tasks + ); + } + + let mut manifest_tasks = full_seed_tasks; + for task in &mut manifest_tasks { + if matches!( + task.id.as_str(), + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" + ) { + task.status = GameCreationAppTaskStatus::Pending; + } + } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec![ + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), + ] + ); + + for task_id in ["design-director", "art-director", "code-director"] { + manifest_tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap_or_else(|| panic!("{task_id} task exists")) + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + autonomous_manifest_ready_task_ids( + &manifest_tasks, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ), + vec!["code-prototype".to_string()] + ); +} + fn autonomous_fixture_with_setup( task: &str, run_id: &str, @@ -116,16 +207,13 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - let bytes = if kind == "art-spritesheet" { - use image::ImageEncoder; - let mut bytes = Vec::new(); - image::codecs::png::PngEncoder::new(&mut bytes) - .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) - .expect("encode transparent autonomous visual fixture"); - bytes - } else { - b"\x89PNG\r\n\x1a\nfixture".to_vec() - }; + use image::ImageEncoder; + let alpha = if kind == "art-spritesheet" { 0 } else { 255 }; + let pixels = [12, 34, 56, alpha].repeat(64 * 64); + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&pixels, 64, 64, image::ColorType::Rgba8.into()) + .expect("encode autonomous visual fixture"); fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); let (generation_route, generation_kind, reference_resource_ids) = match kind { "icon-spec" => ( @@ -1008,10 +1096,24 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task .expect("leave publish strategy pending"); update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending) .expect("leave publish package pending"); + for task in new_game_creation_app_seed_tasks() { + if !matches!( + task.id.as_str(), + "design-director" + | "art-director" + | "code-director" + | "code-prototype" + | "preview-readiness" + | "preview-playtest" + ) { + update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}")); + } + } let revision = advance_game_index_revision( &root, &state, - "", + "", ); mark_verification_passed(&root, &state, "game.static_smoke"); let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario); @@ -1035,12 +1137,50 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task } #[test] -fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() { +fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary() { + let (_temporary, root, state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-schedule-ready-boundary", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task_id in ["publish-strategy", "publish-package"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("leave game-chat publish task pending"); + } + + let observation = observe_agent_runtime_schedule_ready_tasks( + &root, + &state.agent_id, + &state.run_id, + &serde_json::json!({ "limit": 16 }), + ); + + assert_eq!(observation.status, "ok"); + assert_eq!(observation.summary, "已调度 0 个 Ready 任务"); + let manifest = read_manifest_for_project(&root).expect("read game-chat manifest"); + for task_id in ["publish-strategy", "publish-package"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Pending), + "game-chat must not schedule {task_id} after preview-playtest" + ); + } +} + +#[test] +fn game_chat_code_prototype_requires_registered_canvas_art_spec_visible_use() { let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(), ); - let (_temporary, root, parent_state, _contract) = - autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent"); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮植物塔防游戏", + "game-chat-code-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) .expect("mark code prototype running"); let code_record = @@ -1052,7 +1192,140 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i "", ); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) - .expect("missing spritesheet reference must block code prototype"); + .expect("missing art-spec reference must block code prototype"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("assets/art-spec.png"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(), + "URL relative to game/index.html must resolve to the registered asset" + ); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("an unused art-spec string must not satisfy visible-use validation"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("missing-visible-art-spec-use"))); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + for hidden_html in [ + "", + "", + "", + ] { + advance_game_index_revision(&root, &code_state, hidden_html); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + } + + advance_game_index_revision( + &root, + &code_state, + "
", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some()); + + advance_game_index_revision( + &root, + &code_state, + "", + ); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); +} + +#[test] +fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(), + ); + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark CLI code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("CLI must still require the art spritesheet"); assert!(blocker .detail .as_deref() @@ -1061,13 +1334,222 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i advance_game_index_revision( &root, &code_state, - "", + "", ); assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none()); } +#[test] +fn game_chat_requires_canvas_art_spec_even_without_editor_configuration() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须使用平台美术的小游戏", + "game-chat-unconfigured-art-gate-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("mark code prototype running"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered art-spec file"); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("game-chat must fail closed without a valid Canvas art spec"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("canvas-registration-invalid"))); +} + +#[test] +fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮必须先完成美术阶段的小游戏", + "game-chat-unconfigured-art-stage-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Running) + .expect("mark art director running"); + let art_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "art-director"); + let art_state = agent_runtime_state_from_task_record(&art_record); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove Canvas art spec file"); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &art_state) + .expect("game-chat art stage must fail closed without a valid Canvas asset"); + assert!(blocker.summary.contains("Canvas")); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("task=art-director"))); +} + +#[test] +fn game_chat_initial_directors_can_converge_after_hydration_restores_manifest_to_pending() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-ready-child-hydration-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + for task_id in ["design-director", "art-director", "code-director"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .unwrap_or_else(|error| panic!("restore initial {task_id} to pending: {error}")); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let mut state = agent_runtime_state_from_task_record(&record); + + assert!( + autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none(), + "bound game-chat initial child {task_id} must survive late hydration" + ); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + .unwrap_or_else(|error| panic!("project completed {task_id}: {error}")) + ); + } + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + for task_id in ["design-director", "art-director", "code-director"] { + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == task_id) + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + } +} + +#[test] +fn game_chat_later_code_child_rejects_pending_then_projects_verified_completion() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-code-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("leave later code prototype pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let mut code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("later code child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); + + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running) + .expect("restore the later code child to its authoritative running state"); + mark_verification_passed(&root, &code_state, "game.static_smoke"); + code_state.status = "completed".to_string(); + code_state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &code_state) + .expect("project completed game-chat code child") + ); + let manifest = read_manifest_for_project(&root).expect("read projected game-chat manifest"); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); + let root_gate = read_game_creator_agent_runtime_verification_gate( + &root, + &parent_state.agent_id, + &parent_state.run_id, + ) + .expect("read projected root verification gate"); + assert_eq!(root_gate.verified_revision, Some(1)); + assert_eq!(root_gate.agent_id, parent_state.agent_id); + assert_eq!(root_gate.run_id, parent_state.run_id); + assert!(!root_gate.requires_verification); + assert_eq!(root_gate.mutation_revision, None); + assert_eq!( + root_gate.last_verification_status.as_deref(), + Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + ); + assert_eq!( + root_gate.last_verification_tool.as_deref(), + Some("game.static_smoke") + ); + for task_id in ["preview-readiness", "preview-playtest"] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) + .unwrap_or_else(|error| panic!("complete {task_id}: {error}")); + } + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &parent_state) + .expect("missing root playtest receipt must still block completion"); + assert!(blocker.summary.contains("交互试玩回执")); +} + +#[test] +fn game_chat_preview_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source( + "创建一轮星空收集游戏", + "game-chat-preview-pending-parent", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Pending, + ) + .expect("leave preview readiness pending"); + let preview_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let preview_state = agent_runtime_state_from_task_record(&preview_record); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &preview_state) + .expect("preview child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + +#[test] +fn gui_ready_child_still_rejects_pending_manifest_status() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("创建完整小游戏", "gui-ready-child-pending-manifest-parent"); + update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Pending) + .expect("mark GUI code prototype pending"); + let code_record = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype"); + let code_state = agent_runtime_state_from_task_record(&code_record); + advance_game_index_revision( + &root, + &code_state, + "", + ); + + let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) + .expect("GUI child must keep the strict manifest status gate"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("status=pending"))); +} + #[test] fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, parent_state, _contract) = autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent"); update_manifest_task_status_at(&root, "balance-seed", GameCreationAppTaskStatus::Running) @@ -1132,10 +1614,13 @@ fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { let code_state = agent_runtime_state_from_task_record(&code_record); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("initial code placeholder must block child completion"); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); + assert!( + blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")), + "unexpected blocker: {blocker:?}" + ); } #[test] @@ -1741,6 +2226,7 @@ fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() { #[test] fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, mut state, contract) = autonomous_fixture( "做一个塔防游戏,选择植物阻挡敌人并正常闯关", "autonomous-completion-evidence-run", @@ -1762,7 +2248,10 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( mark_verification_passed(&root, &state, "project.verify"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("project.verify cannot replace static smoke"); - assert!(blocker.summary.contains("game.static_smoke")); + assert!( + blocker.summary.contains("game.static_smoke"), + "unexpected blocker: {blocker:?}" + ); mark_verification_passed(&root, &state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index 209a344da..7352ad379 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -185,8 +185,6 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( if stream.task_id != state.task_id || stream.session_id != state.session_id || stream.applied_steer_cursor != state.applied_steer_cursor - || stream.response_revision - != read_game_creator_agent_runtime_project_revision(root)?.revision || stream.request_slot != game_creator_agent_runtime_response_stream_request_slot( state, @@ -195,10 +193,14 @@ pub(in crate::agent) fn visible_game_creator_agent_runtime_response_stream_at( { return Ok(None); } + let response_revision_is_current = stream.response_revision + == read_game_creator_agent_runtime_project_revision(root)?.revision; let visible = match stream.status.as_str() { AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY => { - state.status == "running" && matches!(state.phase.as_str(), "response" | "finalizing") + response_revision_is_current + && state.status == "running" + && matches!(state.phase.as_str(), "response" | "finalizing") } AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED => { matches!(state.status.as_str(), "idle" | "completed") && state.phase == "completed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 24927fb05..12f7ff72f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1,5 +1,118 @@ use super::*; +static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); + +fn new_game_creator_agent_runtime_event_id( + state: &AgentRuntimeState, + event_type: &str, + phase: &str, + action_id: Option<&str>, +) -> String { + if let Some(action_id) = action_id { + return format!( + "runtime-event-action-{}-{}-{}-{}", + state.run_id, event_type, phase, action_id + ); + } + let sequence = + AGENT_RUNTIME_EVENT_ID_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!( + "runtime-event-{}-{}-{}-{}", + std::process::id(), + unix_millis(), + sequence, + event_type + ) +} + +fn game_creator_agent_runtime_event_type_is_public(event_type: &str) -> bool { + matches!( + event_type, + "thinking_summary" + | "plan" + | "plan_update" + | "action" + | "observation" + | "turn.started" + | "turn.progress" + | "turn.completed" + | "turn.failed" + | "turn.budget_exhausted" + | "turn.cancelled" + | "response" + | "response.stale" + | "goal.paused" + | "goal.resumed" + | "agent.delegate.result" + | "agent.delegate.result_failed" + ) || event_type.starts_with("tool_confirmation.") + || event_type.starts_with("user_input.") +} + +fn game_creator_agent_runtime_public_event_text( + root: &Path, + event_type: &str, + summary: &str, +) -> Option { + let event_type = event_type.trim(); + if !game_creator_agent_runtime_event_type_is_public(event_type) { + return None; + } + let summary = redact_agent_runtime_error(root, summary.trim(), 240); + if summary.is_empty() { + return None; + } + let lower = summary.to_ascii_lowercase(); + if [ + "runtime.", + "agent.runtime.", + "provider.", + "provider_request.", + "parallel_read_batch.", + "provider_action_batch.", + "finalization.", + "context.", + "process_session.", + "steer.", + "autonomous_manifest.parent_wake", + "agent.delegate.parent_wake", + "command.exec:", + "command.exec:", + "command.output_read:", + "command.output_read:", + "agent.action_history:", + "agent.action_history:", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return None; + } + if [ + "sha256", + "fingerprint", + "authorization", + "bearer", + "api key", + "api_key", + "password", + "secret", + "cookie", + "token=", + "private process output", + " { Ok(None) @@ -2318,10 +2436,12 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( run_id: state.run_id.clone(), source: state.source.clone(), event_type: event_type.to_string(), + event_id: new_game_creator_agent_runtime_event_id(state, event_type, phase, action_id), action_id: action_id.map(ToString::to_string), status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), + public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary), detail: detail .filter(|_| { !(event_type == "observation" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index f5aa2c596..cb3addee3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -228,6 +228,34 @@ pub(in crate::agent) fn render_static_delegate_task_contract( Ok(rendered) } +fn validate_publish_delegate_run_profile_at( + root: &Path, + agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, +) -> Result<(), String> { + if !matches!(target_agent_id, "publish-strategy" | "publish-package") { + return Ok(()); + } + let current_binding = + read_game_creator_agent_runtime_run_profile_binding(root, agent_id, parent_run_id)? + .ok_or_else(|| { + "agent.delegate 缺少当前 Run Profile binding,已拒绝发布委派".to_string() + })?; + let root_binding = read_game_creator_agent_runtime_run_profile_binding( + root, + ¤t_binding.root_agent_id, + ¤t_binding.root_run_id, + )? + .ok_or_else(|| "agent.delegate 缺少 root Run Profile binding,已拒绝发布委派".to_string())?; + if root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE { + return Err(format!( + "game-chat Run Profile 禁止委派 {target_agent_id},未创建 child runtime" + )); + } + Ok(()) +} + pub(crate) fn observe_agent_runtime_agent_delegate( root: &Path, agent_id: &str, @@ -359,6 +387,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + if let Err(error) = + validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id) + { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 0f2815ff7..321e21aec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -1347,12 +1347,31 @@ pub(in crate::agent) fn record_game_creator_agent_runtime_receipt_start_warning( pub(in crate::agent) fn observe_agent_runtime_schedule_ready_tasks( root: &Path, + agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let limit = agent_runtime_tool_input_usize(input, &["limit", "maxTasks"]) .map(|value| value.clamp(1, 16)) .unwrap_or(16); - match schedule_game_creator_agent_ready_tasks_at(root, limit) { + let scheduled = + match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) { + Ok(Some(binding)) + if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD => + { + schedule_autonomous_game_build_ready_tasks_at( + root, + &binding.root_agent_id, + &binding.root_run_id, + limit.min(3), + ) + } + Ok(_) => schedule_game_creator_agent_ready_tasks_at(root, limit), + Err(error) => Err(format!( + "agent.schedule_ready 无法核对当前 Run Profile 绑定:{error}" + )), + }; + match scheduled { Ok(results) => { let detail = results .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index c5c017088..fcbb42f59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -492,6 +492,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio run_id: &str, task: &str, input: &serde_json::Value, + pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]); let prompt = if prompt.trim().is_empty() { @@ -670,7 +671,30 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } - if !options.replace_existing { + let resumes_durable_generation = match pending_action { + Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) { + Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false, + Ok( + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, + ) => true, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: redact_agent_runtime_project_paths( + root, + &format!("External Editor 生成账本无法通过恢复预检:{error}"), + 240, + ), + detail: None, + }; + } + }, + None => false, + }; + if !options.replace_existing && !resumes_durable_generation { if let Err(error) = prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) { @@ -687,11 +711,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } - let prepared = match request_platform_art_asset_with_options_at( + let runtime_context = pending_action.map(platform_art_generation_runtime_context_from_pending); + let prepared = match request_platform_art_asset_with_runtime_options_at( root, prompt.trim(), &[], &options, + runtime_context.as_ref(), ) .await { @@ -699,7 +725,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio Err(error) => { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: platform_art_generation_observation_status(root, agent_id, run_id, &error) + .to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), detail: None, }; @@ -808,34 +835,56 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .as_deref() .map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}")) .unwrap_or_default(); + let warning_summary = generated + .warning + .as_deref() + .map(|reason| format!(";平台非阻断告警:{reason}")) + .unwrap_or_default(); AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "ok".to_string(), summary: format!( - "已生成美术素材:{}{slice_warning_summary}", + "已生成美术素材:{}{warning_summary}{slice_warning_summary}", generated.asset.local_path ), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), generated.asset_object_id.as_deref().unwrap_or(""), generated.task_id.as_deref().unwrap_or(""), generated.model.as_deref().unwrap_or(""), + generated.warning.as_deref().unwrap_or(""), generated.slice_warning.as_deref().unwrap_or("") )), } } Err(error) => AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: platform_art_generation_observation_status(root, agent_id, run_id, &error) + .to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), detail: None, }, } } +fn platform_art_generation_observation_status( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> &'static str { + if platform_art_generation_error_needs_reconciliation(error) + || game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id) + { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } +} + #[cfg(test)] pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test( root: &Path, @@ -844,5 +893,43 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di task: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { - observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input).await + observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input, None) + .await +} + +#[cfg(test)] +mod platform_art_generation_observation_tests { + use super::*; + + #[test] + fn unknown_external_generation_result_requires_runtime_reconciliation() { + let root = tempfile::tempdir().expect("create observation status root"); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-director", + "run-unknown", + "platform-generation-result-unknown: 平台已受理但响应丢失" + ), + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-asset-plan", + "run-source-preserved", + "platform-generation-source-preserved-no-retry: provider 源图已保留" + ), + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-director", + "run-failed", + "平台明确返回生成失败" + ), + "failed" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index dde4e6405..3fd2ab07a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -203,8 +203,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( .as_ref() .map(|contract| contract.playtest_scenario.clone()) .or(input.playtest_scenario); - if completion_contract.is_some() { - if let Err(error) = remove_autonomous_playtest_receipt(root, agent_id, run_id) { + if let Some(contract) = completion_contract.as_ref() { + if let Err(error) = + remove_autonomous_playtest_receipt(root, &contract.agent_id, &contract.run_id) + { return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -246,10 +248,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } }, }; + let (evidence_agent_id, evidence_run_id) = completion_contract + .as_ref() + .map(|contract| (contract.agent_id.as_str(), contract.run_id.as_str())) + .unwrap_or((agent_id, run_id)); let evidence_relative_root = format!( ".agent/runtime/browser-validations/{}/{}/{}", - agent_runtime_confirmation_path_component(agent_id, "agent"), - agent_runtime_confirmation_path_component(run_id, "run"), + agent_runtime_confirmation_path_component(evidence_agent_id, "agent"), + agent_runtime_confirmation_path_component(evidence_run_id, "run"), revision_before.revision, ); let evidence_root = match resolve_local_project_path(root, &evidence_relative_root) { @@ -339,7 +345,10 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( } } - if completion_contract.is_some() && !result.passed { + let contract_belongs_to_runtime = completion_contract.as_ref().is_some_and(|contract| { + contract.agent_id == runtime.agent_id && contract.run_id == runtime.run_id + }); + if contract_belongs_to_runtime && !result.passed { if let Err(error) = invalidate_agent_runtime_project_verification_after_preview_failure_at( root, agent_id, @@ -385,18 +394,20 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - if let Err(error) = clear_agent_runtime_failed_playtest_at( - root, - agent_id, - run_id, - revision_after.revision, - ) { - return AgentRuntimeToolObservation { - tool: "preview.validate".to_string(), - status: "failed".to_string(), - summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; + if contract_belongs_to_runtime { + if let Err(error) = clear_agent_runtime_failed_playtest_at( + root, + agent_id, + run_id, + revision_after.revision, + ) { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "浏览器验证已通过,但失败试玩凭证无法安全清除".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } } receipt } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index bf93ebda3..c005c80e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -2,15 +2,37 @@ use super::*; pub(in crate::agent) fn observe_agent_runtime_task_list( root: &Path, + agent_id: &str, + run_id: &str, ) -> AgentRuntimeToolObservation { - let result = read_manifest_for_project(root).map(|manifest| { - let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks); - let seed_task_ids = new_game_creation_app_seed_tasks() - .into_iter() - .map(|task| task.id) - .collect::>(); - let seed_tasks = manifest - .tasks + let result = (|| -> Result { + let game_chat_single_round = root_run_source_is_game_chat(root, agent_id, run_id)?; + let manifest = read_manifest_for_project(root)?; + let visible_tasks = if game_chat_single_round { + autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) + .into_iter() + .map(|mut projected| { + if let Some(persisted) = + manifest.tasks.iter().find(|task| task.id == projected.id) + { + projected.status = persisted.status.clone(); + } + projected + }) + .collect::>() + } else { + manifest.tasks.clone() + }; + let ready_task_ids = ready_task_ids_for_tasks(&visible_tasks); + let seed_task_ids = autonomous_manifest_seed_tasks_for_source(if game_chat_single_round { + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE + } else { + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE + }) + .into_iter() + .map(|task| task.id) + .collect::>(); + let seed_tasks = visible_tasks .iter() .filter(|task| seed_task_ids.contains(&task.id)) .collect::>(); @@ -19,28 +41,23 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( } else { ready_task_ids.join(", ") }; - let completed = manifest - .tasks + let completed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Completed) .count(); - let running = manifest - .tasks + let running = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Running) .count(); - let pending = manifest - .tasks + let pending = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Pending) .count(); - let waiting = manifest - .tasks + let waiting = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::WaitingForConfirmation) .count(); - let failed = manifest - .tasks + let failed = visible_tasks .iter() .filter(|task| task.status == GameCreationAppTaskStatus::Failed) .count(); @@ -72,10 +89,10 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( ), format!( "taskCounts: completed={completed} running={running} pending={pending} waiting={waiting} failed={failed} total={}", - manifest.tasks.len() + visible_tasks.len() ), ]; - lines.extend(manifest.tasks.iter().map(|task| { + lines.extend(visible_tasks.iter().map(|task| { let dependencies = if task.dependencies.is_empty() { "-".to_string() } else { @@ -97,11 +114,215 @@ pub(in crate::agent) fn observe_agent_runtime_task_list( artifacts ) })); - lines.join("\n") - }); + Ok(lines.join("\n")) + })(); observation_from_text_result("task.list", result, "已读取 manifest 任务图") } +fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Result { + let binding = read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id)? + .ok_or_else(|| "task.list 缺少当前 Run Profile binding,无法确认运行来源".to_string())?; + let root_binding = if binding.root_agent_id == binding.agent_id + && binding.root_run_id == binding.run_id + { + binding + } else { + read_game_creator_agent_runtime_run_profile_binding( + root, + &binding.root_agent_id, + &binding.root_run_id, + )? + .ok_or_else(|| "task.list 缺少 root Run Profile binding,无法确认运行来源".to_string())? + }; + Ok(root_binding.source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn register_task_list_visual_fixture( + root: &Path, + local_path: &str, + kind: &str, + generation_kind: &str, + alpha: u8, + reference_resource_ids: Vec, + ) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 140, 220, alpha])) + .save(root.join(local_path)) + .expect("write task list visual fixture"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("fixture-canvas".to_string()), + resource_id: Some(format!("fixture-{kind}-resource")), + asset_object_id: Some(format!("fixture-{kind}-object")), + task_id: Some(format!("fixture-{kind}-task")), + prompt: None, + model: None, + generation_route: Some( + if kind == "art-spritesheet" { + "/api/external/v1/editor/icon-spritesheets/generations" + } else { + "/api/external/v1/editor/images/generations" + } + .to_string(), + ), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, + }, + ) + .expect("register task list visual fixture"); + } + + fn register_task_list_visual_fixtures(root: &Path) { + let art_spec_resource_id = "fixture-icon-spec-resource".to_string(); + register_task_list_visual_fixture( + root, + "assets/art-spec.png", + "icon-spec", + "spec", + u8::MAX, + Vec::new(), + ); + register_task_list_visual_fixture( + root, + "assets/ui-prototype.png", + "ui-prototype", + "ui-design", + u8::MAX, + vec![art_spec_resource_id.clone()], + ); + register_task_list_visual_fixture( + root, + "assets/art-spritesheet.png", + "art-spritesheet", + "icon-spritesheet", + 0, + vec![art_spec_resource_id], + ); + } + + #[test] + fn game_chat_task_list_hides_publish_tasks_and_counts() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list", "game-chat task list") + .expect("initialize project"); + register_task_list_visual_fixtures(root); + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat run"); + for task_id in [ + "design-director", + "art-director", + "code-director", + "code-prototype", + "preview-readiness", + "preview-playtest", + ] { + update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed) + .expect("complete game-chat seed task"); + } + + let observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-task-list-run", + ); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("task list detail"); + assert!(detail.contains("readyTaskIds: (none)"), "{detail}"); + assert!(!detail.contains("publish-strategy"), "{detail}"); + assert!(!detail.contains("publish-package"), "{detail}"); + assert!( + detail.contains( + "seedTaskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6" + ), + "{detail}" + ); + assert!( + detail + .contains("taskCounts: completed=6 running=0 pending=0 waiting=0 failed=0 total=6"), + "{detail}" + ); + + for task in new_game_creation_app_seed_tasks().into_iter().take(14) { + update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed) + .expect("complete full pre-publish DAG for GUI comparison"); + } + + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind GUI run"); + let gui_observation = observe_agent_runtime_task_list( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "gui-task-list-run", + ); + assert_eq!(gui_observation.status, "ok"); + let gui_detail = gui_observation.detail.expect("GUI task list detail"); + assert!( + gui_detail.contains("readyTaskIds: publish-strategy"), + "{gui_detail}" + ); + assert!( + gui_detail.contains( + "taskCounts: completed=14 running=0 pending=2 waiting=0 failed=0 total=16" + ), + "{gui_detail}" + ); + assert!(gui_detail.contains("publish-strategy"), "{gui_detail}"); + } + + #[test] + fn task_list_fails_closed_when_current_binding_parent_is_missing() { + let temporary = tempfile::tempdir().expect("create task list project"); + let root = temporary.path(); + init_local_game_project_at(root, "game-chat-task-list-missing-parent", "task list") + .expect("initialize project"); + let parent_run_id = "missing-game-chat-parent"; + let task_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + ..Default::default() + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + "code-prototype", + "game-chat-child-run", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&task_link), + ) + .expect("bind child run"); + + let observation = + observe_agent_runtime_task_list(root, "code-prototype", "game-chat-child-run"); + assert_eq!(observation.status, "failed"); + assert!(observation.detail.is_none()); + assert!(observation.summary.contains("binding"), "{observation:?}"); + } +} + pub(in crate::agent) fn observe_agent_runtime_task_create( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 95e7ab68a..4be2f67d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -811,7 +811,101 @@ fn plan_update_schema() -> Value { }) } -fn action_function_parameters(input_schema: Value) -> Value { +fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) { + let Value::Object(object) = value else { + return; + }; + + // `$id` 会建立独立 schema resource;其内部 fragment 应继续相对该 resource + // 解析,不能按外层 function parameters 根重定位。 + let has_local_resource_id = has_local_resource_id || object.contains_key("$id"); + let reference = object + .get("$ref") + .and_then(Value::as_str) + .map(ToString::to_string); + if let Some(reference) = reference { + // 只有空 fragment 和 JSON Pointer fragment 相对当前 document 根。 + // `#Mode` 是命名 anchor,外部 URI 也有自己的解析范围,必须保持原样。 + if !has_local_resource_id && (reference == "#" || reference.starts_with("#/")) { + let rebased = if reference == "#" { + "#/properties/input".to_string() + } else { + format!("#/properties/input{}", &reference[1..]) + }; + object.insert("$ref".to_string(), Value::String(rebased)); + } + } + + // 只进入 JSON Schema 明确定义为 subschema 的位置。default、const、examples、 + // enum 等关键词承载普通 JSON 数据,其中即使出现 `$ref` 也不能改写。 + for keyword in [ + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "additionalItems", + "unevaluatedItems", + "contains", + "not", + "if", + "then", + "else", + "contentSchema", + ] { + if let Some(child) = object.get_mut(keyword) { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(Value::Array(children)) = object.get_mut(keyword) { + for child in children { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + } + + // draft-07 的 tuple validation 允许 items 为 schema 数组;新版本则为单 schema。 + if let Some(items) = object.get_mut("items") { + match items { + Value::Array(children) => { + for child in children { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + child => rebase_action_input_schema_refs_in_scope(child, has_local_resource_id), + } + } + + for keyword in [ + "$defs", + "definitions", + "properties", + "patternProperties", + "dependentSchemas", + ] { + if let Some(Value::Object(children)) = object.get_mut(keyword) { + for child in children.values_mut() { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + } + + // draft-07 dependencies 的 value 可能是 subschema,也可能是属性名数组。 + if let Some(Value::Object(dependencies)) = object.get_mut("dependencies") { + for dependency in dependencies.values_mut().filter(|value| value.is_object()) { + rebase_action_input_schema_refs_in_scope(dependency, has_local_resource_id); + } + } +} + +fn rebase_action_input_schema_refs(value: &mut Value) { + rebase_action_input_schema_refs_in_scope(value, false); +} + +fn action_function_parameters(mut input_schema: Value) -> Value { + // MCP 的 input schema 会被包进 action.input。局部 JSON Pointer 仍从整个 + // function parameters 根解析,因此必须同步重定位;否则 #/$defs/... 会悬空。 + rebase_action_input_schema_refs(&mut input_schema); json!({ "type": "object", "required": ["reason", "input"], @@ -1369,6 +1463,108 @@ mod tests { assert!(issues.is_empty(), "{}", issues.join("\n")); } + #[test] + fn action_function_parameters_rebases_local_schema_refs_after_wrapping() { + let parameters = action_function_parameters(json!({ + "type": "object", + "$defs": { + "Mode": {"type": "string", "enum": ["fast", "safe"]}, + "Options": { + "type": "object", + "properties": {"mode": {"$ref": "#/$defs/Mode"}}, + "required": ["mode"], + "additionalProperties": false + } + }, + "properties": { + "options": {"$ref": "#/$defs/Options"}, + "recursive": {"$ref": "#"}, + "anchor": {"$ref": "#Mode"}, + "scoped": { + "$id": "nested.json", + "$defs": {"Value": {"type": "string"}}, + "properties": {"value": {"$ref": "#/$defs/Value"}} + }, + "external": {"$ref": "https://schemas.example/tool.json"} + }, + "required": ["options"], + "additionalProperties": false + })); + + let input = ¶meters["properties"]["input"]; + assert_eq!( + input["properties"]["options"]["$ref"], + "#/properties/input/$defs/Options" + ); + assert_eq!( + input["$defs"]["Options"]["properties"]["mode"]["$ref"], + "#/properties/input/$defs/Mode" + ); + assert_eq!( + input["properties"]["recursive"]["$ref"], + "#/properties/input" + ); + assert_eq!(input["properties"]["anchor"]["$ref"], "#Mode"); + assert_eq!( + input["properties"]["scoped"]["properties"]["value"]["$ref"], + "#/$defs/Value" + ); + assert_eq!( + input["properties"]["external"]["$ref"], + "https://schemas.example/tool.json" + ); + for reference in [ + input["properties"]["options"]["$ref"] + .as_str() + .expect("options ref"), + input["$defs"]["Options"]["properties"]["mode"]["$ref"] + .as_str() + .expect("mode ref"), + input["properties"]["recursive"]["$ref"] + .as_str() + .expect("recursive ref"), + ] { + assert!( + parameters + .pointer(reference.trim_start_matches('#')) + .is_some(), + "rebased ref must resolve: {reference}" + ); + } + } + + #[test] + fn action_function_parameters_preserves_refs_inside_schema_data_keywords() { + let parameters = action_function_parameters(json!({ + "type": "object", + "$defs": { + "Value": {"type": "string"} + }, + "properties": { + "value": { + "$ref": "#/$defs/Value", + "default": {"$ref": "#/literal-default"}, + "const": { + "nested": [{"$ref": "#/literal-const"}] + }, + "examples": [ + {"$ref": "#/literal-example"}, + [{"$ref": "#/nested-literal-example"}] + ] + } + }, + "required": ["value"], + "additionalProperties": false + })); + + let value = ¶meters["properties"]["input"]["properties"]["value"]; + assert_eq!(value["$ref"], "#/properties/input/$defs/Value"); + assert_eq!(value["default"]["$ref"], "#/literal-default"); + assert_eq!(value["const"]["nested"][0]["$ref"], "#/literal-const"); + assert_eq!(value["examples"][0]["$ref"], "#/literal-example"); + assert_eq!(value["examples"][1][0]["$ref"], "#/nested-literal-example"); + } + #[test] fn native_project_patchset_normalizes_nullable_strict_shape() { let arguments = json!({ 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 dbd8adef2..5c9f77367 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -868,8 +868,11 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks( ) -> Result, String> { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; + if !has_recoverable_game_creator_agent_background_tasks_at(root)? { + return Ok(Vec::new()); + } + enforce_project_permission_policy(root, "conversation.write")?; enforce_project_auto_permission_policy(root, "agent.resume")?; resume_game_creator_agent_background_tasks_at(root) } @@ -1333,16 +1336,26 @@ pub(crate) fn append_local_conversation_message( agent_id: Option, session_id: Option, message: LocalConversationMessage, + message_id: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.write")?; let _lock = acquire_project_write_lock(root, "conversation.write")?; - append_local_conversation_message_for_session_at( - root, - agent_id.as_deref(), - session_id.as_deref(), - message, - ) + match message_id.as_deref() { + Some(message_id) => append_local_conversation_message_for_session_idempotent_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + message_id, + ), + None => append_local_conversation_message_for_session_at( + root, + agent_id.as_deref(), + session_id.as_deref(), + message, + ), + } } #[tauri::command] 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 1b6a1996d..36ce07533 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -53,7 +53,7 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { - validate_game_creator_llm_web_search_config(llm, config_path)?; + let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?; let api_key = trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?; let base_url = @@ -61,6 +61,8 @@ fn build_game_creator_platform_llm_config( let model = trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?; validate_game_creator_llm_timing_config(llm, config_path)?; + let anthropic_strict_tool_support = + game_creator_supports_anthropic_strict_tools(api_kind, &base_url, &model); LlmConfig::new( LlmProvider::OpenAiCompatible, base_url, @@ -70,9 +72,54 @@ fn build_game_creator_platform_llm_config( llm.max_retries, llm.retry_backoff_ms, ) + .map(|config| config.with_anthropic_strict_tool_support(anthropic_strict_tool_support)) .map_err(|error| format!("LLM 配置无效:{error}")) } +fn game_creator_supports_anthropic_strict_tools( + api_kind: LlmApiKind, + base_url: &str, + model: &str, +) -> bool { + if api_kind != LlmApiKind::Anthropic { + return false; + } + + // 兼容网关即使复用了 Anthropic messages 协议,也不能据此推断 structured + // outputs 能力。只对无凭据、无自定义端口/路径的官方 HTTPS endpoint 开启。 + let Ok(endpoint) = url::Url::parse(base_url) else { + return false; + }; + if endpoint.scheme() != "https" + || endpoint.host_str() != Some("api.anthropic.com") + || endpoint.port().is_some() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.path() != "/" + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return false; + } + + // Claude API 的 structured outputs 从 Claude 4.5 起可用。仅识别官方 Claude + // family 的版本化 model id;不凭 `latest`、第三方别名或未知产品名猜能力。 + let normalized = model.trim().to_ascii_lowercase(); + let mut parts = normalized.split('-'); + if parts.next() != Some("claude") || !matches!(parts.next(), Some("opus" | "sonnet" | "haiku")) + { + return false; + } + let Some(major) = parts.next().and_then(|value| value.parse::().ok()) else { + return false; + }; + let minor = parts + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + major > 4 || (major == 4 && minor >= 5) +} + pub(crate) fn build_game_creator_llm_client_from_config() -> Result { let app_config = load_game_creator_app_config()?; build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm") @@ -174,7 +221,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, error: Some(error), agents: Vec::new(), - } + }; } }; let global_route_shape_error = @@ -675,6 +722,24 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +#[cfg(windows)] +pub(crate) fn windows_private_dacl_security_information( + initialize_owner: bool, + owner_matches: bool, +) -> u32 { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | if initialize_owner && !owner_matches { + OWNER_SECURITY_INFORMATION + } else { + 0 + } +} + #[cfg(windows)] fn secure_windows_game_creator_path_for_current_user_with_owner_policy( path: &Path, @@ -795,7 +860,6 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( const SE_FILE_OBJECT: u32 = 1; const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; - const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; const SE_DACL_PROTECTED: u16 = 0x1000; const TOKEN_QUERY: u32 = 0x0000_0008; const TOKEN_USER_CLASS: u32 = 1; @@ -925,18 +989,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } // SAFETY: path is NUL terminated and private_dacl was allocated by SetEntriesInAclW. + let should_initialize_owner = initialize_owner && !owner_matches; let set_status = unsafe { SetNamedSecurityInfoW( wide_path.as_mut_ptr(), SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION - | PROTECTED_DACL_SECURITY_INFORMATION - | if initialize_owner { - OWNER_SECURITY_INFORMATION - } else { - 0 - }, - if initialize_owner { + windows_private_dacl_security_information(initialize_owner, owner_matches), + if should_initialize_owner { current_user_sid } else { std::ptr::null_mut() @@ -1574,3 +1633,54 @@ pub(crate) fn game_creator_config_file_label(file_name: &str) -> String { .map(|directory| directory.join(file_name).display().to_string()) .unwrap_or_else(|| file_name.to_string()) } + +#[cfg(test)] +mod anthropic_strict_capability_tests { + use super::*; + + fn anthropic_config(base_url: &str, model: &str) -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "test-key".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + api_kind: "anthropic".to_string(), + web_search_enabled: false, + ..GameCreatorLlmConfig::default() + } + } + + #[test] + fn official_supported_claude_model_opts_in_to_anthropic_strict_tools() { + for model in [ + "claude-sonnet-4-5-20250929", + "claude-opus-4-6", + "claude-haiku-5", + ] { + let config = build_game_creator_platform_llm_config( + &anthropic_config("https://api.anthropic.com", model), + "llm", + ) + .expect("supported official Anthropic config"); + assert!(config.anthropic_strict_tool_support(), "model={model}"); + } + } + + #[test] + fn old_models_and_compatible_or_lookalike_endpoints_keep_strict_disabled() { + for (base_url, model) in [ + ("https://api.anthropic.com", "claude-3-5-sonnet-latest"), + ("https://api.anthropic.com", "claude-sonnet-latest"), + ("https://minimax.example.com", "claude-sonnet-4-5"), + ("https://api.anthropic.com.example.com", "claude-sonnet-4-5"), + ("http://api.anthropic.com", "claude-sonnet-4-5"), + ] { + let config = + build_game_creator_platform_llm_config(&anthropic_config(base_url, model), "llm") + .expect("non-capable Anthropic config remains usable without strict"); + assert!( + !config.anthropic_strict_tool_support(), + "base_url={base_url}, model={model}" + ); + } + } +} 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 0e247a357..ac768693f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -468,6 +468,8 @@ struct AgentRuntimeEvent { #[serde(default)] event_type: String, #[serde(default)] + event_id: String, + #[serde(default)] action_id: Option, #[serde(default)] status: String, @@ -476,6 +478,8 @@ struct AgentRuntimeEvent { #[serde(default)] summary: String, #[serde(default)] + public_text: Option, + #[serde(default)] detail: Option, #[serde(default)] updated_at: u64, @@ -884,6 +888,7 @@ struct GeneratedPlatformArtAsset { asset_object_id: Option, task_id: Option, model: Option, + warning: Option, slice_warning: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 066a6a37a..2c5f26c40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -899,13 +899,47 @@ fn normalize_game_creator_mcp_catalog_tool( }) } +fn normalize_game_creator_mcp_server_tools( + server_id: &str, + config: &GameCreatorMcpServerConfig, + listed_tools: Vec, +) -> Result, String> { + if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER { + return Err(format!( + "MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}", + listed_tools.len() + )); + } + let mut server_tools = Vec::new(); + let mut server_tool_names = BTreeSet::new(); + for tool in listed_tools { + if !game_creator_mcp_tool_is_enabled(config, tool.name.as_ref()) { + continue; + } + if tool.task_support() == TaskSupport::Required { + return Err(format!( + "MCP tool {server_id}/{} 要求 task-mode,当前切片未支持", + tool.name + )); + } + if !server_tool_names.insert(tool.name.to_string()) { + return Err(format!( + "MCP server {server_id} 返回重复 tool identity:{}", + tool.name + )); + } + server_tools.push(normalize_game_creator_mcp_catalog_tool( + server_id, config, tool, + )?); + } + server_tools.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(server_tools) +} + pub(crate) async fn read_game_creator_mcp_catalog_at( root: &Path, ) -> Result { let config = load_game_creator_app_config()?; - let mut servers = Vec::new(); - let mut tools = Vec::new(); - let mut catalog_identity = Vec::new(); let server_reads = config .mcp_servers .into_iter() @@ -999,37 +1033,33 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( )); } }; - if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER { - return Err(format!( - "MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}", - listed_tools.len() - )); - } - let mut server_tools = Vec::new(); - let mut server_tool_names = BTreeSet::new(); - for tool in listed_tools { - if !game_creator_mcp_tool_is_enabled(&server_config, tool.name.as_ref()) { - continue; - } - if tool.task_support() == TaskSupport::Required { - return Err(format!( - "MCP tool {server_id}/{} 要求 task-mode,当前切片未支持", - tool.name + let server_tools = match normalize_game_creator_mcp_server_tools( + &server_id, + &server_config, + listed_tools, + ) { + Ok(server_tools) => server_tools, + Err(error) if server_config.required => return Err(error), + Err(error) => { + return Ok(( + GameCreatorMcpServerStatus { + server_id, + enabled: true, + required: false, + transport: server_config.transport, + connected: false, + server_name, + server_version, + instructions: instructions.clone(), + instructions_chars: instructions.chars().count(), + tool_count: 0, + error: Some(sanitize_game_creator_mcp_error(root, &error, 240)), + }, + Vec::new(), + None, )); } - if !server_tool_names.insert(tool.name.to_string()) { - return Err(format!( - "MCP server {server_id} 返回重复 tool identity:{}", - tool.name - )); - } - server_tools.push(normalize_game_creator_mcp_catalog_tool( - &server_id, - &server_config, - tool, - )?); - } - server_tools.sort_by(|left, right| left.name.cmp(&right.name)); + }; let catalog_identity = serde_json::json!({ "serverId": server_id, "configFingerprint": entry.config_fingerprint, @@ -1054,25 +1084,117 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( drop(entry); Ok((status, server_tools, Some(catalog_identity))) }); + let mut server_results = Vec::new(); for server_read in futures::future::join_all(server_reads).await { - let (server, server_tools, identity) = server_read?; - servers.push(server); - tools.extend(server_tools); - if let Some(identity) = identity { - catalog_identity.push(identity); + server_results.push(server_read?); + } + + let collect_catalog_parts = + |results: &[( + GameCreatorMcpServerStatus, + Vec, + Option, + )], + included_optional_servers: &BTreeSet| { + let mut candidate_servers = Vec::with_capacity(results.len()); + let mut candidate_tools = Vec::new(); + let mut candidate_identity = Vec::new(); + for (status, server_tools, identity) in results { + let included = status.required + || included_optional_servers.contains(status.server_id.as_str()); + let mut candidate_status = status.clone(); + if !included && candidate_status.connected { + candidate_status.connected = false; + candidate_status.tool_count = 0; + } + candidate_servers.push(candidate_status); + if included && status.connected { + candidate_tools.extend(server_tools.iter().cloned()); + if let Some(identity) = identity { + candidate_identity.push(identity.clone()); + } + } + } + (candidate_servers, candidate_tools, candidate_identity) + }; + + let catalog_prompt_bytes = |candidate_servers: Vec, + candidate_tools: Vec, + candidate_identity: &[serde_json::Value]| + -> Result { + let candidate = GameCreatorMcpCatalog { + fingerprint: game_creator_mcp_sha256(candidate_identity)?, + servers: candidate_servers, + tools: candidate_tools, + }; + Ok(render_game_creator_mcp_catalog_for_prompt(&candidate)? + .into_bytes() + .len()) + }; + + let mut included_optional_servers = BTreeSet::new(); + let (required_servers, required_tools, required_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); + if required_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS { + return Err(format!( + "required MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}", + required_tools.len() + )); + } + let required_catalog_bytes = + catalog_prompt_bytes(required_servers, required_tools, &required_identity)?; + if required_catalog_bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES { + return Err(format!( + "required MCP catalog 为 {required_catalog_bytes} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}" + )); + } + + for index in 0..server_results.len() { + let status = &server_results[index].0; + if status.required || !status.connected { + continue; + } + let server_id = status.server_id.clone(); + included_optional_servers.insert(server_id.clone()); + let (candidate_servers, candidate_tools, candidate_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); + let candidate_tool_count = candidate_tools.len(); + let candidate_bytes = if candidate_tool_count <= GAME_CREATOR_MCP_MAX_TOOLS { + Some(catalog_prompt_bytes( + candidate_servers, + candidate_tools, + &candidate_identity, + )?) + } else { + None + }; + let capacity_error = if candidate_tool_count > GAME_CREATOR_MCP_MAX_TOOLS { + Some(format!( + "MCP server {server_id} 使目录工具总数达到 {candidate_tool_count},超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}" + )) + } else if candidate_bytes.is_some_and(|bytes| bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES) { + Some(format!( + "MCP server {server_id} 使目录超过 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES} bytes 上限" + )) + } else { + None + }; + if let Some(error) = capacity_error { + included_optional_servers.remove(server_id.as_str()); + let status = &mut server_results[index].0; + status.connected = false; + status.tool_count = 0; + status.error = Some(sanitize_game_creator_mcp_error(root, &error, 240)); } } + + let (servers, mut tools, catalog_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); tools.sort_by(|left, right| { left.server_id .cmp(&right.server_id) .then_with(|| left.name.cmp(&right.name)) }); - if tools.len() > GAME_CREATOR_MCP_MAX_TOOLS { - return Err(format!( - "MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}", - tools.len() - )); - } let fingerprint = game_creator_mcp_sha256(&catalog_identity)?; let catalog = GameCreatorMcpCatalog { fingerprint, @@ -1080,12 +1202,7 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( tools, }; let catalog_bytes = render_game_creator_mcp_catalog_for_prompt(&catalog)?.into_bytes(); - if catalog_bytes.len() > GAME_CREATOR_MCP_MAX_CATALOG_BYTES { - return Err(format!( - "MCP catalog 为 {} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}", - catalog_bytes.len() - )); - } + debug_assert!(catalog_bytes.len() <= GAME_CREATOR_MCP_MAX_CATALOG_BYTES); Ok(catalog) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index d36f60193..056d25ce3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -84,6 +84,32 @@ impl PreviewRegistry { static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock = OnceLock::new(); +const PREVIEW_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2); +const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; +const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; +const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); +const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PreviewListenerAcceptDisposition { + Sleep, + Retry, + Stop, +} + +pub(crate) fn classify_preview_listener_accept_error( + error: &std::io::Error, +) -> PreviewListenerAcceptDisposition { + match error.kind() { + std::io::ErrorKind::WouldBlock => PreviewListenerAcceptDisposition::Sleep, + std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut => PreviewListenerAcceptDisposition::Retry, + _ => PreviewListenerAcceptDisposition::Stop, + } +} + pub(crate) fn game_creator_preview_registry() -> PreviewRegistry { GAME_CREATOR_PREVIEW_REGISTRY .get_or_init(PreviewRegistry::default) @@ -392,10 +418,18 @@ pub(crate) fn start_local_game_preview_for_project( } match listener.accept() { Ok((stream, _)) => handle_preview_stream(stream, &served_root), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(25)); - } - Err(_) => break, + // Chromium can abandon a speculative loopback socket before accept() consumes + // it. Keep the listener alive for that connection; only an unrecoverable listener + // error should tear down the preview server. + Err(error) => match classify_preview_listener_accept_error(&error) { + PreviewListenerAcceptDisposition::Sleep => { + thread::sleep(Duration::from_millis(25)); + } + PreviewListenerAcceptDisposition::Retry => { + thread::sleep(Duration::from_millis(5)); + } + PreviewListenerAcceptDisposition::Stop => break, + }, } }); @@ -410,19 +444,109 @@ pub(crate) fn start_local_game_preview_for_project( } fn handle_preview_stream(mut stream: TcpStream, root: &Path) { - let mut request_line = String::new(); - { - let mut reader = BufReader::new(&mut stream); - if reader.read_line(&mut request_line).is_err() { - return; - } + // The listener is nonblocking so its accept loop can observe the stop channel. Windows may + // inherit that mode on accepted sockets; switch each connection back to blocking mode before + // waiting for Chromium's split request headers. + if stream.set_nonblocking(false).is_err() { + return; } + let request_line = match read_preview_request_line(&mut stream) { + Ok(Some(request_line)) => request_line, + Ok(None) | Err(_) => return, + }; let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or_default(); let url_path = parts.next().unwrap_or("/"); let response = build_preview_response(root, method, url_path); - let _ = stream.write_all(&response); + if stream.write_all(&response).is_ok() { + let _ = stream.flush(); + // Explicitly half-close after the complete response, then consume the peer's remaining + // request bytes for a short bounded interval. This lets Windows complete a graceful + // FIN/ACK exchange instead of surfacing the close as WSAECONNABORTED to Chromium. + let _ = stream.shutdown(std::net::Shutdown::Write); + drain_preview_request_after_response(&mut stream); + } +} + +fn drain_preview_request_after_response(stream: &mut TcpStream) { + let _ = stream.set_read_timeout(Some(PREVIEW_RESPONSE_DRAIN_TIMEOUT)); + let mut buffer = [0u8; 4096]; + let mut drained_bytes = 0usize; + while drained_bytes < PREVIEW_RESPONSE_DRAIN_MAX_BYTES { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(bytes_read) => { + drained_bytes = drained_bytes.saturating_add(bytes_read); + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(_) => break, + } + } +} + +/// Read the request line and all headers before closing the connection. +/// +/// Chromium can deliver the request line and headers in separate packets. Dropping the +/// stream after only `read_line` leaves unread request bytes on Windows and may make the +/// close look like an abortive RST (`net::ERR_SOCKET_NOT_CONNECTED`). The bounded read keeps +/// slow or malformed clients from occupying a preview thread indefinitely. +fn read_preview_request_line(stream: &mut TcpStream) -> std::io::Result> { + stream.set_read_timeout(Some(PREVIEW_REQUEST_READ_TIMEOUT))?; + let mut reader = BufReader::new(stream); + let mut request_line = Vec::new(); + let mut total_bytes = 0usize; + + for line_index in 0..PREVIEW_REQUEST_MAX_HEADER_LINES { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(None); + } + let newline_index = available.iter().position(|byte| *byte == b'\n'); + let bytes_to_consume = newline_index + .map(|index| index + 1) + .unwrap_or(available.len()); + if total_bytes.saturating_add(bytes_to_consume) > PREVIEW_REQUEST_MAX_HEADER_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the size limit", + )); + } + line.extend_from_slice(&available[..bytes_to_consume]); + total_bytes += bytes_to_consume; + reader.consume(bytes_to_consume); + if newline_index.is_some() { + break; + } + } + let is_blank_line = line == b"\r\n" || line == b"\n"; + if line_index == 0 { + request_line = line; + } + if is_blank_line { + return String::from_utf8(request_line).map(Some).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request line is not valid UTF-8", + ) + }); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "preview request headers exceed the line limit", + )) } pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) -> Vec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index ae54a9ba5..91fb6a9ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -258,11 +258,12 @@ pub(crate) fn validate_manifest_required_visual_asset( .and_then(|path| fs::read(path).ok()) .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; - if task_id == "art-asset-plan" - && !image::load_from_memory(&bytes) - .ok() - .is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) - { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?; + if decoded.width() == 0 || decoded.height() == 0 { + return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}")); + } + if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) { return Err("首版美术素材图没有真实透明像素".to_string()); } let canvas_project_id = asset diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index bb3167a00..541b08311 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -2060,6 +2060,136 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() { fs::remove_dir_all(root).ok(); } +#[test] +fn game_chat_autonomous_run_rejects_publish_delegates_before_child_creation() { + for target_agent_id in ["publish-strategy", "publish-package"] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 发布委派门禁") + .expect("project init"); + let parent_run_id = format!("game-chat-publish-deny-{target_agent_id}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind game-chat autonomous profile"); + let action_id = format!("deny-{target_agent_id}"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "不应创建发布任务", + "acceptanceCriteria": ["必须先被 Runtime 拒绝"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "failed", "{observation:?}"); + assert!( + observation.summary.contains("game-chat") + || observation.summary.contains("publish") + || observation.summary.contains("发布"), + "{observation:?}" + ); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read rejected delivery") + .is_none(), + "rejected publish delegate must not create delivery" + ); + assert!( + read_latest_game_creator_agent_runtime_task_by_delegation_id( + &root, + target_agent_id, + &delegation_id, + ) + .expect("read rejected child task") + .is_none(), + "rejected publish delegate must not create child runtime" + ); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn gui_and_cli_autonomous_runs_still_allow_publish_delegates() { + for source in [ + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "GUI CLI 发布委派允许") + .expect("project init"); + let parent_run_id = format!("publish-allow-{source}"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind non-game-chat autonomous profile"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "GUI CLI 发布委派父 Runtime", + &parent_run_id, + source, + "准备发布委派", + vec!["验证发布委派仍可创建子 Runtime".to_string()], + ) + .expect("start non-game-chat parent runtime"); + let target_agent_id = "publish-strategy"; + let action_id = format!("allow-{source}"); + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire publish target lane") + .expect("publish target lane available"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + Some(&action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "允许 GUI CLI 发布策略委派", + "acceptanceCriteria": ["返回可核对回执"], + "expectedArtifacts": [], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + target_agent_id, + &action_id, + ); + assert!( + read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read allowed delivery") + .is_some(), + "non-game-chat publish delegate should create delivery" + ); + drop(target_lock); + fs::remove_dir_all(root).ok(); + } +} + #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index f0580e9e2..a255f8748 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -90,6 +90,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { assert!(autonomous_manifest_parent_wake_error_is_transient( "项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock" )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:$PROJECT_ROOT/.agent/runtime/locks/balance-seed.lock: 另一个程序正在使用此文件。 (os error 32)" + )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "获取 Agent Runtime 系统文件锁失败:sharing violation" + )); assert!(!autonomous_manifest_parent_wake_error_is_transient( "manifest JSON 已损坏" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index f9b5ad9d3..f2b2e0f69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -300,55 +300,36 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi let AgentRuntimeProviderActionBatchPreparation::Ready(valid_batch) = preparation else { panic!("valid autonomous responsibilities must form a ready durable batch"); }; - assert_eq!(valid_batch.actions.len(), 2); + assert_eq!(valid_batch.actions.len(), 3); assert!(valid_batch.collaboration_contract.is_some()); let valid_batch_id = valid_batch.batch_id.clone(); - let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ); - quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ - "直接写入 game/index.html 修复试玩问题", - "修改后执行静态验证并交付新 revision" - ]); + let mut design_not_read_only = valid_autonomous_initial_responsibility_actions_for_test(); + design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目并完成首轮策划实现。"); + design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]); + let mut design_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test(); + design_with_artifacts[0].input["expectedArtifacts"] = + serde_json::json!(["game/game_design.md"]); + let mut art_missing_spec = valid_autonomous_initial_responsibility_actions_for_test(); + art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); + let mut code_with_artifacts = valid_autonomous_initial_responsibility_actions_for_test(); + code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]); let invalid_cases = vec![ ( - "code-missing-game-index", - "game/index.html", - autonomous_initial_responsibility_actions_for_test( - "创建可直接试玩的游戏实现并执行静态验证。", - &["game/main.js"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ), + "design-not-read-only", + "design-director", + design_not_read_only, ), ( - "code-read-only", - "code-prototype", - autonomous_initial_responsibility_actions_for_test( - "只读检查 game/index.html,不要修改任何项目文件。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ), - ), - ( - "quality-not-read-only", - "quality-review", - quality_not_read_only, - ), - ( - "quality-with-artifacts", + "design-with-artifacts", "expectedArtifacts", - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &["game/index.html"], - ), + design_with_artifacts, + ), + ("art-missing-spec", "assets/art-spec.png", art_missing_spec), + ( + "code-with-artifacts", + "expectedArtifacts", + code_with_artifacts, ), ]; for (case_name, expected_error, actions) in invalid_cases { @@ -389,12 +370,7 @@ async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_wi rewrite_autonomous_responsibility_batch_actions_for_test( &root, &mut legacy_v2_batch, - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ), + valid_autonomous_initial_responsibility_actions_for_test(), ); let legacy_v2_schema = "game-creator-provider-action-batch.v2"; legacy_v2_batch.schema_version = legacy_v2_schema.to_string(); @@ -600,68 +576,37 @@ async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mut fs::remove_dir_all(root).ok(); } -fn autonomous_initial_responsibility_actions_for_test( - code_task: &str, - code_artifacts: &[&str], - quality_task: &str, - quality_artifacts: &[&str], -) -> Vec { +fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec { vec![ - AgentRuntimeToolAction { - tool: "agent.delegate".to_string(), - reason: Some("委派程序 Agent 形成可玩入口".to_string()), - input: serde_json::json!({ - "agentId": "code-prototype", - "task": code_task, - "acceptanceCriteria": [ - "game/index.html 必须形成可直接试玩的完整入口", - "程序交付必须完成当前 revision 的静态验证" - ], - "expectedArtifacts": code_artifacts, - "repairOfDelegationId": null, - "runId": null - }), - }, - AgentRuntimeToolAction { - tool: "agent.delegate".to_string(), - reason: Some("委派质量 Agent 独立只读验收".to_string()), - input: serde_json::json!({ - "agentId": "quality-review", - "task": quality_task, - "acceptanceCriteria": [ - "只读核对可玩性、交互闭环和阻塞问题", - "返回可追溯的验收结论,不修改项目文件" - ], - "expectedArtifacts": quality_artifacts, - "repairOfDelegationId": null, - "runId": null - }), - }, + autonomous_initial_leader_responsibility_action_for_test("design-director", &[]), + autonomous_initial_leader_responsibility_action_for_test( + "art-director", + &["assets/art-spec.png"], + ), + autonomous_initial_leader_responsibility_action_for_test("code-director", &[]), ] } -fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec { - autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性与交互闭环;不要修改任何项目文件。", - &[], - ) -} - -fn autonomous_art_director_responsibility_action_for_test( +fn autonomous_initial_leader_responsibility_action_for_test( + agent_id: &str, expected_artifacts: &[&str], ) -> AgentRuntimeToolAction { + let read_only = matches!(agent_id, "design-director" | "code-director"); AgentRuntimeToolAction { tool: "agent.delegate".to_string(), - reason: Some("委派美术总监生成统一视觉规范图".to_string()), + reason: Some("建立首批 Leader 专业规划".to_string()), input: serde_json::json!({ - "agentId": "art-director", - "task": "生成项目统一视觉规范图并写入项目资产目录。", - "acceptanceCriteria": [ - "使用画布生成接口产出后续 UI 与图集共用的规范图", - "生成结果必须登记为项目本地 icon-spec 资产" - ], + "agentId": agent_id, + "task": if read_only { + format!("由 {agent_id} 只读完成首轮专业规划,不得修改项目") + } else { + "生成首轮统一视觉规范图供后续专业 Agent 使用".to_string() + }, + "acceptanceCriteria": if read_only { + serde_json::json!(["只读输出专业规划,不得修改项目文件"]) + } else { + serde_json::json!(["生成并登记统一视觉规范图"]) + }, "expectedArtifacts": expected_artifacts, "repairOfDelegationId": null, "runId": null @@ -713,9 +658,7 @@ async fn supervisor_autonomous_initial_art_director_requires_canonical_art_spec_ ) .expect("start autonomous Supervisor runtime"); let mut actions = valid_autonomous_initial_responsibility_actions_for_test(); - actions.push(autonomous_art_director_responsibility_action_for_test(&[ - "assets/art-preview.png", - ])); + actions[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); let plan = supervisor_collaboration_plan_for_test(actions); let revision = read_game_creator_agent_runtime_project_revision(&root) .expect("read art contract project revision"); @@ -834,40 +777,21 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef ) .expect("start autonomous Supervisor runtime"); - let code_missing_game_index = autonomous_initial_responsibility_actions_for_test( - "创建可直接试玩的游戏实现并执行静态验证。", - &["game/main.js"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ); - let code_read_only = autonomous_initial_responsibility_actions_for_test( - "只读检查 game/index.html,不要修改任何项目文件。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &[], - ); - let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "直接修改 game/index.html,修复试玩阻塞并重新验证。", - &[], - ); - quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ - "直接写入 game/index.html 修复试玩问题", - "修改后执行静态验证并交付新 revision" - ]); - let quality_with_artifacts = autonomous_initial_responsibility_actions_for_test( - "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", - &["game/index.html"], - "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", - &["game/index.html"], - ); let valid = valid_autonomous_initial_responsibility_actions_for_test(); + let mut missing_design = valid.clone(); + missing_design.remove(0); + let mut design_not_read_only = valid.clone(); + design_not_read_only[0].input["task"] = serde_json::json!("直接修改项目完成策划实现"); + design_not_read_only[0].input["acceptanceCriteria"] = serde_json::json!(["直接修改项目文件"]); + let mut art_missing_spec = valid.clone(); + art_missing_spec[1].input["expectedArtifacts"] = serde_json::json!(["assets/art-preview.png"]); + let mut code_with_artifacts = valid.clone(); + code_with_artifacts[2].input["expectedArtifacts"] = serde_json::json!(["game/index.html"]); let responses = [ - ("code-missing-game-index", &code_missing_game_index), - ("code-read-only", &code_read_only), - ("quality-not-read-only", &quality_not_read_only), - ("quality-with-artifacts", &quality_with_artifacts), + ("missing-design", &missing_design), + ("design-not-read-only", &design_not_read_only), + ("art-missing-spec", &art_missing_spec), + ("code-with-artifacts", &code_with_artifacts), ("valid-responsibilities", &valid), ] .into_iter() @@ -904,7 +828,13 @@ async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_bef .await .expect("repair invalid initial responsibilities") .expect("valid initial responsibility plan"); - assert_eq!(plan.actions, valid); + let mut expected_valid = valid.clone(); + expected_valid.sort_by(|left, right| { + left.input["agentId"] + .as_str() + .cmp(&right.input["agentId"].as_str()) + }); + assert_eq!(plan.actions, expected_valid); assert!(plan.response.is_empty()); let requests = (0..5) @@ -1432,10 +1362,11 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio &root, SupervisorCollaborationPolicy { required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: 2, + min_static_delegates: 3, required_static_agent_ids: vec![ - "code-prototype".to_string(), - "quality-review".to_string(), + "design-director".to_string(), + "art-director".to_string(), + "code-director".to_string(), ], ..SupervisorCollaborationPolicy::default() }, @@ -1444,26 +1375,36 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let (sender, receiver) = mpsc::channel(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); - let isolated_function = - native_runtime_function_name("agent.spawn_isolated").expect("isolated function"); - let code_arguments = serde_json::json!({ - "reason": "委派原型实现 Agent", + let design_arguments = serde_json::json!({ + "reason": "委派策划 Leader", "input": { - "agentId": "code-prototype", - "task": "实现可直接试玩的游戏原型", - "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], - "expectedArtifacts": ["game/index.html"], + "agentId": "design-director", + "task": "只读拆解首轮玩法目标和专业分工,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的策划规划,不得修改项目"], + "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null } }) .to_string(); - let quality_arguments = serde_json::json!({ - "reason": "委派质量评审 Agent", + let art_arguments = serde_json::json!({ + "reason": "委派美术 Leader", "input": { - "agentId": "quality-review", - "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", - "acceptanceCriteria": ["只读指出阻塞试玩的具体问题并给出验收结论"], + "agentId": "art-director", + "task": "确定首轮原创视觉方向", + "acceptanceCriteria": ["视觉规范可供后续底层 Agent 按需执行"], + "expectedArtifacts": ["assets/art-spec.png"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let code_arguments = serde_json::json!({ + "reason": "委派程序 Leader", + "input": { + "agentId": "code-director", + "task": "只读拆解首轮程序实现边界,不得修改项目", + "acceptanceCriteria": ["只读给出可供后续底层 Agent 按需执行的程序规划,不得修改项目"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null @@ -1473,15 +1414,22 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let base_url = spawn_mock_llm_raw_responses_with_capture( vec![ native_agent_tool_plan_chat_response( - "call-supervisor-initial-code-delegate", + "call-supervisor-initial-design-delegate", delegate_function.as_str(), - code_arguments, - ), - native_agent_tool_plan_chat_response( - "call-supervisor-read-only-quality-delegate", - delegate_function.as_str(), - quality_arguments, + design_arguments, ), + native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-supervisor-art-director-delegate", + delegate_function.as_str(), + art_arguments, + ), + ( + "call-supervisor-code-director-delegate", + delegate_function.as_str(), + code_arguments, + ), + ]), ], Some(sender), ); @@ -1511,13 +1459,13 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let runtime = start_game_creator_agent_runtime_task_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "并行完成原型实现与质量评审", + "并行完成程策美 Leader 首轮规划", run_id, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "读取后建立首批协作", vec![ "读取必要上下文".to_string(), - "一次性委派两个专业 Agent".to_string(), + "一次性委派三个 Leader Agent".to_string(), ], ) .expect("start supervisor runtime"); @@ -1535,13 +1483,14 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .await .expect("repair read-only initial collaboration plan") .expect("repaired collaboration plan"); - assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions.len(), 3); assert!(plan .actions .iter() .all(|action| action.tool == "agent.delegate")); - assert_eq!(plan.actions[0].input["agentId"], "code-prototype"); - assert_eq!(plan.actions[1].input["agentId"], "quality-review"); + assert_eq!(plan.actions[0].input["agentId"], "design-director"); + assert_eq!(plan.actions[1].input["agentId"], "art-director"); + assert_eq!(plan.actions[2].input["agentId"], "code-director"); let initial_request = receiver .recv_timeout(Duration::from_secs(2)) @@ -1550,7 +1499,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("supervisor read-only collaboration repair request"); - assert!(repair_request.contains("missingStaticAgents=quality-review")); + assert!(repair_request.contains("missingStaticAgents=art-director,code-director")); let repair_request_json = mock_http_request_json(&repair_request); let repair_function_names = repair_request_json["tools"] .as_array() @@ -1568,7 +1517,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .collect::>(); assert_eq!( repair_function_names, - BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) + BTreeSet::from([delegate_function.as_str()]) ); let delegate_schema = repair_request_json["tools"] .as_array() @@ -1590,7 +1539,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio .expect("delegate parameters"); assert_eq!( parameters["properties"]["input"]["properties"]["agentId"]["enum"], - serde_json::json!(["quality-review"]) + serde_json::json!(["art-director", "code-director"]) ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); @@ -1613,7 +1562,7 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio }) .expect("repaired collaboration protocol audit"); assert_eq!(protocol["repairAttempt"], 1); - assert_eq!(protocol["functionCallCount"], 1); + assert_eq!(protocol["functionCallCount"], 2); let collaboration_state = read_supervisor_collaboration_state_at( &root, 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 d529116c8..0276cb20d 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 @@ -1378,6 +1378,25 @@ pub(crate) fn set_windows_test_path_owner_to_distinct_token_owner(path: &Path) - changed } +#[cfg(windows)] +#[test] +fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + + assert_eq!( + windows_private_dacl_security_information(true, true), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION + ); + assert_eq!( + windows_private_dacl_security_information(true, false), + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + ); +} + #[cfg(windows)] #[test] fn windows_appdata_validation_does_not_follow_directory_links() { 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 32e1692c9..7eb2310e6 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 @@ -1265,7 +1265,7 @@ fn spawn_mock_llm_server(response_content: String) -> String { spawn_mock_llm_server_responses(vec![response_content]) } -fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { +pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { spawn_mock_llm_server_responses_with_capture(response_contents, None) } @@ -1309,7 +1309,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( base_url } -fn final_tool_plan_response(response: impl Into) -> String { +pub(crate) fn final_tool_plan_response(response: impl Into) -> String { serde_json::json!({ "thinkingSummary": "已有工具观察足够,可以收束后台任务", "planUpdate": null, @@ -2651,6 +2651,17 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let generation_accepted_body = serde_json::json!({ + "data": { + "operationId": "task-external-fixture-1", + "kind": "editor_image_generation", + "status": "queued", + "statusUrl": "/api/external/v1/generations/task-external-fixture-1", + "pollAfterMs": 1, + "updatedAtMicros": 1 + } + }) + .to_string(); let read_body = serde_json::json!({ "read": { "provider": "aliyun-oss", @@ -2677,6 +2688,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( .to_string(); std::thread::spawn(move || { let mut generation_response_gate = generation_response_gate; + let mut pending_generation_result: Option = None; + let mut generation_poll_index = 0_u8; for _ in 0..expected_requests { let (mut stream, _) = listener.accept().expect("mock canvas api accept"); let mut request_buffer = [0_u8; 8192]; @@ -2686,61 +2699,119 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( let _ = sender.send(request.to_string()); } let normalized_request = request.to_ascii_lowercase(); - let (content_type, body) = if request + let (status, content_type, body) = if request .starts_with("GET /api/external/v1/editor/projects ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", projects_body.as_bytes().to_vec()) + ("200 OK", "application/json", projects_body.as_bytes().to_vec()) } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", library_body.as_bytes().to_vec()) + ("200 OK", "application/json", library_body.as_bytes().to_vec()) } else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", project_body.as_bytes().to_vec()) + ("200 OK", "application/json", project_body.as_bytes().to_vec()) } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { assert!(normalized_request.contains("authorization: bearer ")); - if let Some(gate) = generation_response_gate.take() { - gate.recv_timeout(Duration::from_secs(5)) - .expect("release mock canvas generation response"); - } - ("application/json", generation_body.as_bytes().to_vec()) + let idempotency_key = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("idempotency-key") + .then_some(value.trim()) + }) + .expect("generation request idempotency key"); + uuid::Uuid::parse_str(idempotency_key.trim()) + .expect("generation idempotency key must be UUID"); + pending_generation_result = Some(generation_body.clone()); + generation_poll_index = 0; + ( + "202 Accepted", + "application/json", + generation_accepted_body.as_bytes().to_vec(), + ) } else if request .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { + assert!(normalized_request.contains("authorization: bearer ")); + let idempotency_key = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("idempotency-key") + .then_some(value.trim()) + }) + .expect("generation request idempotency key"); + uuid::Uuid::parse_str(idempotency_key.trim()) + .expect("generation idempotency key must be UUID"); + pending_generation_result = Some(icon_spritesheet_body.clone()); + generation_poll_index = 0; + ( + "202 Accepted", + "application/json", + generation_accepted_body.as_bytes().to_vec(), + ) + } else if request.starts_with( + "GET /api/external/v1/generations/task-external-fixture-1 ", + ) { assert!(normalized_request.contains("authorization: bearer ")); if let Some(gate) = generation_response_gate.take() { gate.recv_timeout(Duration::from_secs(5)) .expect("release mock canvas generation response"); } + let status = match generation_poll_index { + 0 => "queued", + 1 => "running", + _ => "completed", + }; + generation_poll_index = generation_poll_index.saturating_add(1); + let result = (status == "completed").then(|| { + serde_json::from_str::( + pending_generation_result + .as_deref() + .expect("generation query follows one submission"), + ) + .expect("fixture generation result JSON") + }); ( + "200 OK", "application/json", - icon_spritesheet_body.as_bytes().to_vec(), + serde_json::json!({ + "data": { + "operationId": "task-external-fixture-1", + "kind": "editor_image_generation", + "status": status, + "phaseLabel": "图片画布生成图片", + "phaseDetail": if status == "completed" { "生成已完成。" } else { "正在生成。" }, + "progress": if status == "completed" { 100 } else { 35 }, + "result": result, + "pollAfterMs": 1, + "updatedAtMicros": 2 + } + }) + .to_string() + .into_bytes(), ) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", read_body.as_bytes().to_vec()) + ("200 OK", "application/json", read_body.as_bytes().to_vec()) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ( + "200 OK", "application/json", spritesheet_read_body.as_bytes().to_vec(), ) } else if request.starts_with("GET /signed/hero.png ") { - ("image/png", valid_test_png_bytes()) + ("200 OK", "image/png", valid_test_png_bytes()) } else if request.starts_with("GET /signed/spritesheet.png ") { - ("image/png", transparent_test_png_bytes()) + ("200 OK", "image/png", transparent_test_png_bytes()) } else { - ("text/plain", b"not found".to_vec()) - }; - let status = if content_type == "text/plain" { - "404 Not Found" - } else { - "200 OK" + ("404 Not Found", "text/plain", b"not found".to_vec()) }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -2762,7 +2833,7 @@ fn spawn_mock_external_canvas_api_server() -> String { fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { - spawn_mock_external_canvas_api_server_with_capture(5, request_sender) + spawn_mock_external_canvas_api_server_with_capture(8, request_sender) } fn spawn_mock_external_canvas_generation_api_server_with_gate( @@ -2770,7 +2841,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate( generation_response_gate: mpsc::Receiver<()>, ) -> String { spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( - 5, + 8, Some(request_sender), Some(generation_response_gate), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 550350adc..48305afff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -331,6 +331,17 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + let art_spec_path = root.join("assets/art-spec.png"); + let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); + fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) + .expect("write truncated art spec PNG"); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-director") + .expect_err("truncated PNG must fail complete decode") + .contains("无法完整解码") + ); + fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG"); + let ui = manifest .assets .iter_mut() @@ -780,7 +791,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { assert!(agent_db.contains("\"agentId\":\"art-asset-plan\"")); assert!(agent_db.contains("测试图集保持整图,未生成独立切片。")); assert!(!agent_db.contains("editor-runtime-key")); - let canvas_requests = (0..5) + assert!(!agent_db.contains("idempotencyKey")); + assert!(!root + .join(".agent/runtime/canvas-generation-requests/art-asset-plan/art-generate-run.json") + .exists()); + let canvas_requests = (0..8) .map(|_| { canvas_receiver .recv_timeout(Duration::from_secs(2)) @@ -793,6 +808,22 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") }) .expect("canvas generation request"); + assert_eq!( + canvas_requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/")) + .count(), + 1, + "queued/running polling must not submit generation again" + ); + assert_eq!( + canvas_requests + .iter() + .filter(|request| request.starts_with("GET /api/external/v1/generations/")) + .count(), + 3, + "fixture should exercise queued, running, and completed states" + ); for expected in [ r#""referenceImageSrc":"resource-icon-spec""#, r#""iconDescriptions":"#, @@ -839,7 +870,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); - (0..5) + (0..8) .map(|_| { request_receiver .recv_timeout(Duration::from_secs(2)) @@ -3190,6 +3221,77 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_preview_server_drains_split_browser_headers_before_response() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "鍍忕礌鍔ㄤ綔鍘熷瀷").expect("project init"); + + let (preview, stop) = start_local_game_preview_for_project(&root).expect("preview start"); + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + stream + .write_all(b"GET / HTTP/1.1\r\n") + .expect("request line"); + // Chromium may send the request line before the rest of its headers. Keep this split + // deliberate so the server must consume the complete header block before responding. + thread::sleep(Duration::from_millis(100)); + stream + .write_all( + b"Host: 127.0.0.1\r\nConnection: close\r\nUser-Agent: Mozilla/5.0\r\nAccept: text/html\r\n\r\n", + ) + .expect("browser headers"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("response"); + let header_end = response + .windows(b"\r\n\r\n".len()) + .position(|window| window == b"\r\n\r\n") + .expect("complete HTTP header block") + + b"\r\n\r\n".len(); + let headers = String::from_utf8(response[..header_end].to_vec()).expect("HTTP headers"); + assert!(headers.starts_with("HTTP/1.1 200 OK\r\n"), "{headers}"); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .and_then(|value| value.parse::().ok()) + .expect("valid content length"); + assert_eq!(response.len() - header_end, content_length); + assert!(headers.contains("Connection: close"), "{headers}"); + assert!(String::from_utf8_lossy(&response[header_end..]).contains("还没有生成游戏")); + + let _ = stop.send(()); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn preview_listener_retries_transient_accept_errors() { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::WouldBlock, + )), + PreviewListenerAcceptDisposition::Sleep + ); + for kind in [ + std::io::ErrorKind::ConnectionAborted, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::Interrupted, + std::io::ErrorKind::TimedOut, + ] { + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from(kind)), + PreviewListenerAcceptDisposition::Retry, + "transient accept error {kind:?} must keep preview server alive" + ); + } + assert_eq!( + classify_preview_listener_accept_error(&std::io::Error::from( + std::io::ErrorKind::InvalidData, + )), + PreviewListenerAcceptDisposition::Stop + ); +} + #[test] fn preview_content_type_covers_common_game_assets() { assert_eq!(content_type(Path::new("hero.webp")), "image/webp"); 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 4f098396f..ff819cc52 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 @@ -222,6 +222,120 @@ async fn mcp_optional_tools_list_failure_is_bounded_but_required_fails() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn mcp_optional_invalid_tool_catalog_is_isolated_but_required_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "mcp-invalid-catalog", "MCP 非法目录项目") + .expect("initialize invalid MCP catalog project"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create invalid MCP catalog config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + + for (fixture_arg, expected_error) in [ + ("--oversized-input-schema", "input schema 超过上限"), + ("--duplicate-tool", "重复 tool identity"), + ("--require-task-mode", "要求 task-mode"), + ] { + let fixture = serde_json::json!({ + "required": false, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", fixture_arg] + }); + write_mcp_transport_test_config(&config_dir, "optional-invalid-fixture", fixture.clone()); + + let catalog = read_game_creator_mcp_catalog_at(&root) + .await + .expect("optional invalid tool catalog must stay in server status"); + assert!(catalog.tools.is_empty()); + let server = catalog.servers.first().expect("optional invalid status"); + assert!(!server.connected); + assert!( + server + .error + .as_deref() + .is_some_and(|error| { error.contains(expected_error) }), + "fixture={fixture_arg} status={server:?}" + ); + + shutdown_game_creator_mcp_clients_for_tests().await; + let mut required_fixture = fixture; + required_fixture["required"] = serde_json::Value::Bool(true); + write_mcp_transport_test_config(&config_dir, "required-invalid-fixture", required_fixture); + let error = read_game_creator_mcp_catalog_at(&root) + .await + .expect_err("required invalid tool catalog must fail the catalog"); + assert!( + error.contains(expected_error), + "fixture={fixture_arg} error={error}" + ); + shutdown_game_creator_mcp_clients_for_tests().await; + } + + drop(config_guard); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test] +async fn mcp_optional_server_is_isolated_when_aggregate_catalog_exceeds_tool_limit() { + let root = unique_project_path(); + init_local_game_project_at(&root, "mcp-aggregate-limit", "MCP 聚合上限项目") + .expect("initialize MCP aggregate limit project"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create MCP aggregate limit config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let fixture = mcp_fixture_script_path(); + let config = serde_json::json!({ + "mcpServers": { + "required-alpha": { + "required": true, + "transport": "stdio", + "command": "node", + "args": [fixture, "stdio", "--tool-count=64"] + }, + "required-beta": { + "required": true, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"] + }, + "optional-gamma": { + "required": false, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"] + } + } + }); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::to_vec_pretty(&config).expect("serialize MCP aggregate limit config"), + ) + .expect("write MCP aggregate limit config"); + + let catalog = read_game_creator_mcp_catalog_at(&root) + .await + .expect("optional aggregate overflow must stay in server status"); + assert_eq!(catalog.tools.len(), 128); + let optional = catalog + .servers + .iter() + .find(|server| server.server_id == "optional-gamma") + .expect("optional aggregate overflow status"); + assert!(!optional.connected); + assert_eq!(optional.tool_count, 0); + assert!(optional + .error + .as_deref() + .is_some_and(|error| error.contains("工具总数") && error.contains("超过上限"))); + + shutdown_game_creator_mcp_clients_for_tests().await; + drop(config_guard); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn mcp_catalog_refreshes_independent_servers_in_parallel() { let root = unique_project_path(); @@ -4922,24 +5036,36 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .expect("project init"); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); - let code_arguments = serde_json::json!({ - "reason": "委派可玩原型实现", + let design_arguments = serde_json::json!({ + "reason": "委派策划 Leader", "input": { - "agentId": "code-prototype", - "task": "实现可直接试玩的游戏原型", - "acceptanceCriteria": ["项目能够启动并完成最小玩法闭环"], - "expectedArtifacts": ["game/index.html"], + "agentId": "design-director", + "task": "只读拆解首轮玩法目标和专业分工,不得修改项目", + "acceptanceCriteria": ["只读输出策划规划,不得修改项目文件"], + "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null } }) .to_string(); - let quality_arguments = serde_json::json!({ - "reason": "委派独立质量评审", + let art_arguments = serde_json::json!({ + "reason": "委派美术 Leader", "input": { - "agentId": "quality-review", - "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", - "acceptanceCriteria": ["只读给出阻塞试玩的问题和验收结论"], + "agentId": "art-director", + "task": "生成首轮统一视觉规范图供后续专业 Agent 使用", + "acceptanceCriteria": ["生成并登记统一视觉规范图"], + "expectedArtifacts": ["assets/art-spec.png"], + "repairOfDelegationId": null, + "runId": null + } + }) + .to_string(); + let code_arguments = serde_json::json!({ + "reason": "委派程序 Leader", + "input": { + "agentId": "code-director", + "task": "只读拆解首轮程序实现边界,不得修改项目", + "acceptanceCriteria": ["只读输出程序规划,不得修改项目文件"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null @@ -4947,16 +5073,21 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b }) .to_string(); let recovered_response = native_agent_tool_plan_chat_response_with_calls(vec![ + ( + "call-autonomous-upstream-400-design", + delegate_function.as_str(), + design_arguments, + ), + ( + "call-autonomous-upstream-400-art", + delegate_function.as_str(), + art_arguments, + ), ( "call-autonomous-upstream-400-code", delegate_function.as_str(), code_arguments, ), - ( - "call-autonomous-upstream-400-quality", - delegate_function.as_str(), - quality_arguments, - ), ]); let (request_notice_sender, request_notice_receiver) = mpsc::channel(); let base_url = spawn_mock_llm_upstream_400_then_raw_response( @@ -5042,7 +5173,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b assert!(request_notice_receiver .recv_timeout(Duration::from_millis(100)) .is_err()); - assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions.len(), 3); assert!(plan .actions .iter() 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 cd234fa8e..c138e990a 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 @@ -707,7 +707,7 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { )); let scheduled = - schedule_game_creator_agent_ready_tasks_at(&root, 0).expect("schedule ready tasks"); + schedule_game_creator_agent_ready_tasks_at(&root, 1).expect("schedule one ready task"); assert_eq!(scheduled.len(), 1); assert_eq!(scheduled[0].state.agent_id, "design-director"); assert_eq!(scheduled[0].state.source, "agent-ready-task-scheduler"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs index 3d1d27ab9..204b8d047 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/recovery.rs @@ -1852,6 +1852,147 @@ async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliatio fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_fresh_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "fresh project").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["conversation.write".to_string(), "agent.resume".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require confirmation for write permissions"); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("fresh project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_terminal_recovery_artifact() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal recovery artifact") + .expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-artifact-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "terminal runtime with durable artifact".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + let artifact = root + .join(".agent/runtime/pending-actions/design-director/design-terminal-artifact-run.json"); + fs::create_dir_all(artifact.parent().expect("artifact parent")) + .expect("create artifact directory"); + fs::write(&artifact, b"{}\n").expect("write durable recovery artifact"); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("durable recovery artifact still requires agent.resume approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_skips_policy_for_terminal_project() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "terminal project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-terminal-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "completed runtime".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "completed".to_string(), + terminal_detail: Some("completed".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + + let resumed = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect("terminal project has no recoverable runtime work"); + assert!(resumed.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_preflight_preserves_policy_for_recoverable_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "recoverable project").expect("project init"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-recoverable-run".to_string(), + source: "agent-background-task".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "recoverable runtime".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "waiting for recovery".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("recoverable runtime still requires agent.resume policy approval"); + assert!(error.contains("agent.resume"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_resume_commands_distinguish_auto_and_confirmed_paths() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 8fb6c488e..d718069e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -86,6 +86,100 @@ fn runtime_task_reader_rejects_unterminated_non_truncated_syntax_error() { fs::remove_dir_all(root).ok(); } +#[test] +fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime event fixture directory"); + let state = default_game_creator_agent_runtime_state("code-prototype", "public-event-run"); + + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "正在生成首个可玩版本", + Some("taskSha256=private-hash"), + "public-event-progress-1", + ) + .expect("append public progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "observation", + "running", + "observation", + "runtime.plan_update:blocked · 内部计划门禁", + Some("fingerprint=private"), + "public-event-internal-1", + ) + .expect("append internal observation"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.progress", + "running", + "planning", + "Bearer secret-token", + None, + "public-event-sensitive-1", + ) + .expect("append sensitive progress"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("append public action"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "action", + "running", + "action", + "调用工具 file.write", + Some("raw tool input must stay private"), + "public-event-action-1", + ) + .expect("repeat public action idempotently"); + + let events = read_recent_game_creator_agent_runtime_events( + &game_creator_agent_runtime_event_path(&root, "code-prototype"), + ) + .expect("read public runtime events"); + assert_eq!(events.len(), 4); + assert!(events.iter().all(|event| !event.event_id.trim().is_empty())); + let mut event_ids = events + .iter() + .map(|event| event.event_id.as_str()) + .collect::>(); + event_ids.sort_unstable(); + event_ids.dedup(); + assert_eq!(event_ids.len(), events.len()); + assert_eq!( + events[0].public_text.as_deref(), + Some("正在生成首个可玩版本") + ); + assert_eq!(events[1].public_text, None); + assert_eq!(events[2].public_text, None); + assert_eq!( + events[3].public_text.as_deref(), + Some("调用工具 file.write") + ); + assert!(!events[3] + .public_text + .as_deref() + .unwrap_or_default() + .contains("raw tool input must stay private")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_task_reader_rejects_unknown_status_and_phase() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index 9eb96c7d7..2f73eaf39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2616,6 +2616,7 @@ fn local_conversation_write_respects_project_policy() { content: "should fail".to_string(), agent_id: None, }, + None, ) .expect_err("conversation write denied"); assert!(error.contains("项目权限策略拒绝执行:conversation.write")); @@ -2623,6 +2624,33 @@ fn local_conversation_write_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn local_conversation_command_message_id_is_idempotent() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "game-chat 输出消息").expect("project init"); + let message_id = "game-chat-output-code-prototype-run-1"; + let append = || { + append_local_conversation_message( + root.to_string_lossy().into_owned(), + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: "【程序 Agent】\n首个可玩版本代码已生成。".to_string(), + agent_id: None, + }, + Some(message_id.to_string()), + ) + }; + + append().expect("append game-chat output"); + let repeated = append().expect("repeat game-chat output idempotently"); + assert_eq!(repeated.messages.len(), 1); + assert_eq!(repeated.messages[0].message_id.as_deref(), Some(message_id)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_conversation_read_respects_project_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs b/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs index aaa811adf..0dd384e63 100644 --- a/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs +++ b/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs @@ -6,6 +6,13 @@ const args = process.argv.slice(2); const mode = args[0] ?? 'stdio'; const failList = args.includes('--fail-list'); const includeUnannotated = args.includes('--include-unannotated'); +const duplicateTool = args.includes('--duplicate-tool'); +const oversizedInputSchema = args.includes('--oversized-input-schema'); +const requireTaskMode = args.includes('--require-task-mode'); +const toolCountArgument = args.find((value) => value.startsWith('--tool-count=')); +const toolCount = toolCountArgument + ? Number(toolCountArgument.slice('--tool-count='.length)) + : null; const listDelayArgument = args.find((value) => value.startsWith('--list-delay-ms='), ); @@ -89,6 +96,28 @@ const tools = [ }, ]; +if (duplicateTool) { + tools.push({ ...tools[0] }); +} + +if (oversizedInputSchema) { + tools[0].inputSchema.properties.query.description = 'x'.repeat(70 * 1024); +} + +if (requireTaskMode) { + tools[0].execution = { taskSupport: 'required' }; +} + +if (Number.isInteger(toolCount) && toolCount > tools.length) { + for (let index = tools.length; index < toolCount; index += 1) { + tools.push({ + ...tools[0], + name: `lookup-${index}`, + title: `Lookup ${index}`, + }); + } +} + if (includeUnannotated) { tools.push({ name: 'mutate-unannotated', diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 21ff93a40..7d2652110 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -230,6 +230,10 @@ import { buildGameChatProgressEvidence, collectGameChatResultImages, formatGameChatStageRecord, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + mergeGameChatFinalReplyMessagesIntoHistory, + mergeGameChatRuntimeEventMessagesIntoHistory, SupervisorChatOnlyView, } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; @@ -263,6 +267,58 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { playable: boolean; }; +const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function gameChatManifestHasTerminalStageTasks( + manifest: GameCreationAppManifest | null, +) { + if (!manifest) { + return false; + } + return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { + const status = manifest.tasks.find((task) => task.id === taskId)?.status; + return status === 'completed' || status === 'failed'; + }); +} + +function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { + return ( + ['completed', 'failed', 'cancelled'].includes(runtime.status) || + ['completed', 'failed', 'cancelled'].includes(runtime.phase) + ); +} + +function mergeGameChatHydratedConversationMessages( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const mergedMessages = mergeGameChatRuntimeEventMessagesIntoHistory( + mergeGameChatFinalReplyMessagesIntoHistory( + mergeGameChatRuntimeResponseMessagesIntoHistory( + historyMessages, + currentMessages, + ), + currentMessages, + ), + currentMessages, + ); + const historyMessageReferences = new Set(historyMessages); + const pendingMessages = mergedMessages + .filter((message) => !historyMessageReferences.has(message)) + .sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0)); + // Conversation persistence uses a positional cursor. Keep durable history + // as one prefix so newly observed replies cannot sort ahead of that cursor + // and be skipped during hydration. + return [...historyMessages, ...pendingMessages]; +} + function gameChatPlayableRevisionIsAfterAuthorization( revision: GameChatPlayableRevision, authorization: GameChatAutoPreviewAuthorization, @@ -575,6 +631,9 @@ export function App({ const gameChatAutoPreviewAttemptedRef = useRef(new Set()); const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); + const gameChatPendingStageRuntimesRef = useRef( + new Map(), + ); const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, @@ -886,6 +945,7 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + gameChatPendingStageRuntimesRef.current.clear(); gameChatCommittedResponseStreamKeysRef.current.clear(); projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); @@ -932,43 +992,207 @@ export function App({ }); } + function flushPendingGameChatStageRecords() { + if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { + return; + } + for (const [ + archiveKey, + pendingRuntime, + ] of gameChatPendingStageRuntimesRef.current) { + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + continue; + } + const progress = buildGameChatProgressEvidence( + pendingRuntime, + agentRuntimeById, + manifest, + ); + if (!progress) { + continue; + } + const text = formatGameChatStageRecord( + pendingRuntime, + progress, + collectGameChatResultImages(manifest), + ); + gameChatArchivedRunKeysRef.current.add(archiveKey); + gameChatPendingStageRuntimesRef.current.delete(archiveKey); + setMessages((current) => { + if ( + current.some( + (message) => message.role === 'assistant' && message.text === text, + ) + ) { + return current; + } + // Conversation hydration can update the saved cursor in the same + // turn as this append. Clamp it to the pre-append list so the new + // stage record remains visible to the persistence effect. + const projectPath = archiveKey.split('\n', 1)[0]; + if (savedConversationProjectPathRef.current === projectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, { role: 'assistant', text }]; + }); + } + } + + function appendGameChatRuntimeEventMessages( + nextProjectPath: string, + runtime: AgentRuntimeState, + ) { + const eventMessages = gameChatRuntimeEventMessages( + runtime, + agentRuntimeById, + ); + if (eventMessages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = eventMessages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + return [...current, ...missingMessages]; + }); + } + + const appendGameChatFinalReplyMessages = useCallback( + (nextProjectPath: string, runtimeResults: AgentRuntimeResult[]) => { + if (!gameChatOnly || runtimeResults.length === 0) { + return; + } + // A professional Runtime is only part of the active game-chat turn when + // it was delegated by the current Project Supervisor run. This prevents + // a stale child Runtime (or a different app mode) from leaking into the + // project transcript after a restart. + const supervisorRunId = + projectSupervisorRuntimeRef.current?.runId ?? + runtimeResults.find( + (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, + )?.state.runId; + if (!supervisorRunId) { + return; + } + const messages = runtimeResults.flatMap((result) => { + const runtime = agentRuntimeStateFromResult(result); + if ( + runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || + runtime.parentRunId !== supervisorRunId + ) { + return []; + } + return gameChatFinalReplyMessages([result.responseStream]); + }); + if (messages.length === 0) { + return; + } + setMessages((current) => { + const currentMessageIds = new Set( + current + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const missingMessages = messages.filter( + (message) => + message.messageId && !currentMessageIds.has(message.messageId), + ); + if (missingMessages.length === 0) { + return current; + } + if (savedConversationProjectPathRef.current === nextProjectPath) { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + current.length, + ); + } + const orderedMissingMessages = [...missingMessages].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); + const nextMessages = [...current, ...orderedMissingMessages]; + latestMessagesRef.current = nextMessages; + return nextMessages; + }); + }, + [gameChatOnly], + ); + function appendGameChatStageRecord( nextProjectPath: string, runtime: AgentRuntimeState, ) { - if (!gameChatOnly || !isAgentRuntimeTerminalState(runtime)) { + if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) { return; } const archiveKey = `${nextProjectPath}\n${runtime.runId}`; - if ( - !gameChatObservedRunKeysRef.current.has(archiveKey) || - gameChatArchivedRunKeysRef.current.has(archiveKey) - ) { + // A restored terminal runtime may be the first runtime snapshot observed + // after the app opens. Do not require a prior non-terminal event: the + // durable runtime/manifest pair is sufficient evidence for the record. + if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - const progress = buildGameChatProgressEvidence( - runtime, - agentRuntimeById, - manifest, - ); - if (!progress) { - return; - } - const text = formatGameChatStageRecord( - runtime, - progress, - collectGameChatResultImages(manifest), - ); - gameChatArchivedRunKeysRef.current.add(archiveKey); - setMessages((current) => - current.some( - (message) => message.role === 'assistant' && message.text === text, - ) - ? current - : [...current, { role: 'assistant', text }], - ); + gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); + flushPendingGameChatStageRecords(); } + useEffect(() => { + const nextProjectPath = localProject?.projectPath; + const runtime = projectSupervisorRuntime; + if (gameChatOnly && nextProjectPath && runtime) { + appendGameChatRuntimeEventMessages(nextProjectPath, runtime); + } + if ( + gameChatOnly && + nextProjectPath && + runtime && + gameChatRuntimeHasTerminalOutcome(runtime) + ) { + // Hydration can restore a terminal root run without delivering a live + // runtime-update event. Feed that snapshot through the same deferred + // archive path used by live terminal updates. A run that was already + // observed in a non-terminal state is archived by its terminal + // conversation refresh instead, avoiding a hydration race with that + // refresh's saved-message cursor. + const archiveKey = `${nextProjectPath}\n${runtime.runId}`; + const refreshKey = `${nextProjectPath}\n${runtime.sessionId}\n${runtime.runId}`; + if ( + !gameChatObservedRunKeysRef.current.has(archiveKey) && + !projectSupervisorRuntimeSyncingRef.current.has(refreshKey) + ) { + appendGameChatStageRecord(nextProjectPath, runtime); + } + } + flushPendingGameChatStageRecords(); + // The terminal Runtime can arrive before the durable manifest refresh. + // Retry when either projection changes, but archive each run only once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + agentRuntimeById, + gameChatOnly, + localProject?.projectPath, + manifest, + projectSupervisorRuntime, + ]); + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -1077,6 +1301,11 @@ export function App({ return; } const nextRuntime = agentRuntimeStateFromResult(payload.runtime); + if (gameChatOnly && payload.agentId !== PROJECT_SUPERVISOR_AGENT_ID) { + appendGameChatFinalReplyMessages(payload.projectPath, [ + payload.runtime, + ]); + } if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { const expectedSessionId = projectSupervisorSessionIdRef.current; const currentRuntime = projectSupervisorRuntimeRef.current; @@ -1157,7 +1386,12 @@ export function App({ disposed = true; cleanup?.(); }; - }, [updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime]); + }, [ + appendGameChatFinalReplyMessages, + gameChatOnly, + updateProjectSupervisorResponseStream, + updateProjectSupervisorRuntime, + ]); useEffect(() => { const invoke = resolveTauriInvoke(); @@ -1299,6 +1533,9 @@ export function App({ ) { return; } + if (gameChatOnly) { + appendGameChatFinalReplyMessages(nextProjectPath, runtimes); + } const nextRuntimes = runtimes.map((runtimeResult) => agentRuntimeStateFromResult(runtimeResult), ); @@ -1772,10 +2009,14 @@ export function App({ { projectPath: nextProjectPath, agentId: null, + ...(message.messageId ? { messageId: message.messageId } : {}), message: { role: message.role, content: message.text, agentId: null, + ...(typeof message.updatedAt === 'number' + ? { updatedAt: message.updatedAt } + : {}), }, }, ); @@ -2502,13 +2743,15 @@ export function App({ // so a committed game-chat response cannot be overwritten by stale // `latestMessagesRef` state captured before that callback ran. const nextMessages = gameChatOnly - ? mergeGameChatRuntimeResponseMessagesIntoHistory( + ? mergeGameChatHydratedConversationMessages( conversationMessages, current, ) : conversationMessages; savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextMessages.length; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextMessages.length; latestMessagesRef.current = nextMessages; return nextMessages; }); @@ -2631,7 +2874,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); setMessages((current) => { const nextConversationMessages = gameChatOnly - ? mergeGameChatRuntimeResponseMessagesIntoHistory( + ? mergeGameChatHydratedConversationMessages( conversationMessages, current, ) @@ -2655,7 +2898,9 @@ export function App({ } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = nextProjectPath; - savedConversationCountRef.current = nextConversationMessages.length; + savedConversationCountRef.current = gameChatOnly + ? conversationMessages.length + : nextConversationMessages.length; latestMessagesRef.current = nextConversationMessages; setWorkspaceStatus((workspaceStatus) => { if (mode === 'replace') { @@ -5427,9 +5672,7 @@ export function App({ prompt, runtime: runtimeAtSubmission, runProfile: submissionRunProfile, - ...(gameChatOnly - ? { source: 'project-supervisor-game-chat' } - : {}), + ...(gameChatOnly ? { source: 'project-supervisor-game-chat' } : {}), }); const runtimeResult = submission.runtimeResult; const acceptedRunId = submission.acceptedRunId.trim(); @@ -9947,6 +10190,7 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -9978,6 +10222,9 @@ export function App({ } agentRuntimeResumeProjectPathRef.current = nextProjectPath; for (const runtimeResult of resumedRuntimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [ + runtimeResult, + ]); rememberAgentRuntimeState( agentRuntimeStateFromResult(runtimeResult), ); @@ -10018,6 +10265,7 @@ export function App({ } const nextRuntimes: AgentRuntimeState[] = []; for (const runtimeResult of runtimes) { + appendGameChatFinalReplyMessages(nextProjectPath, [runtimeResult]); nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } const supervisorRuntimeIndex = nextRuntimes.findIndex( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 4f6cfa6cc..8cec58340 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -312,11 +312,14 @@ export interface AgentRuntimeEventRecord { source: string; runProfile?: 'standard' | 'autonomous-game-build'; runProfileBindingFingerprint?: string; + eventId?: string; + actionId?: string | null; eventType: string; status: string; phase: string; summary: string; detail: string | null; + publicText?: string | null; updatedAt: number; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index fb1421691..7ebbbec4c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { AgentRuntimeEventRecord, + AgentRuntimeResponseStream, AgentRuntimeState, ChatMessage, LocalPreviewResult, @@ -54,6 +55,25 @@ export type GameChatRuntimeEvent = { event: AgentRuntimeEventRecord; }; +const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; +const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; +const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +]); +const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ + 'tool.request', + 'tool.response', + 'tool.result', + 'agent.runtime.tool.request', + 'agent.runtime.tool.response', + 'agent.runtime.tool.result', +]); + export type GameChatProgressEvidence = { key: string; label: string; @@ -289,6 +309,14 @@ function latestEvidenceEvent( return events.find(({ event }) => predicate(event)) ?? null; } +export function formatGameChatRuntimeText(text: string) { + return text.replace(/第\s*\d+\s*轮/gu, '本轮'); +} + +export function formatGameChatRuntimeEvent(event: AgentRuntimeEventRecord) { + return formatGameChatRuntimeText(formatAgentRuntimeEvent(event)); +} + export function buildGameChatProgressEvidence( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, @@ -297,7 +325,16 @@ export function buildGameChatProgressEvidence( if (!runtime?.runId) { return null; } - const tasks = manifest?.tasks ?? []; + const fastPathTaskIds = new Set([ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', + ]); + const tasks = + manifest?.tasks.filter((task) => fastPathTaskIds.has(task.id)) ?? []; const completedTasks = tasks.filter( (task) => task.status === 'completed', ).length; @@ -323,9 +360,6 @@ export function buildGameChatProgressEvidence( .map((professionalRuntime) => { const parts = [ projectProfessionalAgentLabel(professionalRuntime.agentId), - (professionalRuntime.loopIteration ?? 0) > 0 - ? `第 ${professionalRuntime.loopIteration} 轮` - : null, projectRuntimeVisibleCurrentWork(professionalRuntime), ].filter(Boolean); return compactProgressText(parts.join(' · '), 140); @@ -457,21 +491,24 @@ export function buildGameChatProgressEvidence( } return { runId: runtime.runId, - title: - (runtime.loopIteration ?? 0) > 0 - ? `Supervisor 进度播报 · 第 ${runtime.loopIteration} 轮` - : 'Supervisor 进度播报', + // loopIteration is the Runtime's private provider/tool loop, not a + // user-visible game generation round. A single game-chat turn can require + // several of these loops for delegation, repair and playtest. + title: '本轮生成进度', taskProgress: taskParts.join(' · ') || projectSupervisorChatRuntimeStatus(runtime), - currentWork: compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + currentWork: formatGameChatRuntimeText( + compactProgressText(projectRuntimeVisibleCurrentWork(runtime)), + ), activeAgents, evidence, }; } -export function collectGameChatRuntimeEvents( +function collectGameChatRuntimeEventsInternal( runtime: AgentRuntimeState | null, runtimeByAgentId: Record, + limit: number, ) { const sources = runtime ? [ @@ -492,6 +529,7 @@ export function collectGameChatRuntimeEvents( continue; } const key = [ + event.eventId, event.agentId, event.sessionId, event.runId, @@ -509,7 +547,173 @@ export function collectGameChatRuntimeEvents( } return Array.from(deduplicated.values()) .sort((left, right) => right.event.updatedAt - left.event.updatedAt) - .slice(0, 20); + .slice(0, limit); +} + +export function collectGameChatRuntimeEvents( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal(runtime, runtimeByAgentId, 20); +} + +function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) { + const event = item.event; + const eventType = + typeof event.eventType === 'string' + ? event.eventType.trim().toLowerCase() + : ''; + const rawPublicText = + typeof event.publicText === 'string' ? event.publicText.trim() : ''; + const publicText = formatGameChatRuntimeText( + compactProgressText(rawPublicText, 220), + ); + const eventId = typeof event.eventId === 'string' ? event.eventId.trim() : ''; + if ( + !eventId || + !publicText || + GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) + ) { + return null; + } + return `${item.agentLabel}:${publicText}`; +} + +export function gameChatRuntimeEventMessages( + runtime: AgentRuntimeState | null, + runtimeByAgentId: Record, +) { + return collectGameChatRuntimeEventsInternal( + runtime, + runtimeByAgentId, + Number.MAX_SAFE_INTEGER, + ) + .sort((left, right) => { + const updatedAtDelta = left.event.updatedAt - right.event.updatedAt; + return updatedAtDelta !== 0 + ? updatedAtDelta + : left.key.localeCompare(right.key); + }) + .flatMap((item) => { + const text = gameChatRuntimeEventMessageText(item); + const eventId = + typeof item.event.eventId === 'string' ? item.event.eventId.trim() : ''; + if (!text) { + return []; + } + return [ + { + role: 'assistant' as const, + text, + messageId: `${GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX}${eventId}`, + agentId: null, + updatedAt: item.event.updatedAt, + }, + ]; + }); +} + +/** + * Convert a professional Agent's durable final-reply stream into one normal + * project-chat message. Tool plans and in-flight streams intentionally stay + * out of the conversation: the chat is a user-facing transcript, not a + * Runtime protocol log. + */ +export function gameChatFinalReplyMessages( + streams: Array, +) { + return streams.flatMap((stream) => { + const accumulatedText = + typeof stream?.accumulatedText === 'string' + ? stream.accumulatedText.trim() + : ''; + if ( + !stream || + !GAME_CHAT_FINAL_REPLY_AGENT_IDS.has(stream.agentId) || + stream.requestKind !== 'final-reply' || + !['ready', 'committed'].includes(stream.status) || + !accumulatedText + ) { + return []; + } + const streamIdentity = [ + stream.sessionId, + stream.runId, + stream.requestSlot, + stream.responseRevision, + ].join('\u001f'); + return [ + { + role: 'assistant' as const, + text: `${projectProfessionalAgentLabel(stream.agentId)}:${accumulatedText}`, + messageId: `${GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX}${encodeURIComponent( + `${stream.agentId}\u001f${streamIdentity}`, + )}`, + agentId: stream.agentId, + updatedAt: stream.updatedAt, + }, + ]; + }); +} + +export function mergeGameChatFinalReplyMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const repliesToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (repliesToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...repliesToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); +} + +export function mergeGameChatRuntimeEventMessagesIntoHistory( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +) { + const historyMessageIds = new Set( + historyMessages + .map((message) => message.messageId?.trim()) + .filter((messageId): messageId is string => Boolean(messageId)), + ); + const addedMessageIds = new Set(); + const eventsToKeep = currentMessages.filter((message) => { + const messageId = message.messageId?.trim(); + if ( + !messageId?.startsWith(GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX) || + historyMessageIds.has(messageId) || + addedMessageIds.has(messageId) + ) { + return false; + } + addedMessageIds.add(messageId); + return true; + }); + if (eventsToKeep.length === 0) { + return historyMessages; + } + return [...historyMessages, ...eventsToKeep].sort( + (left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0), + ); } type SupervisorChatOnlyViewProps = { @@ -633,7 +837,10 @@ export function SupervisorChatOnlyView({ : runtimeEvents.slice(0, 4); const supervisorProgress = useMemo( () => - gameChatMode && projectReady + gameChatMode && + projectReady && + runtime && + !isAgentRuntimeTerminalState(runtime) ? buildGameChatProgressEvidence(runtime, runtimeByAgentId, manifest) : null, [gameChatMode, manifest, projectReady, runtime, runtimeByAgentId], @@ -781,7 +988,7 @@ export function SupervisorChatOnlyView({ {visibleRuntimeEvents.map((item) => ( {item.agentLabel} - {formatAgentRuntimeEvent(item.event)} + {formatGameChatRuntimeEvent(item.event)} ))}
diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 560753615..7555d2a6d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -466,11 +466,18 @@ function createProjectSupervisorRuntimeHarness({ role: 'user' | 'assistant'; content: string; agentId: null; + updatedAt?: number; }; currentProjectMessages.push({ schemaVersion: 'game-creator-conversation.v1', ...message, - updatedAt: 1500 + ++messageSequence, + ...(args?.messageId + ? { messageId: String(args.messageId) } + : {}), + updatedAt: + typeof message.updatedAt === 'number' + ? message.updatedAt + : 1500 + ++messageSequence, }); return { path: `${projectPath}/.agent/conversations/project.jsonl`, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 91966f530..f204885be 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -14,6 +14,11 @@ import { buildGameChatProgressEvidence, collectGameChatResultImages, collectGameChatRuntimeEvents, + formatGameChatStageRecord, + gameChatFinalReplyMessages, + gameChatRuntimeEventMessages, + isGameChatStageRecordMessage, + mergeGameChatFinalReplyMessagesIntoHistory, SupervisorChatOnlyView, } from '../../src/features/project-workspace/SupervisorChatOnlyView'; import { @@ -62,6 +67,8 @@ function gameChatRuntimeEvent({ summary, detail = null, updatedAt, + eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`, + publicText = summary, }: { agentId?: string; taskId?: string; @@ -72,6 +79,8 @@ function gameChatRuntimeEvent({ phase?: string; summary: string; detail?: string | null; + eventId?: string; + publicText?: string | null; updatedAt: number; }): AgentRuntimeEventRecord { return { @@ -84,11 +93,13 @@ function gameChatRuntimeEvent({ agentId === 'project-supervisor' ? 'project-supervisor' : 'agent-delegate', + eventId, eventType, status, phase, summary, detail, + publicText, updatedAt, }; } @@ -121,6 +132,19 @@ function gameChatRuntimeState( }; } +const GAME_CHAT_STAGE_TASK_IDS = [ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + 'preview-playtest', +] as const; + +function isGameChatStageTask(taskId: string) { + return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId); +} + function gameChatPreviewPlaytestRuntime({ parentRunId, revision, @@ -171,9 +195,11 @@ function gameChatPreviewPlaytestRuntime({ function renderGameChatStatus({ runtime, runtimeByAgentId = {}, + manifest = null, }: { runtime: AgentRuntimeState; runtimeByAgentId?: Record; + manifest?: ReturnType | null; }) { return render( React.createElement(SupervisorChatOnlyView, { @@ -203,6 +229,7 @@ function renderGameChatStatus({ workspaceStatus: '已打开', gameChatMode: true, runtimeByAgentId, + manifest, projectReady: true, }), ); @@ -2287,6 +2314,7 @@ export function registerProjectSupervisorSurfaceTests() { it('keeps the non-empty folder confirmation when game-chat initializes a picked project', async () => { const projectPath = '/tmp/game-chat-non-empty'; + const initialSupervisorMessage = '不要在确认前启动这一轮'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -2326,6 +2354,7 @@ export function registerProjectSupervisorSurfaceTests() { React.createElement(App, { projectSupervisorOnly: true, gameChatOnly: true, + initialSupervisorMessage, }), ); @@ -2339,6 +2368,11 @@ export function registerProjectSupervisorSurfaceTests() { 'init_local_game_project', expect.anything(), ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(0); fireEvent.click(within(dialog).getByRole('button', { name: '继续新建' })); await waitFor(() => { @@ -2711,6 +2745,395 @@ export function registerProjectSupervisorSurfaceTests() { ]); }); + it('turns user-visible game-chat runtime outputs into chronological chat messages and filters protocol payloads', () => { + const runId = 'game-chat-runtime-message-run'; + const runtime = gameChatRuntimeState({ + runId, + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'turn.progress', + summary: 'Generated prototype progress', + detail: 'internal loop iteration 4', + updatedAt: 40, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'tool.request', + summary: 'agent.runtime.tool.request', + detail: '{"tool":"agent.delegate","arguments":{"secret":"x"}}', + publicText: null, + updatedAt: 50, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'action', + summary: 'call tool agent.delegate', + detail: 'code agent repair collision', + publicText: 'code agent repair collision', + updatedAt: 60, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + summary: 'command.output_read:ok', + detail: '{"output":"private process output"}', + publicText: null, + updatedAt: 70, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + summary: 'preview.validate:ok', + detail: '{"passed":true,"revision":3}', + updatedAt: 80, + }), + gameChatRuntimeEvent({ + runId, + eventType: 'turn.progress', + summary: 'legacy output without stable event identity', + eventId: '', + publicText: 'legacy output without stable event identity', + updatedAt: 90, + }), + ], + }); + + const messages = gameChatRuntimeEventMessages(runtime, {}); + + expect(messages.map((message) => message.updatedAt)).toEqual([40, 60, 80]); + expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('Generated prototype progress'), + expect.stringContaining('code agent repair collision'), + expect.stringContaining('preview.validate:ok'), + ]); + expect(messages.every((message) => message.role === 'assistant')).toBe( + true, + ); + expect( + messages.every((message) => + message.messageId?.startsWith('game-chat-runtime-event:'), + ), + ).toBe(true); + expect(messages.map((message) => message.text).join('\n')).not.toContain( + 'private process output', + ); + expect(messages.map((message) => message.text).join('\n')).not.toContain( + 'agent.runtime.tool.request', + ); + }); + + it('turns only professional final-reply streams into labeled game-chat messages', () => { + const makeStream = ( + agentId: string, + status: 'ready' | 'committed', + accumulatedText: string, + responseRevision = 1, + ) => ({ + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: 'game-chat-final-reply-run', + requestKind: 'final-reply', + requestSlot: `final-reply-loop-1-revision-${responseRevision}`, + appliedSteerCursor: 0, + responseRevision, + sequence: 2, + status, + accumulatedText, + finishReason: 'stop', + startedAt: 100, + updatedAt: 200 + responseRevision, + }); + const messages = gameChatFinalReplyMessages([ + makeStream('design-director', 'committed', '玩法方向已完成'), + makeStream('art-director', 'ready', '视觉方向已完成'), + makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'), + makeStream('code-director', 'ready', '程序方案已完成'), + makeStream('code-prototype', 'ready', '代码原型已完成'), + makeStream('preview-readiness', 'committed', '预览就绪检查已完成'), + makeStream('preview-playtest', 'ready', '试玩验证已完成'), + { + ...makeStream('code-prototype', 'ready', 'tool plan should be hidden'), + requestKind: 'tool-plan', + }, + ]); + expect(messages).toHaveLength(6); + expect(messages.map((message) => message.text)).toEqual([ + expect.stringContaining('玩法方向已完成'), + expect.stringContaining('视觉方向已完成'), + expect.stringContaining('程序方案已完成'), + expect.stringContaining('代码原型已完成'), + expect.stringContaining('预览就绪检查已完成'), + expect.stringContaining('试玩验证已完成'), + ]); + expect(messages[0]?.messageId).toContain('design-director'); + expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); + expect(messages.every((message) => message.agentId)).toBe(true); + const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( + [messages[0]!], + messages, + ); + expect( + hydrated.filter( + (message) => message.messageId === messages[0]?.messageId, + ), + ).toHaveLength(1); + }); + + it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => { + const projectPath = '/tmp/game-chat-final-reply-hydration'; + const runId = 'game-chat-final-reply-hydration-run'; + const rootRuntime = gameChatRuntimeState({ + sessionId: 'supervisor-session-active', + runId, + status: 'running', + phase: 'execution', + updatedAt: 100, + }); + const makeRuntimeResult = ( + agentId: string, + status: 'ready' | 'committed', + text: string, + updatedAt: number, + ) => { + const state = gameChatRuntimeState({ + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: `${agentId}-${runId}`, + source: 'agent-delegate', + parentAgentId: 'project-supervisor', + parentRunId: runId, + status: 'completed', + phase: 'completed', + updatedAt, + }); + return { + state, + sessionPath: `${projectPath}/.agent/runtime/${agentId}.json`, + eventPath: `${projectPath}/.agent/runtime/${agentId}.jsonl`, + responseStream: { + schemaVersion: 'game-creator-runtime-response-stream.v1', + agentId, + taskId: agentId, + sessionId: 'supervisor-session-active', + runId: state.runId, + requestKind: 'final-reply', + requestSlot: 'final-reply-loop-1-revision-1', + appliedSteerCursor: 0, + responseRevision: 1, + sequence: 2, + status, + accumulatedText: text, + finishReason: 'stop', + startedAt: updatedAt - 20, + updatedAt, + }, + }; + }; + const professionalResults = [ + makeRuntimeResult('design-director', 'committed', '玩法方向已完成', 170), + makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180), + makeRuntimeResult( + 'art-asset-plan', + 'committed', + '平台美术图集已生成并登记', + 190, + ), + makeRuntimeResult('code-director', 'ready', '程序方案已完成', 195), + makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200), + makeRuntimeResult( + 'preview-readiness', + 'committed', + '预览就绪已完成', + 210, + ), + makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220), + ]; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: rootRuntime, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'game-chat-final-reply-hydration', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'read_game_creator_agent_runtimes') { + return professionalResults; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + const renderRelease = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByText(/视觉方向已完成/)).not.toBeNull(); + expect(screen.queryByText(/平台美术图集已生成并登记/)).toBeNull(); + expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); + expect(screen.getByText(/预览就绪已完成/)).not.toBeNull(); + expect(screen.getByText(/试玩验证已完成/)).not.toBeNull(); + }); + const finalReplyAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), + ); + await waitFor(() => { + expect(finalReplyAppends()).toHaveLength(6); + }); + rendered.unmount(); + rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); + }); + expect(finalReplyAppends()).toHaveLength(6); + rendered.unmount(); + }); + + it('persists game-chat runtime event messages with stable ids and does not duplicate them after hydration', async () => { + const projectPath = '/tmp/game-chat-runtime-message-hydration'; + const runId = 'game-chat-runtime-message-hydration-run'; + const events = [ + gameChatRuntimeEvent({ + sessionId: 'supervisor-session-active', + runId, + eventType: 'turn.progress', + summary: 'First visible runtime output', + updatedAt: 40, + }), + gameChatRuntimeEvent({ + sessionId: 'supervisor-session-active', + runId, + eventType: 'observation', + summary: 'Second visible runtime output', + updatedAt: 50, + }), + ]; + const manifest = createGameCreationAppManifest( + 'game-chat-runtime-message-hydration', + 'game-chat-runtime-message-hydration', + ); + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: { + sessionId: 'supervisor-session-active', + runId, + status: 'running', + phase: 'execution', + recentEvents: events, + updatedAt: 60, + }, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + const renderApp = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderApp(); + const runtimeEventAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), + ); + + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(2); + }); + expect( + screen + .getAllByText(/First visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + screen + .getAllByText(/Second visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ), + ).toHaveLength(0); + + rendered.unmount(); + rendered = renderApp(); + await waitFor(() => { + expect( + screen + .getAllByText(/First visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + expect( + screen + .getAllByText(/Second visible runtime output/) + .some((element) => element.tagName === 'P'), + ).toBe(true); + }); + expect(runtimeEventAppends()).toHaveLength(2); + rendered.unmount(); + }); + it('keeps an unstructured image inspection neutral instead of presenting tool success as visual approval', () => { const runId = 'game-chat-unstructured-image-inspection'; const runtime = gameChatRuntimeState({ @@ -3127,7 +3550,54 @@ export function registerProjectSupervisorSurfaceTests() { ).toBe('true'); }); - it('updates one runtime-owned game-chat Supervisor progress broadcast in place without conversation writes', async () => { + it('hides internal loop iteration wording from game-chat latest status events', () => { + const runtime = gameChatRuntimeState({ + runId: 'game-chat-loop-wording-run', + recentEvents: [ + gameChatRuntimeEvent({ + runId: 'game-chat-loop-wording-run', + eventType: 'turn.progress', + summary: 'Agent 已形成第 8 轮有效进度', + detail: '生成 Agent 工具计划(第 9 轮)', + updatedAt: 9000, + }), + ], + }); + + renderGameChatStatus({ runtime }); + + const statusCard = screen.getByLabelText('最新状态'); + expect(document.body.textContent).not.toMatch(/第\s*\d+\s*轮/u); + expect(statusCard.textContent).toContain('Agent 已形成本轮有效进度'); + expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)'); + }); + + it('counts only the six first-playable tasks in game-chat progress', () => { + const manifest = createGameCreationAppManifest( + 'game-chat-progress-total', + 'game-chat-progress-total', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const runtime = gameChatRuntimeState({ + runId: 'game-chat-progress-total-run', + status: 'running', + phase: 'execution', + updatedAt: 9000, + }); + + renderGameChatStatus({ runtime, manifest }); + + const progress = screen.getByLabelText('Supervisor 进度播报'); + expect(progress.textContent).toContain('任务图 6/6'); + expect(progress.textContent).not.toContain('publish-strategy'); + expect(progress.textContent).not.toContain('publish-package'); + }); + + it('keeps one live progress card while persisting every public game-chat output as a message', async () => { const projectPath = '/tmp/game-chat-progress-broadcast'; const supervisorRunId = 'game-chat-progress-run'; const previewFailure = gameChatRuntimeEvent({ @@ -3209,8 +3679,10 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-progress-broadcast', 'game-chat-progress-broadcast', ); - manifest.tasks = manifest.tasks.map((task, index) => { - if (index < 3) { + manifest.tasks = manifest.tasks.map((task) => { + if ( + ['design-director', 'art-director', 'code-director'].includes(task.id) + ) { return { ...task, status: 'completed' as const }; } if (task.id === 'code-prototype') { @@ -3297,20 +3769,15 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); expect(progress.getAttribute('data-runtime-owned')).toBe('true'); expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId); + expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); + expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); expect( - within(progress).getByText('Supervisor 进度播报 · 第 4 轮'), - ).not.toBeNull(); - expect( - within(progress).getByText( - `任务图 3/${manifest.tasks.length} · 进行中 1 · 计划 1/3`, - ), + within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 1/3'), ).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); expect( - within(progress).getByText( - '程序原型 Agent · 第 2 轮 · 修复角色碰撞与重开逻辑', - ), + within(progress).getByText('程序原型 Agent · 修复角色碰撞与重开逻辑'), ).not.toBeNull(); expect(within(progress).getByText('试玩未通过')).not.toBeNull(); expect( @@ -3318,11 +3785,16 @@ export function registerProjectSupervisorSurfaceTests() { 'revision 7 · 诊断 2 项 · 角色仍会穿过右侧墙体 · 失败后重开按钮没有响应', ), ).not.toBeNull(); - expect( + const runtimeEventAppends = () => invoke.mock.calls.filter( - ([command]) => command === 'append_local_conversation_message', - ), - ).toHaveLength(0); + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + String(args?.messageId ?? '').startsWith('game-chat-runtime-event:'), + ); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(1); + }); const delegateDecision = gameChatRuntimeEvent({ runId: supervisorRunId, @@ -3366,13 +3838,10 @@ export function registerProjectSupervisorSurfaceTests() { }); await waitFor(() => { + expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); + expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( - within(progress).getByText('Supervisor 进度播报 · 第 5 轮'), - ).not.toBeNull(); - expect( - within(progress).getByText( - `任务图 3/${manifest.tasks.length} · 进行中 1 · 计划 2/3`, - ), + within(progress).getByText('任务图 3/6 · 进行中 1 · 计划 2/3'), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); expect(within(progress).getByText('返工决定')).not.toBeNull(); @@ -3384,11 +3853,9 @@ export function registerProjectSupervisorSurfaceTests() { }); expect(screen.getByLabelText('Supervisor 进度播报')).toBe(progress); expect(screen.getAllByLabelText('Supervisor 进度播报')).toHaveLength(1); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'append_local_conversation_message', - ), - ).toHaveLength(0); + await waitFor(() => { + expect(runtimeEventAppends()).toHaveLength(2); + }); }); it('renders registered image outcomes in one runtime-owned game-chat Supervisor card', async () => { @@ -3602,8 +4069,10 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record', 'game-chat-stage-record', ); - manifest.tasks = manifest.tasks.map((task, index) => - index < 3 ? { ...task, status: 'completed' as const } : task, + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, ); manifest.assets.push({ id: 'stage-art', @@ -3782,9 +4251,10 @@ export function registerProjectSupervisorSurfaceTests() { }); const stageRecord = await screen.findByText( - /【Supervisor 阶段记录】[\s\S]*第 6 轮 · 本轮已完成/, + /【Supervisor 阶段记录】[\s\S]*本轮生成进度 · 本轮已完成/, ); expect(stageRecord.className).toContain('game-chat-stage-record'); + expect(screen.queryByLabelText('Supervisor 进度播报')).toBeNull(); expect(stageRecord.textContent).toContain('试玩通过:revision 9'); expect(stageRecord.textContent).toContain( '返工决定:根据上一版试玩诊断安排程序 Agent 完成返工', @@ -3820,6 +4290,411 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getByText(/试玩通过:revision 9/)).not.toBeNull(); }); + it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => { + const projectPath = '/tmp/game-chat-stage-record-manifest-race'; + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + const pendingManifest = createGameCreationAppManifest( + 'game-chat-stage-record-manifest-race', + 'game-chat-stage-record-manifest-race', + ); + const terminalManifest = createGameCreationAppManifest( + 'game-chat-stage-record-manifest-race', + 'game-chat-stage-record-manifest-race', + ); + terminalManifest.tasks = terminalManifest.tasks.map((task) => + task.id === 'preview-playtest' + ? { ...task, status: 'failed' as const } + : [ + 'design-director', + 'art-director', + 'code-director', + 'code-prototype', + 'preview-readiness', + ].includes(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + let manifestReady = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'game-chat-stage-record-manifest-race', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest: pendingManifest, + }; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifestReady ? terminalManifest : pendingManifest; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const composer = screen.getByRole('textbox') as HTMLTextAreaElement; + const form = composer.form; + if (!form) { + throw new Error('missing game-chat composer form'); + } + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(composer.disabled).toBe(false); + }); + fireEvent.change(composer, { target: { value: 'manifest race' } }); + fireEvent.submit(form); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command]) => + command === 'start_game_creator_supervisor_runtime_task', + ), + ).toBe(true); + }); + const startCall = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + const runId = String(startCall?.[1]?.runId ?? ''); + expect(runId).not.toBe(''); + act(() => { + harness.emitRuntime( + harness.runtimeState({ + runId, + status: 'failed', + phase: 'failed', + currentAction: 'preview-playtest failed', + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + status: 'failed', + phase: 'tool-observation', + summary: 'preview.validate failed', + detail: JSON.stringify({ + diagnosticsCount: 1, + passed: false, + playtestPassed: false, + revision: 9, + }), + updatedAt: 9100, + }), + ], + updatedAt: 9200, + }), + ); + }); + + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(0); + }); + + manifestReady = true; + fireEvent.change(composer, { target: { value: '/tasks' } }); + fireEvent.submit(form); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.commandId === 'task.list', + ), + ).toBe(true); + }); + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(1); + }); + const stageRecord = String( + (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) + ?.message?.content ?? '', + ); + expect(stageRecord).toContain('5/6'); + }); + + it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { + const projectPath = '/tmp/game-chat-stage-record-initial-terminal'; + const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + const manifest = createGameCreationAppManifest( + 'game-chat-stage-record-initial-terminal', + 'game-chat-stage-record-initial-terminal', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const runId = 'game-chat-stage-record-initial-terminal-run'; + const terminalRuntime = harness.runtimeState({ + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + recentEvents: [ + gameChatRuntimeEvent({ + runId, + eventType: 'observation', + status: 'completed', + phase: 'tool-observation', + summary: 'preview.validate:ok · 试玩验证已通过', + detail: JSON.stringify({ + diagnosticsCount: 0, + passed: true, + playtestPassed: true, + revision: 11, + }), + updatedAt: 9100, + }), + ], + updatedAt: 9200, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'read_game_creator_agent_runtime') { + return harness.runtimeResult(terminalRuntime); + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(stageRecordAppends()).toHaveLength(1); + }); + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + + // A later refresh/runtime snapshot for the same run must not append again. + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '/tasks' }, + }); + fireEvent.submit( + (screen.getByRole('textbox') as HTMLTextAreaElement).form!, + ); + await waitFor(() => { + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.commandId === 'task.list', + ), + ).toBe(true); + }); + expect(stageRecordAppends()).toHaveLength(1); + }); + + it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => { + const projectPath = '/tmp/game-chat-stage-record-restart-hydration'; + const runId = 'game-chat-stage-record-restart-run'; + const terminalRuntime = gameChatRuntimeState({ + sessionId: 'supervisor-session-active', + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + updatedAt: 9200, + }); + const manifest = createGameCreationAppManifest( + 'game-chat-stage-record-restart-hydration', + 'game-chat-stage-record-restart-hydration', + ); + manifest.tasks = manifest.tasks.map((task) => + isGameChatStageTask(task.id) + ? { ...task, status: 'completed' as const } + : task, + ); + const progress = buildGameChatProgressEvidence( + terminalRuntime, + {}, + manifest, + ); + if (!progress) { + throw new Error('missing terminal game-chat progress fixture'); + } + const historicalStageRecord = formatGameChatStageRecord( + terminalRuntime, + progress, + collectGameChatResultImages(manifest), + ); + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialRuntime: terminalRuntime, + projectMessages: [ + { + role: 'assistant', + content: historicalStageRecord, + agentId: null, + messageId: 'historical-game-chat-stage-record', + updatedAt: 9201, + }, + ], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + return harness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + + const stageRecordAppends = () => + invoke.mock.calls.filter( + ([command, args]) => + command === 'append_local_conversation_message' && + args?.agentId === null && + isGameChatStageRecordMessage( + String( + (args?.message as { content?: string } | undefined)?.content ?? + '', + ), + ), + ); + + await waitFor(() => { + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + }); + expect(stageRecordAppends()).toHaveLength(0); + + // A terminal runtime event can race the hydration refresh. The historical + // record must remain the sole record and must not be appended again. + act(() => { + harness.emitRuntime( + harness.runtimeState({ + sessionId: terminalRuntime.sessionId, + runId, + status: 'completed', + phase: 'completed', + currentAction: 'preview complete', + updatedAt: 9300, + }), + ); + }); + await waitFor(() => { + expect(screen.getAllByText(/【Supervisor 阶段记录】/)).toHaveLength(1); + }); + expect(stageRecordAppends()).toHaveLength(0); + }); + it('starts and displays the first playable game-chat preview exactly once', async () => { const projectPath = '/tmp/game-chat-auto-preview'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); @@ -3978,6 +4853,144 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(1); }); + it('restores an authorized playable game-chat preview after restart without starting it twice', async () => { + const projectPath = '/tmp/game-chat-auto-preview-restart'; + const runId = 'game-chat-auto-preview-restart-run'; + const revision = 7; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialProjectRevision: revision, + initialRuntime: { + sessionId: 'supervisor-session-active', + runId, + status: 'completed', + phase: 'completed', + updatedAt: 9200, + }, + runtimeMapLoader: async () => [ + gameChatPreviewPlaytestRuntime({ + parentRunId: runId, + revision, + updatedAt: 9100, + }), + ], + }); + const manifest = createGameCreationAppManifest( + 'game-chat-auto-preview-restart', + 'game-chat-auto-preview-restart', + ); + manifest.tasks = manifest.tasks.map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + let previewStarted = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: manifest.name, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'init_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_project_revision') { + return { revision }; + } + if (command === 'get_local_game_preview_status') { + return previewStarted + ? { + status: 'running', + url: 'http://127.0.0.1:4327', + port: 4327, + root: `${projectPath}/game`, + } + : { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + expect(args).toEqual({ projectPath, expectedRevision: revision }); + previewStarted = true; + return { + url: 'http://127.0.0.1:4327', + port: 4327, + root: `${projectPath}/game`, + }; + } + return harness.invoke(command, args); + }, + ); + window.localStorage.setItem( + 'genarrative.game-chat.auto-preview-authorization.v2', + JSON.stringify({ + afterRevision: 0, + afterValidatedAt: 0, + authorizationId: 'restored-preview-authorization', + projectPath, + runId, + }), + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: harness.listen }, + }; + + const renderRelease = () => + render( + React.createElement(App, { + initialProjectPath: projectPath, + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + let rendered = renderRelease(); + + await waitFor( + () => { + expect(invoke.mock.calls).toContainEqual( + expect.arrayContaining(['start_local_game_preview']), + ); + }, + { timeout: 5000 }, + ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + expect(screen.getByLabelText('游戏运行')).not.toBeNull(); + expect( + window.localStorage.getItem( + 'genarrative.game-chat.auto-preview-authorization.v2', + ), + ).toBeNull(); + + rendered.unmount(); + rendered = renderRelease(); + await waitFor(() => { + expect(screen.getByLabelText('游戏运行')).not.toBeNull(); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'start_local_game_preview', + ), + ).toHaveLength(1); + rendered.unmount(); + }); + it('refreshes a running game-chat iframe after a later run completes without restarting the preview', async () => { const projectPath = '/tmp/game-chat-preview-revision'; const harness = createProjectSupervisorRuntimeHarness({ projectPath }); @@ -4226,13 +5239,16 @@ export function registerProjectSupervisorSurfaceTests() { const previewStatusReads = driver.invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', ).length; - await waitFor(() => { - expect( - driver.invoke.mock.calls.filter( - ([command]) => command === 'get_local_game_preview_status', - ).length, - ).toBeGreaterThan(previewStatusReads); - }, { timeout: 3000 }); + await waitFor( + () => { + expect( + driver.invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_preview_status', + ).length, + ).toBeGreaterThan(previewStatusReads); + }, + { timeout: 3000 }, + ); expect(driver.readAuthorization()?.authorizationId).toBe( steerAuthorizationId, ); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index fb7760e84..d1d8fd730 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -19,7 +19,7 @@ export async function assertPlanningAndStatusShortcutFlow( screen.getByText(/最近 run 已通过,但当前本地预览未运行。 建议:\/run/), ).not.toBeNull(); expect( - screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/), + screen.getByText(/还有 3 个 ready 任务等待处理。 建议:\/tasks/), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '处理首个风险' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run'); @@ -382,13 +382,13 @@ export async function assertPlanningAndStatusShortcutFlow( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(dependencyMessage.textContent).toContain( - '状态:active 0 / carry 0 / ready 1 / 等待依赖 15', + '状态:active 0 / carry 0 / ready 1 / 等待依赖 13', ); expect(dependencyMessage.textContent).toContain( '美术组 / Asset 生成首版美术素材(art-asset-plan) · 等待:美术组 / Director 确定视觉方向与规范图(art-director);策划组 / Gameplay 确定玩法规格与界面原型(design-foundation)', ); expect(dependencyMessage.textContent).toContain( - '美术组 / Director 确定视觉方向与规范图(art-director) · 等待:策划组 / Director 拆解创作方向(design-director)', + '策划组 / Gameplay 确定玩法规格与界面原型(design-foundation) · 等待:策划组 / Director 拆解创作方向(design-director);美术组 / Director 确定视觉方向与规范图(art-director)', ); expect(dependencyMessage.textContent).toContain( '边界:只整理任务依赖;不读取任务文件;不启动 run;不修改项目', @@ -928,7 +928,7 @@ export async function assertPlanningAndStatusShortcutFlow( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(qaMessage.textContent).toContain('Evaluator:通过 · 质量评审通过'); - expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 1 · 失败 0'); + expect(qaMessage.textContent).toContain('任务:完成 0/16 · ready 3 · 失败 0'); expect(qaMessage.textContent).toContain('静态自检:通过'); expect(qaMessage.textContent).toContain('试玩:待启动预览'); expect(qaMessage.textContent).toContain('产物:3 个'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index a57bb7a96..e8ff42b86 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -107,7 +107,7 @@ export async function assertProjectAndDesignShortcutFlow( ).length; submitChat('/brief'); expect(await screen.findByText(/项目简报:/)).not.toBeNull(); - expect(screen.getByText(/任务:完成 0\/16 · ready 1/)).not.toBeNull(); + expect(screen.getByText(/任务:完成 0\/16 · ready 3/)).not.toBeNull(); expect(screen.getByText(/最近 Run:run-main-shortcut-trace/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '查看下一步' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/next'); @@ -297,7 +297,7 @@ export async function assertProjectAndDesignShortcutFlow( '当前状态:最近 run run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(mvpMessage.textContent).toContain( - '任务:完成 0/16 · ready 1 · 失败 0', + '任务:完成 0/16 · ready 3 · 失败 0', ); expect(mvpMessage.textContent).toContain('预览:未启动'); expect(mvpMessage.textContent).toContain('资产:2 个'); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 0a6b36918..07e5d6a4f 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,9 +7,14 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + isProcessGroupAlive, + preflightExistingVite, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, + runWindowsTaskkill, spawnChild, stopChild, + terminateChildTree, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; @@ -88,6 +93,36 @@ describe('AI 游戏创作配套后端复用门禁', () => { describe('AI 游戏创作启动子进程生命周期', () => { const posixTest = process.platform === 'win32' ? test.skip : test; + test('Linux 进程组只剩僵尸进程时视为已经停止', () => { + const procStats = new Map([ + ['/proc/101/stat', '101 (node worker) Z 1 700 700 0'], + ['/proc/102/stat', '102 (other worker) S 1 701 701 0'], + ]); + const readLinuxGroupAlive = (processGroupId: number) => + readLinuxProcessGroupAlive(processGroupId, { + readdirImpl: () => ['101', '102', 'not-a-pid'], + readFileImpl: (path: string) => { + const stat = procStats.get(path); + if (!stat) { + throw new Error('missing proc stat fixture'); + } + return stat; + }, + }); + const killImpl = vi.fn(); + + expect(readLinuxGroupAlive(700)).toBe(false); + expect(readLinuxGroupAlive(701)).toBe(true); + expect( + isProcessGroupAlive(700, { + platform: 'linux', + killImpl, + readLinuxGroupAlive, + }), + ).toBe(false); + expect(killImpl).toHaveBeenCalledWith(-700, 0); + }); + posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => { const child = spawnChild('genarrative-command-that-does-not-exist', [], { cwd: process.cwd(), @@ -175,4 +210,100 @@ describe('AI 游戏创作启动子进程生命周期', () => { expect(child.kill).toHaveBeenCalledWith('SIGTERM'); }); + + test('Windows 通过 taskkill 收束 Tauri CLI 进程树', async () => { + const child = Object.assign(new EventEmitter(), { + pid: 4821, + exitCode: 1, + signalCode: null, + kill: vi.fn(), + }); + const taskkillImpl = vi.fn(async () => ({ + timedOut: false, + code: 0, + error: null, + })); + + const result = await terminateChildTree(child, { + platform: 'win32', + taskkillImpl, + }); + + expect(taskkillImpl).toHaveBeenCalledWith(4821); + expect(result).toMatchObject({ stopped: true, forced: true }); + }); + + test('Windows taskkill 固定携带 PID、整树和强制参数', async () => { + const taskkill = Object.assign(new EventEmitter(), { + kill: vi.fn(), + }); + const spawnImpl = vi.fn(() => { + queueMicrotask(() => taskkill.emit('exit', 0)); + return taskkill; + }); + + await expect( + runWindowsTaskkill(4821, { spawnImpl, timeoutMs: 100 }), + ).resolves.toMatchObject({ timedOut: false, code: 0, error: null }); + expect(spawnImpl).toHaveBeenCalledWith( + 'taskkill.exe', + ['/PID', '4821', '/T', '/F'], + expect.objectContaining({ shell: false, windowsHide: true }), + ); + }); +}); + +describe('AI 游戏创作 3080 启动前预检', () => { + const agcHtml = { + statusCode: 200, + body: 'AI 游戏创作', + }; + + test('旧 Vite marker 指向其它 API 时在启动后端前失败', async () => { + await expect( + preflightExistingVite({ + readServer: async () => agcHtml, + portListening: async () => true, + readMarker: async () => ({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + apiTarget: 'http://127.0.0.1:10001', + }), + }), + ).rejects.toThrow( + 'API target http://127.0.0.1:10001. Its owning worktree cannot be proven', + ); + }); + + test('marker target 看似匹配时仍拒绝复用无法证明归属的 Vite', async () => { + await expect( + preflightExistingVite({ + readServer: async () => agcHtml, + portListening: async () => true, + readMarker: async () => ({ + schemaVersion: 1, + app: 'ai-game-creator-shell', + apiTarget: 'http://127.0.0.1:10004', + }), + }), + ).rejects.toThrow('Its owning worktree cannot be proven'); + }); + + test('HTTP 探测无响应但端口已监听时失败关闭', async () => { + await expect( + preflightExistingVite({ + readServer: async () => null, + portListening: async () => true, + }), + ).rejects.toThrow('non-HTTP or unrecognized server'); + }); + + test('3080 未监听时允许继续启动', async () => { + await expect( + preflightExistingVite({ + readServer: async () => null, + portListening: async () => false, + }), + ).resolves.toEqual({ status: 'available', apiTarget: '' }); + }); }); diff --git a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts new file mode 100644 index 000000000..86a4ba18f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -0,0 +1,219 @@ +import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, test, vi } from 'vitest'; + +import { + isProcessGroupAlive, + spawnChild, + terminateChildTree, +} from '../scripts/start-dev-stack.mjs'; +import { + buildTauriArguments, + runTauriDev, +} from '../scripts/start-tauri-dev.mjs'; + +async function waitForFile(path: string, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) { + return; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error(`等待测试进程标记超时: ${path}`); +} + +describe('AI 游戏创作 Tauri dev 启动参数', () => { + test('普通 dev 参数原样交给 Tauri CLI', () => { + expect(buildTauriArguments(['--no-watch'])).toEqual(['dev', '--no-watch']); + }); + + test('game-chat 参数进入应用参数区且保留项目参数', () => { + expect( + buildTauriArguments([ + '--game-chat', + '--project-path', + '/tmp/example-game', + ]), + ).toEqual([ + 'dev', + '--', + '--', + '--game-chat', + '--project-path', + '/tmp/example-game', + ]); + }); +}); + +describe('AI 游戏创作 Tauri dev 生命周期', () => { + test('3080 预检失败时不启动 Tauri CLI', async () => { + const spawnCli = vi.fn(); + + await expect( + runTauriDev([], { + preflight: async () => { + throw new Error('stale 3080'); + }, + spawnCli, + }), + ).rejects.toThrow('stale 3080'); + + expect(spawnCli).not.toHaveBeenCalled(); + }); + + test('预检先于 CLI 启动且 CLI 退出后始终清理进程树', async () => { + const order: string[] = []; + const child = Object.assign(new EventEmitter(), { + pid: 1234, + exitCode: 1, + signalCode: null, + kill: vi.fn(), + }); + const result = await runTauriDev([], { + preflight: async () => { + order.push('preflight'); + }, + spawnCli: () => { + order.push('spawn'); + return child; + }, + waitForCli: async () => { + order.push('exit'); + return { type: 'exit', code: 1, signal: null }; + }, + terminateTree: async (receivedChild) => { + expect(receivedChild).toBe(child); + order.push('cleanup'); + return { stopped: true, forced: false }; + }, + }); + + expect(result).toBe(1); + expect(order).toEqual(['preflight', 'spawn', 'exit', 'cleanup']); + }); + + const posixTest = process.platform === 'win32' ? test.skip : test; + + posixTest('Tauri CLI leader 先退出后仍收束同 PGID 的客户端后代', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-tree-')); + const readyPath = join(tempDir, 'client-ready'); + const stoppedPath = join(tempDir, 'client-stopped'); + const descendantSource = ` + const { writeFileSync } = require('node:fs'); + const [readyPath, stoppedPath] = process.argv.slice(1); + process.on('SIGTERM', () => { + writeFileSync(stoppedPath, 'stopped'); + process.exit(0); + }); + writeFileSync(readyPath, 'ready'); + setInterval(() => {}, 1000); + `; + const leaderSource = ` + const { existsSync } = require('node:fs'); + const { spawn } = require('node:child_process'); + const [readyPath, stoppedPath, descendantSource] = process.argv.slice(1); + const descendant = spawn( + process.execPath, + ['-e', descendantSource, readyPath, stoppedPath], + { stdio: 'ignore' }, + ); + descendant.unref(); + const timer = setInterval(() => { + if (existsSync(readyPath)) { + clearInterval(timer); + process.exit(42); + } + }, 10); + `; + let cliChild; + try { + const result = await runTauriDev([], { + preflight: async () => {}, + spawnCli: () => { + cliChild = spawnChild( + process.execPath, + ['-e', leaderSource, readyPath, stoppedPath, descendantSource], + { cwd: process.cwd() }, + ); + return cliChild; + }, + }); + + expect(result).toBe(42); + await waitForFile(stoppedPath); + } finally { + if (Number.isInteger(cliChild?.pid)) { + try { + process.kill(-cliChild.pid, 'SIGKILL'); + } catch { + // 进程组已经由启动器收束。 + } + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + posixTest('客户端后代忽略 TERM 时在有界宽限后升级 KILL', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'agc-tauri-force-tree-')); + const readyPath = join(tempDir, 'client-ready'); + const descendantSource = ` + const { writeFileSync } = require('node:fs'); + const [readyPath] = process.argv.slice(1); + process.on('SIGTERM', () => {}); + writeFileSync(readyPath, 'ready'); + setInterval(() => {}, 1000); + `; + const leaderSource = ` + const { existsSync } = require('node:fs'); + const { spawn } = require('node:child_process'); + const [readyPath, descendantSource] = process.argv.slice(1); + const descendant = spawn( + process.execPath, + ['-e', descendantSource, readyPath], + { stdio: 'ignore' }, + ); + descendant.unref(); + const timer = setInterval(() => { + if (existsSync(readyPath)) { + clearInterval(timer); + process.exit(42); + } + }, 10); + `; + let cliChild; + try { + const result = await runTauriDev([], { + preflight: async () => {}, + spawnCli: () => { + cliChild = spawnChild( + process.execPath, + ['-e', leaderSource, readyPath, descendantSource], + { cwd: process.cwd() }, + ); + return cliChild; + }, + terminateTree: (child) => + terminateChildTree(child, { + gracefulTimeoutMs: 50, + forceTimeoutMs: 2000, + }), + }); + + expect(result).toBe(42); + expect(isProcessGroupAlive(cliChild.pid)).toBe(false); + } finally { + if (Number.isInteger(cliChild?.pid)) { + try { + process.kill(-cliChild.pid, 'SIGKILL'); + } catch { + // 进程组已经由启动器强制收束。 + } + } + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile index ef72a30a9..37ed259e6 100644 --- a/deploy/container/api-server.Dockerfile +++ b/deploy/container/api-server.Dockerfile @@ -2,6 +2,8 @@ FROM rust:1.93-bookworm AS rust-builder WORKDIR /workspace COPY server-rs ./server-rs +COPY docs/openapi ./docs/openapi +COPY .codex/skills/genarrative-external-editor-api ./.codex/skills/genarrative-external-editor-api COPY public ./public RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \ cp server-rs/target/release/api-server /tmp/api-server diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index e47007e3d..0d7a2692a 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "陶泥儿外部编辑器 OpenAPI", "version": "1.0.0", - "description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。\n\n兼容性说明:v1 当前处于无外部存量调用方阶段,正式对外发放 API Key 之前,契约可能在不升 info.version、不设弃用期的情况下发生包含字段移除在内的破坏性变更。生成客户端时请勿假定本文档已冻结。" + "description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。全部生成 POST 都是异步提交:必须携带 Idempotency-Key,收到 202 后使用 operationId 查询统一生成状态。支持远程 MCP 的 Agent 可连接 /api/external/v1/mcp;不支持 MCP 的 Agent 可从 /api/external/v1/skill.zip 下载完整 Skill 包。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。\n\n兼容性说明:v1 当前处于无外部存量调用方阶段,正式对外发放 API Key 之前,契约可能在不升 info.version、不设弃用期的情况下发生包含字段移除在内的破坏性变更。生成客户端时请勿假定本文档已冻结。" }, "servers": [ { @@ -39,6 +39,10 @@ { "name": "Editor Audio", "description": "编辑器音效与音乐生成" + }, + { + "name": "Agent Integration", + "description": "远程 MCP、OpenAPI 和完整 Skill 包发现" } ], "paths": { @@ -64,6 +68,137 @@ } } }, + "/api/external/v1/agent-integration.json": { + "get": { + "tags": [ + "Agent Integration" + ], + "operationId": "getExternalAgentIntegrationManifest", + "summary": "读取 Agent 集成清单", + "description": "返回远程 MCP、OpenAPI、Skill 入口、完整 Skill ZIP、包内文件列表和归档 SHA-256。", + "security": [], + "x-mcp-excluded": true, + "responses": { + "200": { + "description": "Agent 集成清单", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/external/v1/skill/SKILL.md": { + "get": { + "tags": [ + "Agent Integration" + ], + "operationId": "getExternalEditorSkillEntry", + "summary": "读取外部编辑器 Skill 入口", + "security": [], + "x-mcp-excluded": true, + "responses": { + "200": { + "description": "SKILL.md", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/external/v1/skill.zip": { + "get": { + "tags": [ + "Agent Integration" + ], + "operationId": "downloadExternalEditorSkillArchive", + "summary": "下载完整外部编辑器 Skill 包", + "security": [], + "x-mcp-excluded": true, + "responses": { + "200": { + "description": "包含 SKILL.md、references、scripts 和 agents metadata 的 ZIP", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/api/external/v1/mcp": { + "post": { + "tags": [ + "Agent Integration" + ], + "operationId": "callExternalEditorMcp", + "summary": "调用托管式远程 MCP", + "description": "MCP 2025-11-25 Streamable HTTP JSON 端点。使用与 REST API 相同的 Bearer API Key;生成工具立即返回异步 operation。resources/list 和 resources/read 提供 usage、OpenAPI、Skill 主入口以及 capability routing、API operations、authentication and safety、requests and outputs 四篇渐进式 reference;本地脚本仍只通过完整 Skill ZIP 提供。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "x-mcp-excluded": true, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "MCP JSON-RPC 响应", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "202": { + "description": "MCP notification 已接受" + }, + "401": { + "description": "缺少、格式错误或无法验证 Bearer API Key。返回 WWW-Authenticate 以及机器可读的 MCP 鉴权引导,说明 Header 格式、开发者 API Key 创建位置、凭据安全要求和公开 discovery/Skill/OpenAPI 地址;不暴露 tools、resources、owner 或 Key 是否存在。", + "headers": { + "WWW-Authenticate": { + "description": "Bearer 鉴权挑战。", + "schema": { + "type": "string", + "const": "Bearer realm=\"genarrative-external-editor\"" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpAuthenticationGuideResponse" + } + } + } + } + } + } + }, "/api/external/v1/assets/direct-upload-tickets": { "post": { "tags": [ @@ -881,6 +1016,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -892,12 +1032,12 @@ } }, "responses": { - "200": { - "description": "生成结果与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorImageGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -930,6 +1070,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -941,12 +1086,12 @@ } }, "responses": { - "200": { - "description": "重绘结果与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorImageGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -978,6 +1123,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -989,12 +1139,12 @@ } }, "responses": { - "200": { - "description": "图标 spritesheet、实际切片结果、可选非阻断告警与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1026,6 +1176,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1037,12 +1192,12 @@ } }, "responses": { - "200": { - "description": "UI 设计图素材 spritesheet、实际切片结果、可选非阻断告警与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1074,6 +1229,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1085,12 +1245,12 @@ } }, "responses": { - "200": { - "description": "角色动画视频预览与抽帧结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorCharacterAnimationGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1122,6 +1282,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1133,12 +1298,12 @@ } }, "responses": { - "200": { - "description": "视频生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorVideoGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1170,6 +1335,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1181,12 +1351,12 @@ } }, "responses": { - "200": { - "description": "音效生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorAudioGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1218,6 +1388,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1229,12 +1404,12 @@ } }, "responses": { - "200": { - "description": "背景音乐生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorAudioGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1253,6 +1428,55 @@ } } } + }, + "/api/external/v1/generations/{operationId}": { + "get": { + "tags": [ + "Editor Generations" + ], + "operationId": "getExternalEditorGenerationJob", + "summary": "查询异步生成任务", + "description": "queued/running 时返回进度,completed 时返回 compact 稳定结果引用,failed 时返回脱敏错误。跨账号任务按不存在处理。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "生成任务状态及可选结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorGenerationJobResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } + } } }, "components": { @@ -1287,6 +1511,18 @@ "schema": { "type": "string" } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "本次逻辑生成请求的稳定幂等键;网络结果不确定时必须复用原值,不得换键重提。", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!-~]+$" + } } }, "responses": { @@ -2516,6 +2752,198 @@ "JsonValue": { "description": "任意 JSON 值。" }, + "McpAuthenticationGuideResponse": { + "type": "object", + "required": [ + "error", + "meta" + ], + "additionalProperties": false, + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message", + "details" + ], + "additionalProperties": false, + "properties": { + "code": { + "const": "UNAUTHORIZED" + }, + "message": { + "const": "连接陶泥儿托管 MCP 需要开发者 API Key" + }, + "details": { + "type": "object", + "required": [ + "guide" + ], + "additionalProperties": false, + "properties": { + "guide": { + "type": "object", + "required": [ + "reason", + "action", + "authentication", + "keyManagement", + "retry", + "steps", + "credentialSafety", + "publicDiscovery" + ], + "additionalProperties": false, + "properties": { + "reason": { + "const": "MCP_AUTHENTICATION_REQUIRED" + }, + "action": { + "const": "CONFIGURE_BEARER_API_KEY" + }, + "authentication": { + "type": "object", + "required": [ + "scheme", + "header", + "valueFormat" + ], + "additionalProperties": false, + "properties": { + "scheme": { + "const": "Bearer" + }, + "header": { + "const": "Authorization" + }, + "valueFormat": { + "const": "Bearer " + } + } + }, + "keyManagement": { + "type": "object", + "required": [ + "navigationLabel", + "rawKeyShownOnce" + ], + "additionalProperties": false, + "properties": { + "navigationLabel": { + "const": "开发者 API Key" + }, + "rawKeyShownOnce": { + "const": true + } + } + }, + "retry": { + "type": "object", + "required": [ + "method", + "path", + "rpcMethod" + ], + "additionalProperties": false, + "properties": { + "method": { + "const": "POST" + }, + "path": { + "const": "/api/external/v1/mcp" + }, + "rpcMethod": { + "const": "initialize" + } + } + }, + "steps": { + "type": "array", + "items": { + "type": "string" + } + }, + "credentialSafety": { + "type": "object", + "required": [ + "rawKeyShownOnce", + "neverPasteIntoChat", + "neverStoreInRepository" + ], + "additionalProperties": false, + "properties": { + "rawKeyShownOnce": { + "const": true + }, + "neverPasteIntoChat": { + "const": true + }, + "neverStoreInRepository": { + "const": true + } + } + }, + "publicDiscovery": { + "type": "object", + "required": [ + "manifest", + "skill", + "openapi" + ], + "additionalProperties": false, + "properties": { + "manifest": { + "const": "/api/external/v1/agent-integration.json" + }, + "skill": { + "const": "/api/external/v1/skill/SKILL.md" + }, + "openapi": { + "const": "/api/external/v1/openapi.json" + } + } + } + } + } + } + } + } + }, + "meta": { + "type": "object", + "required": [ + "apiVersion", + "routeVersion", + "latencyMs", + "timestamp" + ], + "additionalProperties": false, + "properties": { + "apiVersion": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "routeVersion": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "latencyMs": { + "type": "integer", + "minimum": 0 + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ErrorResponse": { "type": "object", "properties": { @@ -4037,6 +4465,104 @@ } } }, + "ExternalEditorGenerationSubmissionResponse": { + "type": "object", + "required": [ + "operationId", + "kind", + "status", + "statusUrl", + "pollAfterMs", + "updatedAtMicros" + ], + "properties": { + "operationId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, + "statusUrl": { + "type": "string" + }, + "pollAfterMs": { + "type": "integer", + "minimum": 250 + }, + "updatedAtMicros": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "ExternalEditorGenerationJobResponse": { + "type": "object", + "required": [ + "operationId", + "kind", + "status", + "phaseLabel", + "phaseDetail", + "progress", + "updatedAtMicros" + ], + "properties": { + "operationId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, + "phaseLabel": { + "type": "string" + }, + "phaseDetail": { + "type": "string" + }, + "progress": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "error": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "result": { + "type": "object", + "description": "completed 时返回的 compact 稳定结果引用;不包含完整 project/canvas、Data URL、Blob URL 或临时签名 URL。", + "additionalProperties": true + }, + "pollAfterMs": { + "type": "integer", + "minimum": 250 + }, + "updatedAtMicros": { + "type": "integer" + } + }, + "additionalProperties": false + }, "ExternalGenerationJobStatusRecord": { "type": "object", "required": [ diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c94135484..0c8bd3164 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -190,6 +190,15 @@ - 验证方式:以当前 run 成功 `preview.validate` revision N 后断言 iframe 自动出现且 server 归 Tauri registry;再完成 revision N+1,断言 server 进程和 loopback origin 不变、iframe 重新加载新内容且所有响应为 `no-store`。Runner registry 单独 running 不得让页面显示预览;相同 / 更低 revision 不得刷新;停止预览后顶部必须显示“预览未启动”;构建产物和安装信息必须为 `0.1.1`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 +## 2026-08-03 开放 Issue 115、118、127、128 的修复边界 + +- AGC 的 MCP 目录以 server 为隔离单元:可选 server 的连接、tools/list、工具归一化或聚合容量失败只关闭该 server,required server 仍失败关闭;MCP schema 包入原生 action 后只沿 subschema 关键词重定位当前 document 根的 JSON Pointer fragment,`default / const / examples / enum` 等数据值、命名 anchor 与 `$id` resource 内 fragment 保持不变。Anthropic strict 不由 `apiKind` 单独推断:AGC 只对官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成专用传输 schema,剔除不受支持的约束但不修改调用方原 schema;未知关键词、不可解析 / 递归 `$ref` 或请求复杂度超限时保持 non-strict。最后一个工具设置 ephemeral prompt-cache breakpoint;usage 统计把 cache creation / read token 一并计入 prompt 和 total。 +- Windows 私有 ACL 检查复用 `Get-Item` 对象的 `GetAccessControl()`,避免从 PowerShell 7 启动时继承的模块路径让 Windows PowerShell 5.1 的 `Get-Acl` 加载不兼容模块;静态配置门禁禁止重新引入该命令。 +- 编辑器持久化的 `prompt` 统一表示规范化用户意图;provider `actual_prompt` 只保留在 resource / asset 审计字段,系统 prompt 不进入跨资源检索字段。角色和图标的透明图、切片继承源用户 prompt;本次只修新写入,不迁移历史记录,不修改 SpacetimeDB schema。 +- 画布收到生成完成等较新权威快照时,必须把同项目待保存或在途的本地布局重放到新 revision:后端资源与生成终态优先,本地布局编辑优先;后端新增项合入,后端删除项和用户本地删除项均不得复活,合并后立即进入既有串行 CAS 保存队列。 +- 生成器合并必须把 `status / composerOpen / generatedLayerId / errorMessage / generation timestamps / characterAnimationResult` 视为后端生命周期事实;生成完成快照继续保持 `composerOpen=false`,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。 +- 同项目权威快照刷新不得无条件选择第一张图层:当前仍有效的单选、多选和生成占位选择保持,已删除的选择过滤,本来未选择时保持空选;只有首次载入或切换到另一项目时才默认选择第一张可用图层。生成完成结果需要用户显式点击后才进入选中态,后台完成回包不能偷走用户当前焦点。 + ## 2026-07-30 Provider 503 等待与耗尽状态使用严格字段派生的安全摘要 - 背景:game-chat 的进度卡只显示“等待 Provider upstream-5xx 瞬态故障退避到期”,没有 HTTP 状态、重试次数或等待时间;重试耗尽后,Runtime 和持久 conversation 又可能直接展示 `fingerprint/chars` 或 ` [redacted sensitive context]`,用户既无法判断是否在恢复,也看不到可操作的失败原因。 @@ -5490,8 +5499,8 @@ - 复用决策:窗口固定使用 `project-supervisor + autonomous-game-build`,复用 active Session、External Runner、持久 conversation、确认 / 追问链路和共享 `PreviewRegistry`;不新增玩法入口、后端 API、会话库、Runner 或预览服务。原 `supervisor-chat` 继续使用 `standard` profile 并保持纯聊天语义。 - Run 身份决策:External Runner 接受新任务后,命令响应中的 canonical `state` 允许暂时仍是上一轮 idle,而 `acceptedRunId` 才是新任务的权威身份。GUI 必须暂存该身份并开启轮询、放行对应 Runtime event,直到新 state 接管后再清除;自动预览授权同样绑定 `acceptedRunId`。忽略该字段会造成任务实际运行但界面永久显示“等待输入”。 - 状态决策:页面只聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的事件,稳定去重后默认显示最新 4 条、可展开至 20 条;事件只作状态投影,不写入 conversation。无当前项目的有效 `running` PreviewRegistry 状态时只显示聊天;运行后桌面端显示“游戏 2 / 聊天 1”,移动端上下排列。iframe 只接受当前授权项目的 `http://127.0.0.1:*`,继续复用现有 CSP 和 sandbox 合同,远程 URL、`file://`、手填地址或陈旧 manifest 状态均失败关闭。 -- 进度证据决策:聊天消息流内增加单条 Runtime-owned “Supervisor 进度播报”,从当前 run 的 manifest 任务图、结构化计划、真实 loop、直接委派 Agent 和持久事件确定性派生,聚合迭代轮次、当前工作、活跃专业 Agent、试玩 / 静态测试、返工、代码修改和截图检查。该卡同一 run 原位更新,不调用模型、不写 conversation、不制造额外 assistant 记录;详情有界并移除绝对路径,不展示 Provider 元数据、指纹或原始内部正文。顶部原始事件列表继续保留以便核验。 -- 跨轮记录决策:只有当前 game-chat 窗口确实观察过活跃态的父 run,在其终态正式 conversation 刷新完成后才追加一次 `【Supervisor 阶段记录】` 项目 assistant 消息;内容只保留轮次、任务 / 计划完成度、最新测试、最近返工和成果图片路径。记录按“项目 + 父 run”内存幂等,继续经过 `conversation.write` 策略并写入项目 conversation,因此下一轮和重载后仍可见;已终态旧 run 在窗口启动时不回填,避免重复。该项目记录不进入 Supervisor Agent Session,不改变 Runtime 唯一 final assistant 合同。 +- 进度证据决策:聊天消息流内增加单条 Runtime-owned “Supervisor 进度播报”,从当前 run 的 manifest 任务图、结构化计划、真实 loop、直接委派 Agent 和持久事件确定性派生,聚合迭代轮次、当前工作、活跃专业 Agent、试玩 / 静态测试、返工、代码修改和截图检查。该卡同一 run 原位更新,不调用模型、不写 conversation、不制造额外 assistant 记录;详情有界并移除绝对路径,不展示 Provider 元数据、指纹或原始内部正文。顶部原始事件列表继续保留以便核验。2026-08-01 补充的逐条公开输出是独立的 `eventId + publicText` conversation 消息,不改变该进度卡自身不落盘的约束。 +- 跨轮记录决策:父 run 进入真实 `completed / failed / cancelled` 终态且 `code-prototype / preview-readiness / preview-playtest` 三个阶段全部终态后,追加一次 `【Supervisor 阶段记录】` 项目 assistant 消息;内容只保留本轮、任务 / 计划完成度、最新测试、最近返工和成果图片路径。Runtime 先终态而 manifest 尚未刷新时暂存候选,manifest 刷新后补写;页面启动时若首个可见快照已是终态,也必须补齐缺失记录,但 `idle` 不得被当作真实终态。记录按“项目 + 父 run”内存幂等,继续经过 `conversation.write` 策略并写入项目 conversation,因此下一轮和重载后仍可见。该项目记录不进入 Supervisor Agent Session,不改变 Runtime 唯一 final assistant 合同。 - 图片成果决策:manifest 中已登记的 PNG / JPEG / WebP 项目资源通过既有 `read_local_project_image_preview` 安全读取,并在聊天流中以单张 Runtime-owned “Supervisor 成果图片”卡原位展示,最多 4 张。只接受当前项目 `assets/` 已登记路径及返回身份完全一致的 data URL,不从自然语言或 Markdown 解析任意路径,不读取 `.agent` 验收截图,不写 conversation;切换项目、资源移除、读取失败或解码失败时立即移除图片或显示固定失败状态。缩略图点击进入独立模态查看器,支持 50%–400% 按钮 / 滚轮缩放、指针拖拽、复位和 Esc / 遮罩 / 按钮关闭,移动端全屏,不在聊天消息下方内联展开。 - 授权决策:用户成功提交本轮自主生成需求,即授予“当前项目 + 当前 Supervisor 父 run”一次性 `preview.start`;授权只把项目路径与 accepted parent runId 持久化到客户端本地状态,允许 App / WebView 重启恢复,不新增后端接口。首版产物完成后仍经现有权限、项目写锁、审计与 PreviewRegistry 链路启动;成功、显式 deny、非瞬时失败、父 run 在首版完成前终止或切换项目后消费或清除授权。首版完成投影与专业任务写入并发时,`preview.start` 可能暂时命中项目写锁;该错误不得提前标记“已尝试”或清空授权,应在释放锁后重试,最终仍只成功启动一次。项目或 Agent 策略的显式 deny 始终优先,不因页面授权而降级。 - 验证方式:定向覆盖 debug / release 入口分流、项目选择和切换隔离、父 run 事件聚合、首版只启动一次、deny 优先、预览停止后隐藏、loopback / sandbox 安全以及桌面 / 移动响应式布局。 @@ -5708,7 +5717,7 @@ - 背景:旧创作模板退役时误把新版 `/creation`、桌面公共侧边栏和“我的”完整资料页一起缩减;只恢复视觉后,现役 profile client 又经 `rpg-entry` barrel 把旧作品库、旧 runtime request 和展示模型重新带入 Vite 与 TypeScript 图。 - 决策:桌面端继续使用原平台公共结构,一级导航固定为 `创作 / 项目 / 我的`;顶栏保留编辑器项目 / 素材搜索、泥点入口和账号胶囊;“我的”全宽保留资料编辑、陶泥号、三项统计、充值、兑换码、社区、反馈、通用设置、API Key 和法律信息。搜索只面向编辑器项目与公开编辑器素材,不恢复旧公开作品搜索。 - 依赖边界:公共 dashboard、钱包、充值、兑换码、邀请码、API Key 和设置请求迁入 `services/platform-entry`,公共账单展示迁入现役 profile model。Vite 新增退役模块 graph 门禁,ESLint 对现役源码禁止导入旧目录;目录 watch ignore、Tailwind source、tsconfig include 和 tree-shaking 都不能作为依赖隔离证明。 -- 路由与响应式边界:`/creation`、`/project`、`/profile` 都是可刷新、可前进 / 后退的稳定路由;桌面端使用侧边栏,移动端必须提供同样 `创作 / 项目 / 我的` 的三项底部 dock,不得因隐藏桌面侧边栏而丢失移动导航。 +- 路由与响应式边界(2026-08-03 纠正):`/creation`、`/project`、`/profile` 都是稳定路由,但旧模板退役不授权扩大移动端创作范围。桌面端使用 `创作 / 项目 / 我的` 侧边栏;移动端底部 dock 只保留“我的”,直达 `/creation`、`/project`、`/editor/canvas` 或从首页触发项目 / 画布动作时统一显示桌面端提示,不挂载创作主页、项目列表或图片画布。2026-07-18 同批加入的移动端三入口口径无效,不作为产品决策依据。 - 公共设置边界:`runtime_setting` 保持原表结构与历史数据,但它是音乐音量和平台主题的现役账号级公共能力,不归入旧玩法数据壳。鉴权后的 `GET/PUT /api/runtime/settings` 必须经 `spacetime-client` 调用 `get_runtime_setting_or_default` / `upsert_runtime_setting_and_return`;保留该路由不构成恢复旧 runtime API 的先例。 - 编译门禁:除旧业务目录外,`src/uiAssets.ts`、`src/types.ts`、`src/types/**`、`src/services/runtimeAudioFeedback.ts` 和 `src/services/publicWorkCode.ts` 也是顶层退役 module,必须同时退出 Vite module graph、TypeScript、ESLint 和 Vitest;`/audio/**`、`/chat.png`、`/fusion-pixel.ttf` 及旧 pixel / story-tab / 玩法 CSS 不得进入 dev 服务或生产产物。验收时必须同时检查 `tsc --listFilesOnly`、Vite 依赖图 / 产物和退役资产路径,不能只依赖 tree-shaking。 - Rust 产物边界:`module-runtime` 继续承载账号、钱包、公共设置、追踪和 feature gate,但 `CreationEntry*`、旧公开作品、存档、浏览历史与游玩统计 DTO / command / mapper / 规则必须退出实际 rlib;只保留历史表需要的 `RuntimeBrowseHistoryThemeMode`、完整保序的钱包流水来源枚举等持久化 ABI。`check:server-rs-ddd` 必须执行 `check:module-runtime-artifact`,同时验证旧符号和字面量为零、必要 ABI 仍存在,不能以源码存在 `#[cfg(any())]` 或路由未挂载代替产物证明。 @@ -5876,9 +5885,10 @@ ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 -- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,刷新或事件 / 轮询重放时可能丢失;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 -- 决策:game-chat 为每条 Supervisor ready 输出分配由 `runId + requestSlot + responseRevision` 组成的稳定 `runtime-response:*` 消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到聊天框;普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。 -- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。 +- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 +- 决策:game-chat 为 Supervisor ready 输出、四阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;`art-asset-plan` 不属于五分钟首版阶段,其回复不进入 game-chat 项目聊天。tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。 +- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为四阶段 `x/4`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。 +- 美术资源门禁:live10 实测透明 `icon-spritesheet` 的生成和后处理超过 `300` 秒,不能纳入 game-chat 五分钟首版。game-chat 改为由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 External Editor API、生成或登记失败、文件缺失、HTML 未引用或可见画面未使用均拒绝完成。普通 GUI / CLI autonomous 继续执行完整 DAG,并以正式透明 `assets/art-spritesheet.png` 及其真实引用作为原有美术硬门。 - 验证:`agentRuntimeModel.test.ts` 10 项通过;新增 Rust source allowlist、game-chat parent completion 与 Canvas spritesheet reference 合同测试通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。两项既有 Windows `os error 32` 文件锁竞态仍单独记录,未归因于本次改动。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 @@ -5888,3 +5898,40 @@ - 决策:预览的业务失败与浏览器基础设施失败分流。基础设施失败按稳定 kind 持久化并立即失败结束当前 run;preview readiness/playtest 的 manifest 完成分别绑定当前 revision smoke 和根合同 browser receipt,read-only 文本交付不能绕过。 - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 + +## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 + +- 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 +- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。硬上限是从 root `bound_at` 计算的绝对 deadline,必须包住 Provider、图片生成、文件写入、静态检查、`preview.validate` 和 final-reply 的在途等待;超时先强制持久化 `failed` 终态,再清理 pending action、Provider batch、confirmation、recovery 和进程会话,不能留下 `needs-reconciliation` 悬空态。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。 +- Provider:首版最多一次 Provider 规划 / 写入请求,禁止同一首版自动传输重试、第二次 tool-plan 或无限 repair。Provider 结束后由 Runtime 按当前 revision 依次执行确定性静态 smoke 与浏览器试玩。 +- 兜底:fallback HTML 必须自包含、无远程运行依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。fallback 只有在 `assets/art-spec.png` 已有效登记并真实存在时才能生成,而且必须把该平台图片显著绘制为主要背景、玩家和目标;不得以纯 Canvas 视觉绕过平台图片硬门。 +- 美术边界:live10 已证明正式透明 `icon-spritesheet` 的后处理无法稳定收进 `300` 秒。game-chat 首版必须配置可用的 External Editor API,由 `art-director` 发起一次平台 `images/generations` 并登记 `assets/art-spec.png`,再由 `code-prototype` 在用户可见画面中把该图片作为主要背景、玩家和目标真实加载和绘制。图片必须可完整解码,引用必须从 `game/index.html` 正确解析到登记路径;活动 Canvas 上同一资源至少包含一次背景级和两次实体级的可达 `drawImage`,隐藏 Canvas、诱饵路径和永不执行函数均不能过门。未配置 API,或生成、登记、文件、引用、可见使用任一缺失时都必须失败关闭,不得写 completed 或 `single_round_converged`。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用 game-chat 的 `art-spec.png` 快车道。 +- Windows preview 稳定性:非阻塞 listener 接受连接后必须先把 accepted socket 恢复为阻塞模式,再有界读完拆分到达的请求头;完整响应 `flush + shutdown(Write)` 后执行短时有界 drain。Chromium speculative socket 导致的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 归为可继续监听的瞬时 accept 错误;单个中止连接不得令后续 `preview.validate` 复用或重建时连续得到 `net::ERR_SOCKET_NOT_CONNECTED`。 +- Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。 +- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 + +## 2026-08-03 game-chat 开发态前后端同源与快车道恢复 + +- 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。 +- 首波决策:普通 GUI / CLI 的正式 16 节点 DAG 与 game-chat 首版 lane 都只把 `design-director / art-director / code-director` 作为首波 ready Agent。正式 DAG 的 `design-foundation` 等待策划与美术 Director,`code-prototype` 等待程序 Director 及数值、美术、音频三条底层产物链;game-chat 则在三个 Director 全部完成后才启动 `code-prototype`,再串行执行静态检查和试玩。首波以外的非 repair 底层 Agent 只能由依赖就绪调度或上层明确返工合同按需激活。 +- Runtime 决策:game-chat 六任务 lane、平台美术、单轮收束和自动预览只由持久 `project-supervisor-game-chat` root source 启用。hydration 对 `Pending` 的容忍只适用于当前 source-aware lane 的三个零依赖首波任务,不再硬编码单个历史任务。页面进度、阶段记录和 final-reply 白名单统一使用六项任务与 `x/6`。 +- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。 +- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。 +- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 + +- 决策:`/api/external/v1/mcp` 缺少、格式错误或无法验证 Bearer API Key 时继续返回相同 HTTP `401`,并增加 `WWW-Authenticate: Bearer realm="genarrative-external-editor"` 与机器可读 `details.guide`。引导只说明 Bearer Header 格式、登录后在「开发者 API Key」创建密钥、原始密钥只显示一次、凭据不得进入聊天或仓库、配置后重试 `initialize`,以及公开 manifest、Skill 与 OpenAPI 地址。 +- 安全边界:三种鉴权失败不得通过 code、message、details 结构差异暴露 Key 是否存在;未鉴权响应不得包含 MCP tools、resources、owner 或内部鉴权诊断。其它 External v1 业务路由继续使用原通用 401,不继承 MCP 专用引导。 +- 关联:`server-rs/crates/api-server/src/external_api_auth.rs`、`server-rs/crates/api-server/src/modules/external_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 +## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 + +- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。 +- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 仍受父 run 五分钟总截止约束,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 +- 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。 +- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 +- MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 +- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 +- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 +- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 075c4c7c3..acc57dd6f 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -67,7 +67,7 @@ npm run agc:build:game-chat-release 该命令启用 Rust `game-chat-release` feature,并配合编译期前端入口锁定生成独立 NSIS 包。当前专用 release 版本为 `0.1.1`;产物的 `productName` 为 `Genarrative Game Chat`,`identifier` 为 `world.genarrative.ai-game-creator.game-chat`,安装身份和 AppData 不与普通 AI 游戏创作客户端混用。独立包直接渲染本地 `GameChatReleaseApp`,绕过平台 `AuthenticatedClient`,进入本地项目工作台不依赖 `api-server`;普通 `npm run agc`、`npm run agc:dev`、debug game-chat 和 `npm run agc:build` 继续走既有认证入口与配置。 -game-chat 的用户可见 preview 必须由 Tauri 客户端 `PreviewRegistry` 持有。External Runner 和 Tauri 的 registry、server 句柄与 running 状态是进程内资源,不得互相推断或把 Runner 验证用 server 直接交给 iframe。当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 首次给出结构化成功证据、且其 revision 精确等于项目当前 revision 后,客户端才消费一次性授权并启动一个 Tauri preview server,随后自动显示 iframe;`preview.start` 必须携带 `expectedRevision`,Tauri 在取得项目写锁后再次原子比对。same-run steer 授权必须记录授权前 revision / validation cursor 和唯一 generation ID,旧证据、旧 policy await 或旧 start 返回均不能清除新授权。同一 run 的更高 validated revision 只刷新原 iframe,不能重复 `preview.start` 或新增 server,相同 / 更低 revision 不刷新;同 revision 的最新失败证据必须关闭该 revision 的可玩判定。preview HTTP 的 HTML、脚本、样式、资源和错误响应都必须返回 `Cache-Control: no-store`。没有 Tauri 用户预览时,顶部状态必须显示“预览未启动”,不能只写“未启动”。 +game-chat 的用户可见 preview 必须由 Tauri 客户端 `PreviewRegistry` 持有。External Runner 和 Tauri 的 registry、server 句柄与 running 状态是进程内资源,不得互相推断或把 Runner 验证用 server 直接交给 iframe。当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 首次给出结构化成功证据、且其 revision 精确等于项目当前 revision 后,客户端才消费一次性授权并启动一个 Tauri preview server,随后自动显示 iframe;`preview.start` 必须携带 `expectedRevision`,Tauri 在取得项目写锁后再次原子比对。same-run steer 授权必须记录授权前 revision / validation cursor 和唯一 generation ID,旧证据、旧 policy await 或旧 start 返回均不能清除新授权。同一 run 的更高 validated revision 只刷新原 iframe,不能重复 `preview.start` 或新增 server,相同 / 更低 revision 不刷新;同 revision 的最新失败证据必须关闭该 revision 的可玩判定。preview HTTP 的 HTML、脚本、样式、资源和错误响应都必须返回 `Cache-Control: no-store`。服务端必须在有界 read timeout、总 header 字节和 header 行数内读完请求头再响应,避免 Windows 因未读请求字节产生 abortive RST;`accept` 遇到 Chromium speculative socket 的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 时继续监听,不能让一个瞬时连接中止整个 preview server。没有 Tauri 用户预览时,顶部状态必须显示“预览未启动”,不能只写“未启动”。 `generic-v1` 的真实试玩必须从 `ready` 且正整数 level 开始;start 推进到 `playing` 后,必须先持续观察 2 秒并取得至少 8 个实际样本,期间保持 `playing`,以确认玩家获得正常操作机会;随后点击唯一可见、启用且真实可交互的 `data-playtest-id="primary-action"`,由该控件触发真实主要玩法操作,并以 sequence 相对点击前严格推进证明操作已被接受。操作被接受前进入 `won | lost` 代表玩家没有获得正常操作机会,必须失败;操作被接受后的单次 `lost` 是合法结局,但不能成为所有受控尝试的唯一结果。若主要操作后仍为 `playing`,则继续观察 3 秒并取得至少 12 个实际样本;`won` 可提前证明非失败推进。之后 restart 必须推进 sequence、恢复到 `ready | playing`,并持续观察 3 秒、取得至少 12 个实际样本;若首轮结果为 `lost`,重开稳定后必须再执行一次必要的 start、2 秒 / 8 样本操作机会和真实 primary-action,第二次必须进入或保持 `playing`(再观察 3 秒 / 12 样本且不得转为 `lost`)或进入 `won`。两次受控尝试都固定 `lost` 代表无法正常推进的恶性 bug,必须失败。各观察窗口内 sequence 不得回退,restart 窗口只能保持 `ready | playing`。样本数和观察时长必须同时满足,窗口末端必须强制再读取一次有效状态,不能靠前段样本数提前通过。selector、时长、样本门槛、终态边界、非失败推进、窗口末端覆盖、required assertions 和 sequence 规则都属于 scenario fingerprint。旧 fingerprint 回执在读取和 plan liveness 检查时按 stale missing 处理,让同一 run 可重新 `preview.validate`;身份、digest、路径或内容完整性篡改仍失败关闭,最终完成门仍须现场重算当前 fingerprint 并严格拒绝旧证据。 @@ -81,7 +81,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_ 修改 game-chat release flavor 后,至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding` 和 `git diff --check`。打包 smoke 必须确认:安装信息和产物版本为 `0.1.1`;无参数启动直接进入且只能停留在 game-chat 页面;停止或断开 `api-server` 后本地工作台仍能打开;普通 dev / release 与 debug game-chat 仍走原认证入口;独立 AppData 生效。预览 smoke 应先让当前 run 成功验证 revision N,确认 Tauri registry 启动一个 server 且 iframe 自动出现;在 validate 后、start 取得锁前推进项目 revision,必须确认原子 `expectedRevision` 门禁拒绝启动且授权保留等待新证据;再验证 revision N+1,确认 server 进程和 loopback origin 不变、iframe 显示新版本且响应为 `no-store`。same-run steer 还要覆盖旧验证不消费新授权、旧异步 attempt 不清新 generation;Runner registry 单独 running、失败 / 相同 / 更低 revision 均不能触发用户预览或重复刷新,停止后顶部显示“预览未启动”。独立包退出时必须通过 `runner.shutdown_for_client_exit` 先进入 draining 再结束本 boot,保留 durable sidecar 供下次 reconciliation,不把中断任务写成 completed;Windows Runner 必须 `CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread`,分配或恢复失败时 kill + wait,客户端持有 kill-on-close Job 兜底,关闭主窗口后 Runner、MCP、command、ConPTY 及其后代都应消失。普通 dev / release 和 CLI 继续使用 `runner.shutdown_if_idle`。 -game-chat 迭代还必须确认三条行为:每条 Supervisor ready 输出都以 `runId + requestSlot + responseRevision` 组成的 durable message ID 固化在聊天框,事件 / 轮询 / hydration 重放不重复;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不调度 `publish-strategy` / `publish-package`;配置 External Editor API 时,`code-prototype` 的 `game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭。对应定向测试至少包括: +game-chat 迭代还必须确认以下行为:Supervisor ready 输出、`art-director / code-prototype / preview-readiness / preview-playtest` 四个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/4`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`。game-chat 必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 API 或缺少任一环节都必须失败关闭。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: + +game-chat 首版快车道采用独立四阶段口径:只显示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/4`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。live10 实测正式透明 `icon-spritesheet` 后处理超过 300 秒,因此 game-chat 改为一次平台 `images/generations` 生成并登记 `assets/art-spec.png`;fallback 只有该图片有效登记且真实存在时才允许生成,并必须把它显著绘制为主要背景、玩家和目标。未配置 External Editor API、图片生成失败、缺少 Canvas / manifest 登记、文件、HTML 引用或用户可见绘制时必须在 300 秒内失败关闭,绝不误报 completed。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括: + +game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。 ```bash npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run @@ -591,7 +595,7 @@ npm run check:server-rs-ddd - 移动端优先,再兼容网页端。 - 页面只展示后端返回的状态,不自行计算结论型业务状态。 -- 现役一级入口为 `/creation`、`/project`、`/profile`,桌面侧边栏和移动端底部 dock 都固定显示“创作 / 项目 / 我的”。`/creation` 只读取图片编辑器项目与 `GET /api/editor/showcase/resources`,不得重新接入旧模板入口配置、旧作品架或专属运行态。 +- 现役稳定路由为 `/creation`、`/project`、`/profile`。桌面侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只显示“我的”,并对 `/creation`、`/project`、`/editor/canvas` 及页面内项目 / 画布动作统一显示桌面端提示,不挂载创作工具、项目列表或图片画布。桌面端 `/creation` 只读取图片编辑器项目与 `GET /api/editor/showcase/resources`,不得重新接入旧模板入口配置、旧作品架或专属运行态。 - 旧创作模板目录和顶层旧业务模块必须持续退出 Vite、TypeScript、ESLint 与 Vitest;旧 `/api/creation-entry/config`、模板 API、公开作品详情和运行态 API 必须保持未挂载。SpacetimeDB 历史表、迁移白名单与必要兼容类型只作为数据壳保留,不得据此恢复业务逻辑。 - 优先复用现有面板、抽屉、弹窗,不新建独立大系统。 - 不在 UI 中默认写功能说明类文本。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dfb4c60b5..203f91101 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3970,6 +3970,14 @@ - 处理:先以 CAS 单独 commit `queued -> executing`,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。 - 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。 +## Runtime pending 恢复不能让大型 async frame 共用默认 worker 栈(2026-08-03) + +- 现象:Supervisor collaboration durable isolated spawn 恢复测试在默认 Tokio worker 栈下稳定 `stack overflow`;单独运行同样失败,提高 `RUST_MIN_STACK` 后通过。 +- 原因:不是业务递归。debug 构建中 pending action continuation、后台 task queue 和 Agent 主循环各自形成大型 async poll frame;恢复路径在同一次 poll 调用链直接进入下一层状态机,累计超过 worker 默认栈。 +- 处理:整个 pending continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,使上层 poll 先退栈后再轮询下一层状态机。传入边界的 future 必须先装箱;若泛型 helper 直接持有大型 future,即使随后 `spawn`,调用方 async frame 仍会把它保留在默认 worker 栈上。边界必须保留结构化取消语义;当前使用 boxed future 与 `JoinSet`,父 continuation 被丢弃时同步 abort 子任务。不得只增大 CI 的 `RUST_MIN_STACK`,否则生产默认栈仍可能崩溃。 +- 验证:失败用例必须在未设置 `RUST_MIN_STACK` 时通过;同时覆盖 policy batch 全组、拒绝 pending 后重规划并 drain 下一任务,以及 pending/cancellation 回归,证明恢复不重复生成 isolated spawn、队列继续推进且父任务取消不遗留后台子任务。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`。 + ## Provider 可扩展不能用一个全局 protocol 枚举代替实例隔离 - 现象:把 `openai_chat / openai_responses / anthropic` 直接当 Provider 身份,注册第二个同协议 endpoint 时发生 ID 冲突;或为方便调用把 API Key、base URL、raw-log 目录放进全局状态,并行请求后日志串目录。 @@ -3998,3 +4006,55 @@ - 处理:改外部 v1 响应前先确认 `external_api_key` 是否已有非内部账号的活跃密钥。仍无调用方时可按现行豁免直接改,但必须同步更新接入方案的「版本与兼容策略」;已有调用方时按该节规则择一处理(兼容值 / 弃用期 / 升 v2),只改 JSON 不构成合规变更。 - 验证:`external_editor_api.rs` 的 openapi 断言只校验 schema 形状,不校验兼容性,通过不等于契约安全;判定 breaking 与否以「删字段、移出 required、收窄类型、改语义、新增必填」为准。 - 关联:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/openapi/genarrative-external-v1.openapi.json`、`server-rs/crates/api-server/src/external_editor_api.rs`、`server-rs/crates/api-server/src/modules/external_api.rs`。 + +## Tauri beforeDevCommand 失败不等于已启动客户端会自动退出(2026-08-03) + +- 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。 +- 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。 +- 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 +- Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc//stat`,只把同 PGID 的非 `Z / X` 成员视为存活;`/proc` 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。 +- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 +- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。 + +## game-chat 快车道首波与已提交回复不能被后续 revision 破坏(2026-08-03) + +- 现象:首波从单个美术任务扩展为三个 Director 后,hydration 若仍只容忍 seed lane 的第一个任务在 manifest 短暂恢复 `Pending` 时收束,另外两个已启动 Director 会被卡住。另外默认 `llm.stream=false` 下的专业 final reply 虽已由 finalization 提交,但后续阶段推进项目 revision 后,早期回复会从 Runtime 查询中消失。 +- 原因:hydration 例外把“首波”错误收窄成了单个固定或数组第一项任务;`visible_game_creator_agent_runtime_response_stream_at` 又把未提交流的 revision 新鲜度门误用到了已终态提交的 durable final reply。 +- 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision,`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。 +- 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`。 +## 异步生成结果未知时不能换幂等键重提(2026-07-31) + +- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 +- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 +- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 五分钟硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。 +- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时连 GET 也必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 +- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 +- 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 + +## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31) + +- 现象:本地 `cargo test` 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 `include_str!` 找不到 OpenAPI 或 Skill 文件。 +- 原因:本地工作树包含完整仓库,而容器 Rust builder 原先只复制 `server-rs/` 和 `public/`;crate 中向上引用的 `docs/openapi/`、`.codex/skills/` 不会自动进入镜像构建文件系统。 +- 处理:凡 api-server 通过 `include_str!` 使用仓库根目录资源,都要在 `deploy/container/api-server.Dockerfile` 的 builder 阶段显式复制对应权威目录;不要再复制一份内容到 crate 内形成平行事实源。 +- 验证:除本地 Cargo 测试外,检查 Dockerfile 构建上下文覆盖所有 `include_str!` 相对路径;新增或移动嵌入资源时同步更新容器 COPY 和接入文档。 +- 关联:`deploy/container/api-server.Dockerfile`、`server-rs/crates/api-server/src/external_mcp.rs`、`server-rs/crates/api-server/src/external_skill_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 权威画布快照不能清掉本地待保存或在途布局(2026-08-03) + +- 现象:用户拖动、缩放、改层序、背景色或 viewport 后,生成完成回包立即覆盖画布;450ms 防抖尚未触发或布局保存仍在途时,编辑静默丢失,undo 也可能被生成保护项阻断。 +- 原因:服务端 revision 只能排序已提交事实,本地未落库布局没有 revision;直接清空 pending save 并整体应用权威快照等同于把“服务端更新更晚”误判成“服务端知道本地编辑”。 +- 处理:保留同项目最新本地 dirty snapshot,权威回包先更新资源和生成终态,再按稳定 item ID 合并本地布局字段并基于新 revision 保存。旧权威项在新快照缺失表示后端删除,不能从 pending 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。 +- 生成器边界:`composerOpen` 与 `status / generatedLayerId / errorMessage` 一样属于后端生命周期事实;生成完成快照要求保持面板关闭时,不得被本地在途快照重新展开。提示词、参数和占位位置等本地布局编辑继续保留。集成测试夹具必须模拟后端真实完成快照:既有布局保持原位,完成结果层追加到末尾。同项目权威刷新还必须保留仍有效的单选、多选、生成占位选择或空选,只过滤已删除目标,不得无条件降成第一张图层的单选;首次载入 / 项目切换才设置默认选择。不要只跑 persistence Hook 单测,必须同时运行图片画布生成集成测试,覆盖完成后面板关闭、显式选择结果、背景清选和合并后 CAS 保存。 +- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态;运行 `npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`。 + +## Provider schema 能力不能从统一工具标记直接推断(2026-08-03) + +- 现象:把 OpenAI 风格 `strict` 原样透传给完整 Anthropic 工具目录,单个 schema 不支持的约束或全请求工具 / optional / union 上限会让整次 planning 返回 400。 +- 处理:能力不能从 `apiKind=anthropic` 推断;只对已验证 endpoint/model 显式开启,AGC 当前仅自动识别官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id,旧模型、未知别名和第三方兼容网关默认关闭。协议适配层用官方支持关键词白名单生成 Anthropic 专用传输 schema,对已知不支持约束仅从传输副本剔除,未知关键词、不可解析 / 递归 `$ref` 和复杂度超限均失败关闭为 non-strict,不删工具或修改调用方原 schema。真实 live 样例应包含 `$defs/$ref` 嵌套 schema,并使用官方 Anthropic endpoint,第三方兼容网关不能替代官方能力证据。 + +## 可选 MCP server 的坏目录不能拖垮全部工具(2026-08-03) + +- 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。 +- 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 `connected=false + error + tool_count=0`,required 保持失败关闭;被包入 `action.input` 的 `$ref` 只重定位当前 document 根的 `#` / `#/...` JSON Pointer,命名 anchor、外部 URI 与带 `$id` 的 schema resource 内 fragment 不得改写。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 75bc6edb5..2c846bb1a 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -21,7 +21,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A - 小程序 WebView 外壳:`miniprogram/`。 - 法律文本:`media/files/user_agreement.md`、`media/files/privacy_policy.md`、`media/files/disclaimer.md`。 -桌面端侧边栏和移动端底部 dock 的一级入口统一为 `创作 / 项目 / 我的`。`/creation` 是独立创作工具主页,`/project` 是画布项目入口,`/profile` 是“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力;刷新及浏览器前进 / 后退必须保持当前入口与选中态一致。 +桌面端侧边栏的一级入口为 `创作 / 项目 / 我的`;移动端底部 dock 只保留 `我的`。`/creation` 是桌面端独立创作工具主页,`/project` 是桌面端画布项目入口,`/profile` 是桌面端和移动端共用的“我的”稳定路由,继续承载账号、钱包、统计和通用设置等平台公共能力。移动端直达 `/creation`、`/project` 或 `/editor/canvas`,以及从首页触发项目 / 画布动作时,只显示桌面端创作提示,不挂载对应工具页面。 ## 当前后端路线 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index d2fe77662..aa8fe5978 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -48,6 +48,7 @@ - 涉及中文文本时注意 UTF-8 编码和乱码排查。 - 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。 - `packages/shared` 用于前后端 DTO、公开契约及跨页面复用的无业务真相 UI 组件和纯工具;不得把领域规则、后端副作用或正式状态放入其中。 +- 修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义时,必须同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 和对应契约测试;Rust 实现与 OpenAPI 未对齐时不得完成、提交或发布。 - `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run dev:api-server` 与 `/healthz`。 - 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 和 `SPACETIMEDB_TABLE_CATALOG.md`。 - 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ef0e0f8ff..fa82c0075 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -61,7 +61,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本,仍必须以后续显式读取或生成完成返回的后端快照作为项目真相。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本。生成完成或显式读取返回较新权威快照时,若同项目仍有防抖待保存或在途保存的本地布局,前端必须以新快照的资源和生成终态为权威,只重放本地几何、层序、分组、隐藏、锁定、翻转、viewport、背景色和生成面板编辑,并立即基于新 revision 入队保存;后端新增项必须合入,后端已删除的旧项不得被本地旧快照复活,本地在请求期间删除的旧项也不得复活。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index d5839a983..d81274588 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -4,18 +4,18 @@ > 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 -更新时间:`2026-07-21` +更新时间:`2026-07-31` ## 背景 -当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。 +VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的八类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。 ## 目标 - 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。 - 外部生成副作用由独立 `external-generation-worker` 角色执行。 - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 -- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 +- 本地或小流量站内同步排查可显式启用 `inline` 模式,由站内 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。External v1 不继承此例外,始终异步入队。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 - 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 - 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 @@ -37,6 +37,8 @@ - `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 - `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。 +External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。 + 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 @@ -112,6 +114,8 @@ pending/running -> cancelled (预留) - `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。 - `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。 +External v1 八类生成不读取上述模式分支:即使进程配置为 `inline`,External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。 + 同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换: - `api`:只启动 HTTP server。 @@ -207,7 +211,19 @@ controller 配置: 透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。 -inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 +inline 完成结果与 External v1 completed compact result 继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 + +### External v1 异步提交与查询 + +External v1 复用上述九类 editor job kind 中除手动去背景外的八类生成 kind。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数: + +1. 每个生成 POST 必须携带 `Idempotency-Key`。服务端把 owner、job kind、稳定键和规范请求纳入 dedupe;未知结果重试必须复用原键。 +2. 成功入队返回 HTTP `202`、`operationId`、`statusUrl`、`pollAfterMs`,并设置 `Location` / `Retry-After`;不返回 project、asset 或媒体结果。 +3. `GET /api/external/v1/generations/{operationId}` 通过 owner-safe facade 读取摘要。`queued/running` 返回 phase/progress;`failed` 返回脱敏错误;`completed` 再读取同一 owner 的生成 artifacts 并返回 `result_payload_json.result`。 +4. 跨 owner operationId 按不存在处理。查询路径不开放 claim、renew、complete、fail、retry、acknowledge 或 controller 控制面。 +5. completed 查询返回 compact artifact 引用。调用方需要完整画布时重新读取项目,需要媒体临时 URL 时再对稳定 objectKey 换签。 + +托管 MCP 的生成 tools 也走同一 External REST router:MCP 参数中的 `idempotencyKey` 映射到 HTTP `Idempotency-Key`,`get_external_editor_generation_job` 映射统一查询。MCP 不直接调用 SpacetimeDB procedure,不形成平行队列或结果账本。 ## 验收 @@ -226,6 +242,8 @@ cargo check -p api-server --manifest-path server-rs/Cargo.toml cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml +cargo test -p api-server external_editor_generation --manifest-path server-rs/Cargo.toml +cargo test -p api-server external_mcp --manifest-path server-rs/Cargo.toml npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible" npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft" ``` @@ -239,7 +257,7 @@ curl -f http://127.0.0.1:/healthz 本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。 -生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8891958e4..ddb278e0a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -31,10 +31,10 @@ - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows Runner 私有文件初始化:父 AppData 已归当前 `TokenUser` 后,新建 `agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。固定 stale lock 只有在父目录已验证为当前用户 protected 私有 DACL、Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许修复;其它三类文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 - 启动诊断:独立 release 的 `startup.log` 和 `agent-runner.log` 只记录有界、脱敏的阶段与 stdout / stderr 摘要,凭据、AppData 路径和其它绝对路径不得原样落盘;单文件达到 256 KiB 后只轮转保留一份 `.previous.log`。`startup.log` 优先写独立 AppData,目录不可写时回退到系统 TEMP 下的 `Genarrative-Game-Chat-Diagnostics`;Tauri context、窗口 URL、AppData、Runner 或 `.setup()` / `.build()` 初始化失败时,Windows 必须显示可见错误对话框并给出诊断日志位置,不能只在无控制台 release 中静默退出。 -- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。这些原始事件只是 Runtime 状态投影,不写入 conversation,不伪装成用户或 assistant 消息。 +- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 -- 跨轮阶段记录:game-chat 确实观察过活跃态的父 run 进入 completed / failed / cancelled 等终态,且正式 Supervisor conversation 已刷新后,客户端把本轮轮次、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留;加载时已经终态但本窗口未观察其活跃过程的旧 run 不补写,防止每次启动重复归档。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 六项首版任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/6` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 - 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。 @@ -52,11 +52,28 @@ ## 2026-07-31 game-chat 输出、单轮预览与平台美术资源 -- 对话输出:game-chat 的 Supervisor `ready` response stream 以 `runId + requestSlot + responseRevision` 形成稳定 durable message ID,最终每条输出都追加到聊天框;事件、轮询、React StrictMode 重放和 hydration 只按该 ID 去重,不以正文或时间戳合并不同 Run 的相同回复。普通 `supervisor-chat` 保持原有 transient response 行为。 -- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;普通 GUI / CLI 仍执行完整发布 DAG。完成门仍要求当前 revision、`game.static_smoke` 和 `preview.validate` 结构化证据。 -- 平台美术资源:配置 External Editor API 时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。确定性 Provider fixture 同步输出图集引用,避免测试绕过该门禁。 +- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`design-director / art-director / code-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。 +- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。 +- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版只按六项任务显示 `x/6`(详见 2026-08-03 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。 +- 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。它必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,再由 `code-prototype` 在 `game/index.html` 的用户可见画面中把该图片显著用于主要背景、玩家和目标;未配置 API,或缺少生成、登记、文件、引用、可见使用任一证据时均 fail-closed。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用该快车道。 - 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。 +## 2026-08-01 game-chat 首版六任务快车道与美术硬门 + +- 首版任务边界:game-chat 首版只展示 `design-director`、`art-director`、`code-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 六项任务,进度统一显示为 `x/6`;首波仅并行激活三个 Director,`code-prototype` 必须等待三者完成,后续验证再串行推进。`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。 +- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `300` 秒绝对硬上限,不能把硬上限拆成每个 Agent 独立计时。整个 Runtime pass 必须受同一 `timeout_at` 约束,覆盖 Provider、图片生成、文件写入、静态检查、浏览器试玩和 final-reply 的在途等待;超时先强制持久化 `failed`,再清理恢复 sidecar 与进程会话。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。 +- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性平台生图并行;各 Director 只处理本组规划,不得提前激活底层 Agent。三者收束后,`code-prototype` 最多执行一次 Provider 首版写入请求;后续不再请求第二次代码 tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。 +- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。模板只有在 `assets/art-spec.png` 已经完成有效登记并真实存在时才允许生成,且必须把该平台图片显著绘制为主要背景、玩家和目标;未配置 External Editor API、图片生成失败或缺少有效登记时直接失败关闭,不得生成纯几何首版。 +- 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;图片必须完整解码,`game/index.html` 的引用必须正确解析到该登记路径,且同一资源必须在非隐藏活动 Canvas 的可达执行路径中至少完成一次背景级和两次实体级 `drawImage`。`code-prototype` 必须在用户可见游戏画面中把它作为主要背景、玩家和目标真实加载和绘制。未配置 API,或生成、登记、文件存在、HTML 引用、可见使用任一缺失时,都必须在父 Run 的 `300` 秒累计硬上限内失败关闭,绝不写入 completed、`single_round_converged` 或其它成功结论。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。 +- 关联验收:快车道必须分别验证三 Director 首波并行、`code-prototype` 依赖三者、`x/6` 投影、六个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。 + +## 2026-08-03 game-chat 开发态同源与持久输出修复 + +- 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。 +- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后 `code-prototype → preview-readiness → preview-playtest` 的六任务 lane、平台 `art-spec.png` 美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。 +- source-aware lane 的首波 ready child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source 的种子 lane 解析全部零依赖任务,不得硬编码某个 Agent;当前 game-chat 首波是 `design-director / art-director / code-director`,后续 code prototype / preview child 仍严格拒绝 `Pending` 收束。 +- 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream`。`streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。 + ## Runtime 边界 V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 @@ -119,6 +136,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-22 V1.47 收紧自主构建首批职责与专业交付:`project-supervisor` 的 initial wave 必须同时且各一次委派 `code-prototype` 与 `quality-review`。程序委派必须是非只读实现任务,`expectedArtifacts` 包含 `game/index.html`;质量委派必须显式只读、不得修改项目且 `expectedArtifacts=[]`。合同在 Provider 计划解析、batch prepare 和 durable batch 恢复三处重验;新批次使用 `game-creator-provider-action-batch.v3`,只有 v3 按新职责失败关闭,升级前已持久化的 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和合同恢复。只读专业 Agent 的 Provider 计划只允许纯读取与状态观察动作,任何文件、revision、命令、任务、记忆、黑板、资产或委派副作用都在执行前拒绝,并把格式修复目录收窄为仅 `respond_to_user`。非只读专业 Agent 只有在本人 run 产生 project mutation 且当前 mutation revision 已验证通过后才能回复;ready 未认领或 claim 尚未 observed 时,父 Supervisor 必须先只调用 `agent.run_status` 收束回执,再决定 repair。只读判定只接受明确的只读审查/验收或不得修改指令,`非只读 / 不要只读 / not read-only` 等否定式标签不得因子串命中而误判。 +2026-08-03 覆盖说明:上段 `code-prototype + quality-review` 首批身份已退出当前合同,现行首批为 `design-director + art-director + code-director`;策划与程序 Director 只读且 `expectedArtifacts=[]`,美术 Director 非只读并要求 `assets/art-spec.png`。V1.47 的 batch v3 恢复、只读工具限制、mutation / verification 与 claim 收束边界继续保留。 + V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独立真实外部轮次已完整 **PASS**:用户只输入一次任务后 stdin 立即 EOF,人工 approve / answer / steer 均为 `0`;一个原始专业任务失败后由唯一 repair 自行恢复,父 Supervisor 为 `idle / completed`,`turn.report=settled` 且只有 `1` 条 `44` 字符 assistant。项目 revision `0 -> 6`,`game/index.html` 为 `8080` bytes 且已变化,两次静态检查通过,desktop / mobile 的 `lane-defense-v1` 真实 Chrome 试玩为 `37/37`。`88` 个 Provider identity 全部 terminal,其中 `75 completed / 13 failed`,`12` 条 durable retry audit 与专业 repair 均自行恢复;open lifecycle、pending、confirmation、user-input、provider batch/retry/handoff/tool-plan handoff、finalization、reconciliation、duplicate 与各类泄漏终局均为 `0`,Runner、disposable 项目和隔离 AppData 已自动清理。该轮证明失败 attempt 可保留真实证据而循环仍能零人工干预收束,不能把它改写成 Provider 零失败。 2026-07-23 起,开发验收提供两个根级短入口。`npm run agc:test` 直接委托现有确定性可玩塔防 E2E,不复制 Runtime harness;`npm run agc:test:chat` 自动发现 Tauri identifier `world.genarrative.ai-game-creator` 对应 AppData,把 `game-creator.config.json` 和存在时的 `game-creator.config.local.json` 私有复制到单次 sentinel 隔离 AppData,绝不复制正式 Runner endpoint、lock、备份或其它文件,再创建带私有 sentinel 的一次性项目。LLM 状态检查和 `--swarm-chat --init --autonomous-game-build` 都只使用隔离 AppData,因此当前 debug 二进制指纹变化不会探测、退役或阻塞正在工作的正式客户端 Runner。用户只输入需求并发送 EOF;正常收束且存在 `game/index.html` 后,通过仅开发 CLI `--preview-serve` 复用正式 localhost preview server,自动打开固定形态的 loopback 试玩地址。预览按 `Ctrl+C` 结束后,脚本通过内部 `--runner-shutdown-if-idle` 只关闭已空闲的隔离 Runner,确认 endpoint 消失后再验证 sentinel 并清理隔离 AppData 和项目;隔离 Runner 仍有任务、退出失败、Swarm 未收束或预览启动失败时保留对应现场,不能强杀或误删。`--keep-project` 可主动保留项目但不额外保留已空闲的隔离配置;显式 `--project-dir` 永不删除,非空未初始化目录拒绝,`--config-dir` 只表示绝对配置来源目录,`--project-dir` 也只接受绝对路径。该人工入口用于快速体验,不能替代真实外部 Provider E2E 的完整生命周期、隐私和残留门禁。 @@ -229,7 +248,7 @@ Agent Runtime 负责: - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm..reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。 - 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。 -- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`,无 `strict`)与对象形态 `tool_choice`(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。 +- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新,2026-08-03 收紧 strict 边界:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`)。`strict` 能力不从 `apiKind` 推断:AGC 只对无凭据 / 自定义端口 / 路径的官方 HTTPS endpoint 和 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成 Anthropic 专用传输 schema,已知不支持约束只从传输副本剔除,调用方原 schema 保持不变;未知关键词、不可解析 / 递归 `$ref` 和 strict 工具 / optional / union 请求级复杂度超限时该工具保持 non-strict,不能因完整 AGC 工具集超限让整次请求被上游拒绝。工具数组最后一项携带 `cache_control: {"type":"ephemeral"}` 作为 prompt cache breakpoint;非流式和流式 usage 都将 `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` 合并为 prompt tokens。`tool_choice` 使用对象形态(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。 - 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step` 与 `action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。 - 2026-07-15 V1.17 公共审计收紧:`thinking_summary` event 只保存固定摘要、正文 SHA-256 与字符数,legacy `plan` event 只保存步骤数;结构化计划审计只保存 explanation 的哈希与字符数,以及 step 标题哈希、状态和数量。模型 thinking、legacy plan 标题、repair 错误和调用体只允许出现在对应私有 Runtime 上下文或有界 repair 请求中,不得复制到公共 event、task 或 Agent DB 正文字段。 - 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。 @@ -240,7 +259,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 2026-07-10 补充,2026-07-28 收紧:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、平台素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。图集响应含 `warning.code=postprocess-failed-source-preserved` 或不含任意 `alpha < 255` 时不得登记为正式透明图集;仅有 `sliceWarning` 时可保留完整透明图,但不宣称已有独立切片。本地 manifest 持久生成 route、kind 与精确参考 resourceId;登记失败时删除本轮刚写入的新文件。API Key 不进入 observation、manifest、agent.db 或日志。 +- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算,game-chat 仍受父 run 五分钟总截止约束。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 2026-08-03 durable 恢复补充:`accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源,再查询既有 operation;不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住 operation GET。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 +- 2026-08-03 durable 恢复加固:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。生成提交只有契约明确的 `400 / 401 / 403` 可视为入队前拒绝并清理 prepared 账本;其它非成功状态保留账本进入对账。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 - 2026-07-10 补充:后台任务工具箱已加入 `task.update`。Agent 可在 loop 中把 manifest 种子任务状态更新为 `pending / running / waiting-for-confirmation / completed / failed`,用于表达长期后台任务的当前进度;Runtime 复用 `task.update` 策略和项目写锁,实际只修改 `.agent/manifest.json` 中已有 taskId 的 `status`,并写入 `agent.runtime.task.update` 审计记录。策略要求确认或拒绝时不会修改 manifest,也不会创建新任务。 - 2026-07-10 补充:后台任务工具箱已加入 `file.list`。Agent 可在 loop 中自行列出项目文件摘要或某个相对目录下的文件摘要,再决定是否继续读取具体文件;Runtime 复用 `file.list` 项目权限策略,observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -251,7 +272,7 @@ Agent Runtime 负责: - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 -- 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 +- 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 - 2026-07-10 补充,2026-07-12 更新,2026-07-15 增加 V1.17 完成门禁并由 V1.21 澄清:后台 Agent 返回空 `actions` 后,只有不存在 `project.verify` 等既有 blocker,且当前结构化计划的全部必要步骤均为 `completed`,才视为 loop 已收束。工具 action 序号和成功 observation 不会自动推进结构化计划;未完成时 Runtime 返回 `runtime.plan_update` blocker,在同一 run 要求 Agent 按真实进度更新。每 6 轮只做进度 checkpoint 与停滞检测;有新的独立 observation 时继续同一 run,最近 6 轮没有独立进展或相邻 checkpoint 重复时终态才写为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计。上下文摘要只由 token 阈值或显式 `/compact` 触发。解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。 - 2026-07-15 V1.18 补充:开发单 Agent 对话框使用 `执行 / 聊天 / 目标` 三段模式,Goal 创建/编辑在独立弹层完成,并可查看状态、revision、完成标准以及暂停/恢复/清理;正式用户窗口不展示 Goal 管理控件。Provider 中断边界先持久化可恢复的当前 v5 context;Runner 重启先收束 Goal control,`paused` 在 finalization/pending action 前直接保持休眠。resume 只从 `paused` 续接,先删除同一 run 旧 cancel tombstone;finalization v3 在 assistant 后先投影 Runtime completed,再写 Goal completed 并补 Goal 终态投影。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 @@ -327,7 +348,7 @@ game-project/ - `canvas.project_open` 只打开本机 Genarrative 编辑器的 `/editor/canvas?projectid=...`,默认地址为 `http://127.0.0.1:3000`,开发者可在开发窗口改成本机端口;不允许打开远程站点或任意 URL。 - 画板资源回流到本地项目 `assets/`,并在 manifest 中记录画板项目、资源 ID、assetObjectId、prompt、model、taskId 和 assetKind;当前最小落地提供 `asset.register` 登记项目内已有资产,并提供 `canvas.export_import` 读取现有画板素材导出 ZIP。 - `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。 -- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再调用 `/api/external/v1/editor/images/generations`,携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`,随后通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件、agent.db、trace、manifest 或日志;未配置 Key 或生成失败时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 +- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用生成端点。Runtime 在私有生成账本持久化精确请求、幂等键和返回的 `operationId`,并按 `pollAfterMs` 查询统一状态端点;completed 后从 compact result 取得稳定 objectKey/resourceId,再通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件;幂等键只作为该动作的私有可恢复身份保存,不进入 agent.db、trace、manifest、observation 或日志;operationId 允许出现在脱敏的对账错误与私有账本中,但不进入 manifest。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 - 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。 - `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端资源行。 @@ -698,7 +719,7 @@ game-project/ - 2026-07-15 当前正式 `openai_chat / gpt-5.5` 路由的三轮真实联网专项均 FAIL:上游接受搜索开启请求并完成 lifecycle,但模型没有获得原生搜索能力,无法命中动态 GitHub release baseline。客户端能力已落地但该路由不可启用;最终复验使用正式 AppData 同级的 `0600` 私有配置副本,源配置 inode/nlink/timestamps/hash 前后完全一致,隔离 Runner/AppData/项目和全部泄漏门禁均安全收束。 - 2026-07-15 起,同一 Runtime 文档的“V1.21 单 Agent token-aware 持久上下文压缩”作为长会话预算事实源。全局/per-Agent LLM 配置提供 context window、自动压缩阈值和工具输出 token 限额;后台 planning 超阈值时只压缩旧 conversation/observation prefix,Goal、任务、计划、steer、pending、verification 与副作用身份逐字段保留。开发 Agent 窗口与 `agc:chat` 提供同一安全 `/compact`,正式用户首页不新增控制项。私有 sidecar、context bundle 绑定、Provider orphan barrier、公共零正文和 30 轮真实长链路按 Runtime V1.1 的 V1.21 章节验收。 - 2026-07-15 V1.21 已落地并完成真实验收:`context-compaction` suite 在正式 `openai_chat / gpt-5.5` 路由上完成 30/30 轮、两次压缩 revision、一次 Runner pidfd 强杀恢复和早期显式约束召回;最大估算输入 29134/64000,32 组 Provider lifecycle 唯一闭合,重复 assistant/audit、工具重放以及公共正文、summary、API Key、诱饵、项目路径和正式配置路径泄漏均为 0。首轮第 22 轮 Provider transport 失败按单次请求终态停止且零重放,新 disposable 项目完整重跑后 PASS。 -- 2026-07-15 起,同一 Runtime 文档的“V1.22 Runner-owned MCP 动态工具”作为外部工具扩展事实源。AppData 配置管理 STDIO / Streamable HTTP server、Bearer/static header、工具 allow/deny 与 `auto / confirm / writes / deny` 审批;独立 Runner 持有连接并把过滤后的真实 tool schema 和 server instructions 送入 planning。模型通过现有 `submit_agent_tool_plan` 请求 `mcp.call`,调用继续复用 durable pending action、确认、steer、Goal、reconciliation 和 V1.21 token 预算;完整结果只落私有 sidecar,正式用户首页不新增 MCP 调试配置。本切片不宣称 OAuth、resources/prompts、sampling、elicitation 或 MCP task-mode 已实现。 +- 2026-07-15 起,同一 Runtime 文档的“V1.22 Runner-owned MCP 动态工具”作为外部工具扩展事实源。AppData 配置管理 STDIO / Streamable HTTP server、Bearer/static header、工具 allow/deny 与 `auto / confirm / writes / deny` 审批;独立 Runner 持有连接并把过滤后的真实 tool schema 和 server instructions 送入 planning。模型通过现有 `submit_agent_tool_plan` 请求 `mcp.call`,调用继续复用 durable pending action、确认、steer、Goal、reconciliation 和 V1.21 token 预算;完整结果只落私有 sidecar,正式用户首页不新增 MCP 调试配置。可选 server 的 tools/list、schema 归一化、重复 tool identity、单 server 或聚合目录容量、未支持 task-mode 错误只隔离该 server,目录状态记录 `connected=false + error` 且不暴露其工具;required server 对相同错误继续失败关闭。MCP input schema 包入原生 action 的 `input` 属性时,只把当前 schema document 根的 `#` 与 `#/...` JSON Pointer 重定位到 `#/properties/input...`;命名 anchor、外部引用和带 `$id` 的独立 schema resource 内 fragment 保持不变。本切片不宣称 OAuth、resources/prompts、sampling、elicitation 或 MCP task-mode 已实现。 - 2026-07-15 V1.22 已落地并完成真实验收:开发配置窗可管理 server、敏感凭据、工具过滤和审批并通过 Runner 查看有界目录,`agc:chat` / `agc:swarm` 可用 `/mcp` 查询状态。正式 `openai_chat / gpt-5.5` 路由真实调用 STDIO/Streamable HTTP lookup 和确认后的 mutate,正常 run 的 action/sidecar/receipt 各 3 且最终 assistant 唯一;第二 run 在 HTTP mutate 副作用后强杀 Runner,只进入 1 次 reconciliation,调用、sidecar、receipt 和 assistant 均未重放。公共 arguments、结果正文、instructions、凭据和项目/配置路径泄漏为 0,一次性现场已清理。 - 2026-07-16 起,同一 Runtime 文档的“V1.23 单 Agent 持久用户输入请求”作为 Needs input 事实源。Agent 可在计划未完成时通过 `user.input_request` 提出 1-3 个结构化问题,Runtime 保持同一 run 并暂停;Project Supervisor、开发 Agent 窗口和 `agc:chat` 从私有 sidecar 展示并提交答案。普通 steer、工具确认和最终回复不再承担问题回答语义,问题/答案正文不进入公共审计。 - 2026-07-16 V1.23 已完成真实验收:正式 `openai_chat / gpt-5.5` 路由在 Project Supervisor 上产生 1 个含 2 选项的 Needs input,等待期 Runner pidfd 强杀恢复未增加 Provider 请求,回答后同 Session/run 完成唯一最终回复。问题/回答各一条,重复消息、公共正文、密钥、路径和报告泄漏均为 0,隔离现场已清理。 @@ -727,6 +748,7 @@ game-project/ - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 - 2026-07-17 起,同一 Runtime 文档的“V1.32 Runtime 强制 Supervisor 协作合同”作为 mixed swarm 可靠性事实源。项目可用 `.agent/collaboration-policy.json` 约束首波 static/isolated 模式、数量和 required static Agent;Runtime 在任何 child 副作用前整批校验并把合同指纹固化进 Provider batch v2。当前父 run 一旦形成 delivery/group,正式 `project-supervisor` 默认只负责编排、状态认领和验证,不再直接执行项目 mutation;专业 Agent/isolated child 权限与唯一 Supervisor 最终回复边界保持不变。 - 2026-07-17 V1.32 最终代码已完成独立真实 Provider PASS:首批 mixed batch、三 isolated child、Runner 强杀恢复、专业返工、宿主验证、唯一最终回复与零重复/残留/泄漏同时成立。真实报告计数、隔离重试配置和仍待收敛的 tool-plan repair 成本统一以 Runtime 文档 V1.32 章节与共享决策记录为准。 +- 2026-08-03 恢复执行补充约束:整个 pending action continuation、它进入的后台主循环,以及完成、取消或失败后 drain 同 Agent 后续队列时,都必须跨越独立 Tokio task 轮询边界,不能让 pending executor、task queue 与 Agent 主循环的大型 async poll frame 在同一 worker 调用栈连续嵌套。边界输入必须先装箱,避免泛型 helper 在真正 spawn 前仍把大型 future 保留在调用方 async frame;边界同时必须随父 continuation 取消子任务并保持 durable action、batch、run/session 身份及恢复防重语义,当前使用 boxed future 与 `JoinSet` 承担该约束。CI 和生产均使用默认 worker 栈验证,不以提高 `RUST_MIN_STACK` 代替代码边界。 - 2026-07-18 起,同一 Runtime 文档的“V1.34 动态隔离子 Agent writeScopes 命令绕过封堵”作为 isolated child 的现行能力事实源。在 scope-aware OS sandbox 完成前,动态 child 无条件禁用 `project.verify / project.git_commit / command.exec / command.start / command.stdin / preview.start / agent.delegate / agent.spawn_isolated / project.restore / agent.schedule_ready / canvas.asset_generate / task.create / task.update / blackboard.write` 和全部 MCP;原生工具策略统一显示 `denied`,模板、项目 policy 与用户确认均不能放宽。保留固定只读 `command.run_limited`、同身份 `command.output_read / command.poll / command.terminate`、既有预览的 `preview.validate`,以及严格位于 `writeScopes` 内的 `file.write / file.patch / file.delete / project.patchset`。 - V1.34 的新单动作在 confirmation 和 OS launcher 前拒绝;新多 action 原生 batch 只要含一个 denied member 就在独立 pending-action sidecar、confirmation、OS spawn、revision 和任何成员项目副作用前整批 abort,只保留 `aborted / nextActionIndex=0` batch 事实。旧 pending / approval / batch 真正进入执行器时仍重新应用当前 child 边界,旧 executing 未知结果继续进入既有 reconciliation。该安全收紧由恶意 sibling 写入、策略快照、batch、旧 pending 执行器重验和 isolated/mixed/collaboration/provider-batch 回归证明;不因本切片重跑已通过且 isolated mutation 为 0 的 V1.31/V1.32 外部 Provider suite。通用命令只有在后续 scope-aware OS sandbox 对所有后代强制同一 `writeScopes` 并通过独立决策与测试后才可重新评估开放。 - 2026-07-18 起,同一 Runtime 文档的“V1.35 多 ready isolated all-join 原子认领与恢复”作为 `agent.run_status` 同父 run 多 group 认领的现行事实源。Runtime 按 `delegationGroupId` 排序并一次性预取全部 join 锁;任一后续锁忙时保持零 delivery mutation、零 claim sidecar。全锁就绪后,同一 action 的 durable claim journal 按 `prepared -> committed -> observed` 推进;部分 commit 或 Runner 恢复只能复用该 journal 幂等补齐。只认领可完整放入优先 `readyIsolatedJoins` 观察预算的有序前缀,未观察旧 claim 可由后续 action 完整重放,但不创建第二份 isolated claim。每个 claimed delivery 必须由匹配原 action/group 的 journal 覆盖;无 journal 的旧 delivery 每轮只迁移一个原 action,已有 journal 不得扩写或状态倒退,跨 action group 归属冲突失败关闭。成功 observation 写入 pending sidecar 后只能把本轮完整输出的 claim 标记 `observed`,任一未观察或无 journal claim 继续阻断 finalization;每个 group 审计按 `actionId + delegationGroupId` 唯一,并在 Agent DB 锁内修复 torn tail、全量核对后幂等追加。 @@ -783,7 +805,7 @@ game-project/ - Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 - 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 - 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 -- 2026-07-27 Windows DACL 启动回归修正:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析失败退出。 +- 2026-07-27 Windows DACL 启动回归修正,2026-08-03 补充 pwsh 模块隔离:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记,并复用 `Get-Item` 返回的 `FileSystemInfo.GetAccessControl()` 读取 ACL,不调用会因父 PowerShell 7 `PSModulePath` 污染而自动加载不兼容模块的 `Get-Acl`;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析或模块加载失败退出。 - 2026-07-31 macOS 临时路径回归修正:配置目的地安全检查返回解析过现存父目录的真实路径,回归 fixture 的期望值也必须先使用平台原生 `realpath` 规范化临时根目录。macOS 下 `/var/folders/...` 与 `/private/var/folders/...` 是同一目录身份,不得用未规范化字符串阻断 `agc:typecheck`。CI 还必须使用“真实目录 + 符号链接父目录 + 不存在叶目录”确定性复现该语义;Windows 使用 junction 覆盖驱动器号、大小写与链接路径差异。fixture 的规范化与断言必须位于同一 `try/finally` 清理边界内。 - 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。 - 2026-07-28 Windows `tool-plan` 成功响应交接修正:相对目录句柄下安装 handoff 账本改用 `NtSetInformationFile(FileRenameInformation)`;`SetFileInformationByHandle(FileRenameInfo)` 不接受当前实现所需的非空 `RootDirectory`,会稳定返回 `ERROR_INVALID_PARAMETER (87)` 并让总控首轮进入 `needs-reconciliation`。实现继续绑定已验证的父目录句柄和相对 hash 文件名,不退化为绝对路径 rename;“按句柄安装”归入 `tool-plan-storage`。总控对 reconciliation 提供“已核对,结束旧任务”,取消后有 pending task 时只等待 Runner 续跑,队列为空时才允许显式 retry;自主构建 Supervisor 的 retry source 从原 Run Profile 绑定恢复并重新验证为可信 GUI / CLI 根入口,不降级成普通后台任务来源。 @@ -804,7 +826,7 @@ game-project/ ## 2026-07-31 长耗时与恢复收口 -- `autonomous-game-build` 的固定 manifest DAG 是唯一首轮专业执行链。缺省 collaboration policy 不再额外强制 `code-prototype / quality-review / art-*` 静态首波;显式项目 policy 仍原样生效,但 Runtime 不再按 Editor Key 或已有图片偷偷追加 Agent。Supervisor prompt 同步禁止在 manifest 前复制同职责委派。 +- `autonomous-game-build` 的固定 manifest DAG 是唯一缺省首轮专业执行链。缺省 collaboration policy 不再额外强制 `code-prototype / quality-review / art-*` 静态首波,Runtime 也不再按 Editor Key 或已有图片偷偷追加 Agent。2026-08-03 起,显式项目 policy 或旧 batch 恢复若进入首批 `agent.delegate` 兜底,同样只能激活 `design-director / art-director / code-director`,不得提前激活底层 Agent;这条兜底不能在默认 manifest 前复制同职责委派。 - `preview-readiness` 只有在自己的 child run 持有当前 project revision 的 `game.static_smoke=passed` 凭证后才能完成;`preview-playtest` 作为根 Supervisor 的直接 manifest child,必须解析并写入根完成合同的当前 revision browser receipt,报告、desktop/mobile 截图及摘要复核通过后才能投影 manifest completed。 - 浏览器未发现、临时环境不可建、启动超时或在 WebSocket URL 解析前退出统一分类为 `preview-infrastructure-unavailable`。首个持久 observation 后收束当前 action batch并失败结束 child/root run,禁止继续用 Provider 逐轮规划同一 revision 的重复启动;普通页面/玩法验收失败仍保留为业务失败,不混入基础设施分类。 - game-chat release 在 `CloseRequested / ExitRequested` 前复用 Runner durable idle probe;只要存在 process session、pending/finalization/provider/tool-plan handoff 或非终态 Agent queue/phase,就阻止关闭并提示先完成、暂停或取消。不可撤销的最终 `Exit` 不再作为唯一保护点,Windows Job Object 的 child-owned 安全边界保持不变。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index 804bcd7a9..6f52dcf98 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -26,7 +26,7 @@ - 为历史审计、迁移、资产归属核对所必需的最小只读表定义;不得借兼容读取重新暴露旧创作、发布、公开详情或运行接口。 - 编辑器、项目、账号、钱包、资产、HostBridge、运维和安全等平台公共能力。 - 通用 `feature_gate_config`、`GET/PUT /admin/api/feature-gates` 与后台 `#gray-release` 控制页。灰度页只读取通用 gate,不再请求旧 `/admin/api/creation-entry/config`,固定目标只登记现役功能;不得恢复 `creation-entry:*` 动态目标。 -- 新版 `/creation` 创作工具主页、`/project` 项目入口、稳定的 `/profile` 个人页路由、`creation-home` 展示组件与现役静态资产。桌面端保留“创作 / 项目 / 我的”公共侧边栏,移动端保留同样三项的底部 dock;“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。 +- 新版 `/creation` 创作工具主页、`/project` 项目入口、稳定的 `/profile` 个人页路由、`creation-home` 展示组件与现役静态资产。桌面端保留“创作 / 项目 / 我的”公共侧边栏;移动端底部 dock 只保留“我的”,不得因退役旧模板而扩大移动端创作范围。“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。 - `runtime_setting` 是账号级公共设置事实,不属于旧模板运行态。原表结构和数据不变,继续由鉴权后的 `GET/PUT /api/runtime/settings`、`get_runtime_setting_or_default` 与 `upsert_runtime_setting_and_return` procedure 支撑音乐音量和平台主题读写。 - 旧页面、测试、素材、handler、service、worker、生成 bindings 和纯业务 crate 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。 @@ -65,7 +65,7 @@ ## 验收 - 旧 URL 不再命中旧页面或后端路由。 -- `/creation`、`/project` 与 `/profile` 在桌面端显示“创作 / 项目 / 我的”公共侧边栏,在 `390x844` 等移动视口显示同样三项的底部 dock;点击、刷新及浏览器前进 / 后退均保持路由与选中态一致。新创作主页只调用编辑器项目和公开编辑器素材接口;顶栏保持现役搜索、公共泥点入口与账号胶囊,不重新拼装平行账号按钮组。 +- `/creation`、`/project` 与 `/profile` 在桌面端显示“创作 / 项目 / 我的”公共侧边栏;在 `390x844` 等移动视口,底部 dock 只显示“我的”。移动端直达 `/creation`、`/project`、`/editor/canvas` 时显示桌面端创作提示,且不得挂载创作主页、项目列表或图片画布;移动端首页触发项目或画布动作时使用同一门禁。桌面端新创作主页只调用编辑器项目和公开编辑器素材接口;顶栏保持现役搜索、公共泥点入口与账号胶囊,不重新拼装平行账号按钮组。 - “我的”桌面布局按原平台公共资料页全宽展示四个常用入口、两行设置和法律栏;头像、昵称、复制、充值、兑换码、社区、反馈、API Key 等入口可用,但不发起旧模板、旧公开作品或旧运行态请求。 - 鉴权访问 `GET/PUT /api/runtime/settings` 不得返回 404,读写必须经 `spacetime-client` 调用现役 settings procedure;未鉴权请求返回 401,不恢复任何旧运行态设置路由。 - `tsc --listFilesOnly` 与 Vite 干净加载均不得出现旧业务目录、上述顶层退役 module 或小程序旧订阅授权实现。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 61ed7e2aa..440bd9b5d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -635,7 +635,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_asset_group_source_provenance` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 3c41aae27..fbac43ea3 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -2,7 +2,7 @@ ## 背景 -外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态或生成结果放到前端临时状态中。 +外部调用方需要通过稳定 HTTP 契约或托管式远程 MCP 使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。不支持 MCP 的 Agent 还需要可发现、可校验、可完整下载的 Skill 包,而不是只有一份 OpenAPI JSON。全部入口必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态、生成任务或生成结果放到前端临时状态中。 ## v1 范围 @@ -32,24 +32,86 @@ v1 只开放以下能力: - `POST /api/external/v1/editor/assets`:创建素材记录。 - `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。 - `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。 -- `POST /api/external/v1/editor/images/generations`:调用编辑器图片素材生成能力;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。可选传入 `projectId` 和 `assetFolderId`,生成后按站内编辑器规则写入 `editor_project_resource` 和账号级 `editor_asset`。 -- `POST /api/external/v1/editor/images/edits`:重绘 / 调整已有图片,结果可写入项目资源和素材库。 -- `POST /api/external/v1/editor/icon-spritesheets/generations`:按规范图生成图标 spritesheet,并拆分为独立图标素材。 -- `POST /api/external/v1/editor/ui-designs/assets/extractions`:从 UI 设计图中提取 / 拆分素材。 -- `POST /api/external/v1/editor/character-animations/generations`:基于角色图片生成角色动画预览和帧序列。 -- `POST /api/external/v1/editor/videos/generations`:生成编辑器视频素材,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。 -- `POST /api/external/v1/editor/audios/sound-effects/generations`:生成编辑器音效素材。 -- `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。 +- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。 +- `POST /api/external/v1/editor/images/edits`:异步提交已有图片重绘 / 调整。 +- `POST /api/external/v1/editor/icon-spritesheets/generations`:异步提交规范图驱动的图标 spritesheet 生成和拆分。 +- `POST /api/external/v1/editor/ui-designs/assets/extractions`:异步提交 UI 设计图素材提取 / 拆分。 +- `POST /api/external/v1/editor/character-animations/generations`:异步提交角色动画预览和帧序列生成。 +- `POST /api/external/v1/editor/videos/generations`:异步提交编辑器视频生成,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。 +- `POST /api/external/v1/editor/audios/sound-effects/generations`:异步提交编辑器音效生成。 +- `POST /api/external/v1/editor/audios/background-music/generations`:异步提交编辑器背景音乐生成。 +- `GET /api/external/v1/generations/{operationId}`:按 API Key owner 查询异步生成状态;`completed` 时返回 compact 稳定结果引用,跨 owner 按不存在处理。 - `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。 +- `GET /api/external/v1/agent-integration.json`:公开导出 Agent 集成发现 manifest,声明 MCP、OpenAPI、Skill 入口、完整 Skill archive、archive SHA-256 和包内文件清单。 +- `GET /api/external/v1/skill/SKILL.md`:公开读取 Skill 原始入口。 +- `GET /api/external/v1/skill.zip`:公开下载完整 Skill 包。 +- `POST /api/external/v1/mcp`:使用相同 Bearer API Key 的托管式 Streamable HTTP MCP;对外暴露本节 OpenAPI operation tools 以及使用说明、OpenAPI、Skill 入口 `SKILL.md` 和逐个 Skill reference 文档,不开放内部 SpacetimeDB MCP。 -图片生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`。外部 OpenAPI 当前公开四个稳定 `code`: +八类生成 POST 全部要求 `Idempotency-Key`,成功只返回 HTTP `202 Accepted`、`operationId`、`kind`、`status`、`statusUrl`、`pollAfterMs` 和 `updatedAtMicros`。调用方不得把 `202` 当作媒体生成完成,也不得在网络结果不确定时换一个幂等键重新提交。 + +图片生成、图标 spritesheet 和 UI 素材提取的 completed compact `result` 可携带可选结构化 `warning { code, reason }`;任务查询顶层 `warning` 是可直接展示的有界摘要。外部 OpenAPI 当前公开四个稳定 `code`: - `postprocess-failed-source-preserved`:生成成功,但透明处理、像素规整等后处理未完成,接口保留仍可使用的原图或进入该步骤前的结果。 - `dimension-restore-fallback`:provider 回图无法安全收口到目标交付尺寸,接口保留实际回图尺寸。 - `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。 - `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。 -provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 `warning.reason`,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。 +provider 原图已保存但透明背景处理最终失败时,worker 保留原图稳定引用,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回。调用方应展示告警,但不得把 completed 任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍使任务失败。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥;风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,compact result 不得丢弃任一条。 + +## 异步提交、查询与幂等 + +外部生成不受 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响:无论站内本地排障模式如何配置,External v1 都只持久化入队并返回 `202`,不在 API 请求中同步执行 provider。正式状态源是既有 `external_generation_job`;生成核心、计费、OSS、画布写回、lease 续租和 fencing 继续由现役 worker 链路负责。 + +调用规则: + +1. 调用方为一次逻辑生成分配 `1-128` 字节、无空格的可打印 ASCII `Idempotency-Key`。 +2. 服务端以 owner、job kind、幂等键和规范请求建立稳定去重身份;同一请求的传输重试必须复用原键。 +3. `202` 响应通过 `Location` / `statusUrl` 指向 `/api/external/v1/generations/{operationId}`,并提供 `Retry-After` / `pollAfterMs`。 +4. `queued/running` 返回 phase、进度与下一次建议轮询间隔;`completed` 返回 `result`;`failed` 返回脱敏 `error`。调用方自己的轮询超时不改变任务状态。 +5. `result` 只保留稳定 `objectKey`、`resourceId`、`assetId`、`assetObjectId`、尺寸、媒体类型、taskId、warning 等轻量引用;禁止持久化完整 project/canvas、大型布局快照、Data URL、Blob URL、过期 signed URL、worker lease/fencing 字段和内部 provider 诊断。 +6. 需要完整项目或素材库状态时,调用方在 completed 后重新读取项目或素材库;需要下载媒体时,用稳定 `objectKey` 调 `/assets/read-url` 获取短期签名 URL。 + +结果查询使用 API Key owner 过滤。任务不存在、已删除或属于其他 owner 时统一返回 `404`,不能通过差异错误枚举他人 operationId。 + +## 托管远程 MCP + +`/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。 + +MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 + +MCP 暴露下列稳定文本资源: + +- `genarrative://external-editor/usage`:关键工作流和异步轮询规则。 +- `genarrative://external-editor/openapi`:完整 External v1 OpenAPI。 +- `genarrative://external-editor/skill`:Skill 入口原文;保留首版已声明的稳定 URI。 +- `genarrative://external-editor/skill/references/capability-routing.md`:能力选路与场景边界。 +- `genarrative://external-editor/skill/references/api-operations.md`:公开 API 操作、必填字段与调用顺序。 +- `genarrative://external-editor/skill/references/authentication-and-safety.md`:API Key 鉴权、幂等与安全边界。 +- `genarrative://external-editor/skill/references/requests-and-outputs.md`:异步提交、状态轮询与 compact 结果语义。 + +Skill 日后新增 `references/` 文档时,MCP 必须按包内相对路径逐个增加 `genarrative://external-editor/skill/references/` resource,不得只暴露 `SKILL.md` 而让 Agent 无法读取其引用。当前稳定 reference 精确为上述四篇,不得声明不存在的 reference。MCP Agent 直接调用托管 tools,不下载或安装 Python CLI;`scripts/`、`tests/` 和 `.github/workflows/` 不作为 MCP resources。 + +MCP 必须始终复用 `require_external_api_key`,owner 从 `ExternalApiPrincipal` 获取,不接受请求参数伪造 owner。禁止透传内部 `external_generation_job` procedure、worker controller、SpacetimeDB MCP 或 lease/fencing 控制面。 + +MCP 缺少、格式错误或无法验证 Bearer API Key 时仍返回 HTTP `401`,但不能只返回通用“未授权访问”。响应必须附带 `WWW-Authenticate: Bearer realm="genarrative-external-editor"`,并在安全 JSON `details.guide` 中给出稳定 `reason=MCP_AUTHENTICATION_REQUIRED`、`action=CONFIGURE_BEARER_API_KEY`、`Authorization: Bearer ` 格式、登录后前往「开发者 API Key」创建密钥、原始密钥只显示一次、不得粘贴到聊天或写入仓库、配置后重试 `initialize` 的结构化信息,以及公开 manifest、Skill 入口和 OpenAPI 地址。三种失败使用同一响应,不得通过文案或结构差异枚举 Key 是否存在;引导不得匿名暴露 tools、resources 或 owner 信息。`agent-integration.json` 的 `mcp.credentialSetup` 同步提供 action、Header 值格式、导航标签和公开 Skill 引导地址,不编造未纳入公开契约的账户页面 URL。 + +## Agent 集成发现与完整 Skill 包 + +`agent-integration.json` 是机器可读的统一发现入口。支持远程 MCP 的 Agent 读取其中 `mcp.transport/url/authentication`,通过 MCP resources 读取 Skill 入口和所需 references,直接调用 MCP tools,不安装 CLI。仅不支持 MCP,或需要在 Agent 所在机器上编排本地文件上传的调用方下载 `skill.archive`,核对 `archiveSha256`,解压后从 `genarrative-external-editor-api/SKILL.md` 进入。 + +Skill archive 必须至少包含: + +- `SKILL.md` +- `references/capability-routing.md` +- `references/api-operations.md` +- `references/authentication-and-safety.md` +- `references/requests-and-outputs.md` +- `scripts/genarrative_external_api.py` +- `agents/openai.yaml` + +包由 api-server 直接从仓库同源文件构建,不能只返回光秃秃的 OpenAPI JSON,也不能把个人 API Key、环境配置或本机路径写入包。完整 `skill.zip` 只服务不支持 MCP 或需要本地文件编排的 Agent,不是 MCP resource catalog 的压缩包镜像。Python helper 对上层保持便利的同步函数外观,但内部必须执行“异步提交 → 保存 operationId → 按 pollAfterMs 查询 → completed 返回 result”,查询超时应保留 operationId 供后续继续,不得换键重提。 + +api-server 使用 `include_str!` 嵌入 OpenAPI 与 Skill 源文件;容器构建阶段必须同时复制 `docs/openapi/` 和 `.codex/skills/genarrative-external-editor-api/`,不能只复制 `server-rs/`,否则本地 Cargo 验证虽可通过,隔离镜像构建会在编译期找不到同源资源。 管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON: @@ -78,6 +140,8 @@ DELETE /api/profile/api-keys/{keyId} 这是 breaking change,不是文档同步:严格反序列化的调用方(OpenAPI Generator 生成的 Java / Kotlin / C#、pydantic、serde 非 `Option` 字段)在 `required` 字段缺失时直接失败,且失败发生在服务端上线瞬间,不需要调用方做任何动作。脱敏目标本身成立,接受不升版本、不设弃用期的唯一依据是当前无存量调用方。 +2026-07-31 同一豁免还覆盖了「八类生成从同步成功响应切换为 `202 + operationId`,新增统一查询接口」这一 breaking change。旧调用方若仍把生成 POST 响应当作媒体结果会立即失败;接受原地修改 v1 的唯一依据同样是上线前已确认没有外部第三方存量调用方。托管 MCP、集成 manifest 与 Skill archive 均为新增入口,不产生既有客户端兼容债务。 + ### 豁免的失效条件 API Key 由用户在个人中心自助发放,因此「无外部调用方」不是受控状态,可能在无人决策的情况下变为假。本节豁免在下列任一条件出现后立即失效: @@ -113,7 +177,7 @@ Authorization: Bearer tnr_sk_xxx - 明文 Key 只在创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`。 - API Key 被撤销后立即不可再用于外部接口。 - 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。 -- OpenAPI JSON 公共可读,不需要鉴权。 +- OpenAPI JSON、Agent 集成 manifest、原始 Skill 入口和完整 Skill archive 公共可读,不需要鉴权;MCP 与全部业务操作需要鉴权。 ## 数据模型 @@ -142,19 +206,19 @@ SpacetimeDB procedure: ## 素材生成与落库 -外部生成接口复用站内编辑器已有 handler 和 DTO,不维护第二套生成语义: +外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义: -- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。 +- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。 - 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。 - 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。 - API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。 -素材外部生成成功后,后端拿到素材后: +素材外部生成由 worker 成功后: 1. 通过 OSS / asset object adapter 持久化媒体文件。 2. 写入 `editor_asset`,让生成素材进入账号级素材库。 3. 如果请求带 `projectId`,写入 `editor_project_resource`。 -4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model 和 taskId;普通 External API 响应、项目资源与素材 read model 不返回生成 provider 或内部抠图审计字段。同源画布前端自动提交默认 `segModel` 属于站内 BFF 请求契约,不因此向 External OpenAPI 开放该字段。 +4. 在 `result_payload_json` 保存 compact 稳定引用,由查询接口返回素材 ID、资源 ID、objectKey、尺寸、媒体类型、model、taskId 和 warning;普通 External API 响应、项目资源与素材 read model 不返回生成 provider、原始 prompt 或内部抠图审计字段。同源画布前端自动提交默认 `segModel` 属于站内 BFF 请求契约,不因此向 External OpenAPI 开放该字段。 如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。 @@ -177,17 +241,21 @@ OpenAPI 3.1 JSON 固定落在: docs/openapi/genarrative-external-v1.openapi.json ``` -服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。 +服务端 `GET /api/external/v1/openapi.json` 和 MCP OpenAPI resource 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。MCP tool catalog 同样以这份 OpenAPI 的 path、method、operationId、参数和 request body 是否存在为来源;字段级精确约束继续以 OpenAPI resource 为准。 ## 验收 - API Key 创建只返回一次明文,列表不返回明文。 - 撤销后的 API Key 调用外部接口返回 `401`。 -- 外部图片生成、重绘、图标拆分、UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。 -- 角色图、图标 spritesheet 和 UI 素材提取的 2xx 成功响应允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 +- 八类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。 +- 八类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。 +- 外部图片生成、重绘、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。 +- 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。 - 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。 - OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。 - OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。 +- `agent-integration.json` 能发现 MCP、OpenAPI、Skill entry/archive;下载 archive 的 SHA-256 与 manifest 一致,ZIP 包含 `SKILL.md`、四篇 references、Python helper 和 `agents/openai.yaml` 七个声明文件且不含凭据。 +- MCP 在无 Bearer、Bearer 格式错误或 Key 无效时返回相同的 `401 + WWW-Authenticate + details.guide` 鉴权引导,且不暴露 tools/resources/owner;合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md`、`skill/references/api-operations.md`、`skill/references/authentication-and-safety.md` 与 `skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。 - 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。 - 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。 - 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index b5d95a433..cf62e1db0 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-07-23` +更新时间:`2026-08-03` ## 标准开发流程 @@ -58,6 +58,10 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +AI 游戏创作客户端使用 `npm run agc`,开发态 game-chat 使用 `npm run agc:game-chat`。两个入口都先由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在 Tauri CLI 启动前检查固定地址 `http://127.0.0.1:3080/`:只有端口空闲时才继续启动。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;即使页面和 target 看似匹配,也不得复用已经存在的 3080。旧 worktree Vite、无响应监听器或非 AGC 服务一律在创建原生窗口前失败关闭,并提示先停止旧服务;启动器不擅自终止无法证明归属的进程。 + +Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:旧 3080 已就绪时,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对 3080 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 + Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg,`npm run dev` / `npm run dev:api-server` 会自动把该目录加入本次 `api-server` 子进程 `Path`,并注入 `CHARACTER_ANIMATION_FFMPEG_PATH` / `CHARACTER_ANIMATION_FFPROBE_PATH` 的绝对路径。这样即使外层终端或长期运行的 dev 进程是在安装 FFmpeg 之前启动,角色动画抽帧也不会继续因为 `ffmpeg: program not found` 失败;若手动配置了上述环境变量或 `GENARRATIVE_CHARACTER_ANIMATION_*` 前缀变量,显式配置优先。 diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 053e616b7..0a3214591 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -1,6 +1,6 @@ # 创作主页与项目入口改版计划 -> 2026-07-18 退役覆盖:本文关于旧模板入口、`/creation/`、移动端隐藏“创作 / 项目”和 `/api/creation-entry/config` 的内容均已被后续实现替代,只保留为阶段设计记录。现役口径是桌面侧边栏与移动端底部 dock 都显示“创作 / 项目 / 我的”,稳定路由为 `/creation`、`/project`、`/profile`;旧模板业务只保留历史数据壳。当前实现与验收以 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 +> 2026-08-03 纠正:2026-07-18 的旧模板退役只替代本文关于旧模板入口、`/creation/` 和 `/api/creation-entry/config` 的内容,不替代“移动端隐藏创作 / 项目并阻止进入画布”的既有边界。现役稳定路由仍为 `/creation`、`/project`、`/profile`,但创作主页、项目管理和图片画布只在桌面端挂载;移动端底部 dock 只保留“我的”,触发创作工具时显示桌面端提示。当前实现与验收以 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 日期:2026-06-18 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 013d80523..875a7a0ea 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -12,7 +12,7 @@ - `/project` 展示当前账号的图片编辑器项目,项目卡继续进入 `/editor/canvas`。 - `/profile` 是“我的”稳定路由,保留头像与昵称编辑、陶泥号复制、泥点余额与账单、累计统计、泥点充值、兑换码、玩家社区、反馈与建议、通用设置、开发者 API Key 和法律信息等平台公共能力。 - 桌面顶栏保留现役项目 / 素材搜索、泥点入口和账号胶囊。搜索只筛选当前编辑器项目与已读取的公开编辑器素材,不恢复旧公开作品号搜索、旧广场、旧作品详情或旧运行态。 -- 桌面端使用公共侧边栏,移动端使用同样包含“创作 / 项目 / 我的”的三项底部 dock;点击、刷新及浏览器前进 / 后退都必须保持 URL、标题和选中态一致。 +- 桌面端公共侧边栏固定显示“创作 / 项目 / 我的”;移动端底部 dock 只保留“我的”,不暴露“创作 / 项目”。移动端直达 `/creation`、`/project` 或 `/editor/canvas` 时显示桌面端创作提示,不挂载创作主页、项目列表或图片画布;从移动端首页触发项目或画布动作时也只显示同一提示。 现役入口和公共资料能力只能依赖 `creation-home`、`project`、`image-editor`、公共组件及 `services/platform-entry` 等现役模块。Vite 模块门禁会拒绝 `components/rpg-entry`、`services/rpg-entry`、旧玩法目录和旧平台业务模块进入依赖图;Tailwind `@source`、TypeScript `include`、ESLint ignore 或 Vite watch ignore 都不能替代这条运行时依赖门禁。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 2512dd437..655329af0 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -487,6 +487,15 @@ describe('AI 游戏创作 App 共享契约', () => { 'publish-strategy', 'publish-package', ]); + for (const directorId of [ + 'design-director', + 'art-director', + 'code-director', + ]) { + expect( + manifest.tasks.find((task) => task.id === directorId)?.dependencies, + ).toEqual([]); + } expect( manifest.tasks.find((task) => task.id === 'design-foundation'), ).toEqual({ @@ -495,7 +504,7 @@ describe('AI 游戏创作 App 共享契约', () => { group: 'design', role: 'Gameplay', status: 'pending', - dependencies: ['art-director'], + dependencies: ['design-director', 'art-director'], artifacts: [ 'memory/project.md', 'game/game_design.md', @@ -519,6 +528,14 @@ describe('AI 游戏创作 App 共享契约', () => { ], }, ); + expect( + manifest.tasks.find((task) => task.id === 'code-prototype')?.dependencies, + ).toEqual([ + 'code-director', + 'balance-seed', + 'art-polish', + 'audio-asset-plan', + ]); }); it('selects ready tasks from dependency status', () => { @@ -526,17 +543,16 @@ describe('AI 游戏创作 App 共享契约', () => { expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), - ).toEqual(['design-director']); + ).toEqual(['design-director', 'art-director', 'code-director']); - manifest.tasks.find((task) => task.id === 'design-director')!.status = - 'completed'; - - expect( - selectGameCreationAppReadyTasks(manifest).map((task) => task.id), - ).toEqual(['art-director']); - - manifest.tasks.find((task) => task.id === 'art-director')!.status = - 'completed'; + for (const directorId of [ + 'design-director', + 'art-director', + 'code-director', + ]) { + manifest.tasks.find((task) => task.id === directorId)!.status = + 'completed'; + } expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), @@ -548,6 +564,61 @@ describe('AI 游戏创作 App 共享契约', () => { expect( selectGameCreationAppReadyTasks(manifest).map((task) => task.id), ).toEqual(['balance-director', 'art-asset-plan', 'audio-director']); + + for (const taskId of [ + 'balance-director', + 'art-asset-plan', + 'audio-director', + ]) { + manifest.tasks.find((task) => task.id === taskId)!.status = 'completed'; + } + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['balance-seed', 'art-polish', 'audio-asset-plan']); + + manifest.tasks.find((task) => task.id === 'balance-seed')!.status = + 'completed'; + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['art-polish', 'audio-asset-plan']); + + for (const taskId of ['art-polish', 'audio-asset-plan']) { + manifest.tasks.find((task) => task.id === taskId)!.status = 'completed'; + } + expect( + selectGameCreationAppReadyTasks(manifest).map((task) => task.id), + ).toEqual(['code-prototype']); + }); + + it('keeps the formal seed task graph acyclic with known dependencies', () => { + const tasks = createGameCreationAppSeedTasks(); + const remaining = new Map( + tasks.map((task) => [task.id, new Set(task.dependencies)]), + ); + const knownIds = new Set(remaining.keys()); + expect( + tasks + .flatMap((task) => task.dependencies) + .every((id) => knownIds.has(id)), + ).toBe(true); + + let visited = 0; + while (remaining.size > 0) { + const readyIds = Array.from(remaining) + .filter(([, dependencies]) => dependencies.size === 0) + .map(([id]) => id); + expect(readyIds.length).toBeGreaterThan(0); + for (const id of readyIds) { + remaining.delete(id); + visited += 1; + } + for (const dependencies of remaining.values()) { + for (const id of readyIds) { + dependencies.delete(id); + } + } + } + expect(visited).toBe(tasks.length); }); it('keeps canvas asset source fields camelCase', () => { diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index eddefabf9..adcba62ba 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -266,7 +266,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'art', role: 'Director', status: 'pending', - dependencies: ['design-director'], + dependencies: [], artifacts: [ '.agent/passes/pass-*/groups/art/director.md', 'assets/art-spec.png', @@ -281,7 +281,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'design', role: 'Gameplay', status: 'pending', - dependencies: ['art-director'], + dependencies: ['design-director', 'art-director'], artifacts: [ 'memory/project.md', 'game/game_design.md', @@ -359,12 +359,7 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'code', role: 'Director', status: 'pending', - dependencies: [ - 'design-foundation', - 'balance-seed', - 'art-polish', - 'audio-asset-plan', - ], + dependencies: [], artifacts: ['.agent/passes/pass-*/groups/code/director.md'], acceptanceCriteria: ['渲染、输入、状态和数据读取边界明确'], }, @@ -374,7 +369,12 @@ export const GAME_CREATION_APP_SEED_TASKS = [ group: 'code', role: 'Code', status: 'pending', - dependencies: ['code-director'], + dependencies: [ + 'code-director', + 'balance-seed', + 'art-polish', + 'audio-asset-plan', + ], artifacts: ['game/'], acceptanceCriteria: ['本地 Web 游戏项目可以通过 HTTP server 打开'], }, diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 7c01f7be6..d3424f54c 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -231,6 +231,7 @@ dependencies = [ "platform-wechat", "reqwest", "ring", + "rmcp", "serde", "serde_json", "sha1", @@ -827,6 +828,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1932,6 +1944,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -3679,6 +3692,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -4516,6 +4535,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4554,6 +4584,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4720,6 +4756,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rmp" version = "0.8.15" @@ -4931,12 +4996,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5027,6 +5106,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -5670,6 +5760,19 @@ dependencies = [ "log", ] +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 44de73be6..dbf37e43e 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false } pingora-proxy = { version = "0.8.1", default-features = false } rand_core = "0.6" reqwest = { version = "0.12", default-features = false } +rmcp = { version = "=2.2.0", default-features = false } ring = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 27b3bc76c..ff0357094 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -16,6 +16,7 @@ hex = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] } webp = { workspace = true } module-ai = { workspace = true } module-assets = { workspace = true, features = ["server-service"] } @@ -48,6 +49,7 @@ tokio-stream = { workspace = true } futures-util = { workspace = true } time = { workspace = true, features = ["formatting"] } tower-http = { workspace = true, features = ["trace"] } +tower = { workspace = true, features = ["util"] } tracing = { workspace = true } opentelemetry = { workspace = true } url = { workspace = true } @@ -62,4 +64,3 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ base64 = { workspace = true } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } -tower = { workspace = true, features = ["util"] } diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b44a66496..5a47c01a9 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -48,7 +48,7 @@ use shared_contracts::assets::{ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, }; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ExternalGenerationJobRecord, SpacetimeClientError}; use crate::{ api_response::json_success_body, @@ -61,7 +61,7 @@ use crate::{ editor_generation_queue::{ EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, - editor_generation_source_entity_id, enqueue_editor_generation_job, + editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, @@ -578,38 +578,14 @@ pub async fn generate_editor_character_animation( )); } if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - character_animation_error_response( - &request_context, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })), - ) - })?; - // 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。 - let normalized = normalize_editor_character_animation_request_with_pricing( - payload.clone(), - &pricing, - crate::editor_green_screen::default_editor_screen_background_color(), - ) - .map_err(|error| character_animation_error_response(&request_context, error))?; - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - payload.source_layer_id.as_str(), - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_character_animation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成角色动作", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -627,6 +603,56 @@ pub async fn generate_editor_character_animation( .await } +pub(crate) async fn enqueue_editor_character_animation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: EditorCharacterAnimationGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } + let pricing = state.editor_generation_pricing().await.map_err(|error| { + character_animation_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + // 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。 + let normalized = normalize_editor_character_animation_request_with_pricing( + payload.clone(), + &pricing, + crate::editor_green_screen::default_editor_screen_background_color(), + ) + .map_err(|error| character_animation_error_response(request_context, error))?; + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + payload.source_layer_id.as_str(), + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成角色动作", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_character_animation_for_owner( state: AppState, request_context: RequestContext, @@ -970,31 +996,14 @@ pub async fn generate_editor_video( })?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - editor_video_error_response( - &request_context, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })), - ) - })?; - let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing) - .map_err(|error| editor_video_error_response(&request_context, error))?; - let source_entity_id = - editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video"); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_video_generation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_VIDEO_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成视频", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -1005,6 +1014,41 @@ pub async fn generate_editor_video( generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await } +pub(crate) async fn enqueue_editor_video_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: EditorVideoGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + let pricing = state.editor_generation_pricing().await.map_err(|error| { + editor_video_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| editor_video_error_response(request_context, error))?; + let source_entity_id = + editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video"); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_VIDEO_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成视频", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_video_for_owner( state: AppState, request_context: RequestContext, diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 640aa6dc9..248adf54a 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -1,6 +1,7 @@ use axum::http::StatusCode; use serde::Serialize; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; @@ -26,6 +27,7 @@ pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str = pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas"; const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; +const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -77,6 +79,132 @@ where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; + enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json, + job_id, + dedupe_key, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_external_api_editor_generation_job( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + idempotency_key: &str, +) -> Result +where + T: Serialize, +{ + let request_payload_json = serialize_editor_generation_job_payload(payload)?; + let mut hasher = Sha256::new(); + hasher.update(owner_user_id.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(job_kind.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(idempotency_key.as_bytes()); + let dedupe_key = format!( + "{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}", + hasher.finalize() + ); + let requested_job_id = build_prefixed_uuid_id("task-"); + let job = enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json.clone(), + requested_job_id.clone(), + dedupe_key, + ) + .await?; + + if job.job_kind != job_kind + || job.owner_user_id != owner_user_id + || job.request_payload_json != request_payload_json + { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + })), + ); + } + Ok(job) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_editor_generation_job_for_caller( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + external_idempotency_key: Option<&str>, +) -> Result +where + T: Serialize, +{ + let source_entity_id = source_entity_id.into(); + let request_label = request_label.into(); + match external_idempotency_key { + Some(idempotency_key) => { + enqueue_external_api_editor_generation_job( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + idempotency_key, + ) + .await + } + None => { + enqueue_editor_generation_job( + state, + request_context, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + ) + .await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn enqueue_serialized_editor_generation_job_with_identity( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + request_payload_json: String, + job_id: String, + dedupe_key: String, +) -> Result { let now_micros = current_utc_micros(); state .spacetime_client() @@ -164,12 +292,21 @@ fn is_inline_media_reference(value: &str) -> bool { pub(crate) fn editor_generation_queue_state( job: ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { + let (status, phase_detail, progress) = match job.status.as_str() { + "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" if job.phase.as_deref() == Some("processing") => { + (ExternalGenerationJobStatus::Running, "正在处理。", 70) + } + "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), + "failed" | "cancelled" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), + _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), + }; ExternalGenerationJobStatusRecord { operation_id: job.job_id, - status: ExternalGenerationJobStatus::Queued, + status, phase_label: job.request_label, - phase_detail: "排队中。".to_string(), - progress: 8, + phase_detail: phase_detail.to_string(), + progress, error: job.last_error_message, updated_at_micros: job.updated_at_micros, } @@ -194,6 +331,38 @@ fn current_utc_micros() -> i64 { mod tests { use super::*; + fn queue_job_fixture(status: &str, phase: Option<&str>) -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "task-queue-test".to_string(), + dedupe_key: "editor-canvas:test:task-queue-test".to_string(), + job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), + owner_user_id: "user-1".to_string(), + source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片画布生成图片".to_string(), + request_payload_json: "{}".to_string(), + status: status.to_string(), + attempt: 0, + max_attempts: 1, + last_error_message: None, + worker_id: None, + lease_expires_at: None, + available_at: "2026-07-31T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-07-31T00:00:00Z".to_string(), + started_at: None, + completed_at: None, + updated_at: "2026-07-31T00:00:00Z".to_string(), + updated_at_micros: 1_785_456_000_000_000, + lease_token: None, + price_mud_points: 2, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: phase.map(ToOwned::to_owned), + } + } + #[test] fn serialize_payload_accepts_persistable_media_references() { let payload = json!({ @@ -253,4 +422,72 @@ mod tests { assert_eq!(error.status_code().as_u16(), 413); assert!(error.body_text().contains("超过持久化上限")); } + + #[test] + fn queue_state_maps_idempotent_replays_to_the_persisted_status() { + let cases = [ + ( + "pending", + None, + ExternalGenerationJobStatus::Queued, + "排队中。", + 8, + ), + ( + "running", + Some("generating"), + ExternalGenerationJobStatus::Running, + "正在生成。", + 35, + ), + ( + "running", + Some("processing"), + ExternalGenerationJobStatus::Running, + "正在处理。", + 70, + ), + ( + "completed", + None, + ExternalGenerationJobStatus::Completed, + "生成已完成。", + 100, + ), + ( + "failed", + None, + ExternalGenerationJobStatus::Failed, + "生成失败。", + 0, + ), + ]; + + for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases + { + let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase)); + + assert_eq!(state.operation_id, "task-queue-test"); + assert_eq!(state.status, expected_status, "status={persisted_status}"); + assert_eq!( + state.phase_detail, expected_detail, + "status={persisted_status}" + ); + assert_eq!( + state.progress, expected_progress, + "status={persisted_status}" + ); + } + } + + #[test] + fn queue_state_keeps_failed_error_for_idempotent_replay() { + let mut job = queue_job_fixture("failed", None); + job.last_error_message = Some("生成失败摘要".to_string()); + + let state = editor_generation_queue_state(job); + + assert_eq!(state.status, ExternalGenerationJobStatus::Failed); + assert_eq!(state.error.as_deref(), Some("生成失败摘要")); + } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9457a2004..7a4d13361 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -51,7 +51,7 @@ use spacetime_client::{ EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, - SpacetimeClientError, + ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::{ @@ -63,7 +63,7 @@ use crate::{ EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, editor_generation_source_entity_id, - enqueue_editor_generation_job, + enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause, @@ -1592,59 +1592,15 @@ pub async fn generate_editor_image( Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, AppError> { - let Json(mut payload) = parse_editor_generation_json_payload(payload)?; - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-image-generation", - "referenceImageSrcs", - "生成参考图", - )?; - let normalized_kind = payload.kind.as_deref().map(str::trim); - let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); - let is_publication_material_generation = - matches!(normalized_kind, Some("publication-material")); - let generation_options = normalize_editor_generation_options( - if is_ui_design_generation || is_publication_material_generation { - Some(GPT_IMAGE_2_MODEL) - } else { - payload.model.as_deref() - }, - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ); - let price_mud_points = u64::from( - state - .editor_generation_pricing() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - })? - .image_generation_mud_points( - normalized_kind, - Some(generation_options.model), - Some(generation_options.image_size), - ), - ); - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-image-generation", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_image_generation_for_owner( &state, &request_context, - caller.owner_user_id.as_str(), - EDITOR_IMAGE_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成图片", - price_mud_points, - &payload, + &caller, + payload, + None, ) .await?; return Ok(json_success_body( @@ -1657,6 +1613,68 @@ pub async fn generate_editor_image( generate_editor_image_for_owner(&state, &request_context, caller, payload).await } +pub(crate) async fn enqueue_editor_image_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorImageGenerationRequest, + external_idempotency_key: Option<&str>, +) -> Result { + payload.generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; + let normalized_kind = payload.kind.as_deref().map(str::trim); + let is_ui_design_generation = matches!(normalized_kind, Some("ui-design")); + let is_publication_material_generation = + matches!(normalized_kind, Some("publication-material")); + let generation_options = normalize_editor_generation_options( + if is_ui_design_generation || is_publication_material_generation { + Some(GPT_IMAGE_2_MODEL) + } else { + payload.model.as_deref() + }, + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let price_mud_points = u64::from( + state + .editor_generation_pricing() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + })? + .image_generation_mud_points( + normalized_kind, + Some(generation_options.model), + Some(generation_options.image_size), + ), + ); + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + "editor-image-generation", + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_IMAGE_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成图片", + price_mud_points, + &payload, + external_idempotency_key, + ) + .await +} + pub(crate) async fn generate_editor_image_for_owner( state: &AppState, request_context: &RequestContext, @@ -1958,7 +1976,7 @@ pub(crate) async fn generate_editor_image_for_owner( caller.owner_user_id.as_str(), generated.task_id.as_str(), image, - submitted_prompt.as_str(), + role_setting.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, "character-images", @@ -2140,7 +2158,7 @@ pub(crate) async fn generate_editor_image_for_owner( ); } image = restored_removal_image; - output_prompt = "去除纯色背景".to_string(); + output_prompt = role_setting.clone(); output_actual_prompt = None; output_provider = removal_provider.to_string(); output_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( @@ -3571,55 +3589,13 @@ pub async fn edit_editor_image( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(mut payload): Json, + Json(payload): Json, ) -> Result, AppError> { - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-image-edit", - "sourceImageSrc", - "待修改图片", - )?; - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-image-edit", - "referenceImageSrcs", - "修改参考图", - )?; - ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) - .await?; - let generation_options = normalize_editor_image_edit_generation_options( - payload.model.as_deref(), - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - payload.size.as_deref(), - ); - let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); - let price_mud_points = u64::from( - resolve_editor_image_edit_price( - &state, - generation_options.model, - image_size.as_ref(), - Some(generation_options.image_size), - ) - .await?, - ); - let source_entity_id = - editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit"); - let queue_job = enqueue_editor_generation_job( - &state, - &request_context, - caller.owner_user_id.as_str(), - EDITOR_IMAGE_EDIT_JOB_KIND, - source_entity_id, - "图片画布修改图片", - price_mud_points, - &payload, - ) - .await?; + let queue_job = + enqueue_editor_image_edit_for_owner(&state, &request_context, &caller, payload, None) + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -3630,6 +3606,60 @@ pub async fn edit_editor_image( edit_editor_image_for_owner(&state, &request_context, caller, payload).await } +pub(crate) async fn enqueue_editor_image_edit_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorImageEditRequest, + external_idempotency_key: Option<&str>, +) -> Result { + payload.generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-image-edit", + "sourceImageSrc", + "待修改图片", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; + ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; + let generation_options = normalize_editor_image_edit_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + payload.size.as_deref(), + ); + let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let price_mud_points = u64::from( + resolve_editor_image_edit_price( + state, + generation_options.model, + image_size.as_ref(), + Some(generation_options.image_size), + ) + .await?, + ); + let source_entity_id = + editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-image-edit"); + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_IMAGE_EDIT_JOB_KIND, + source_entity_id, + "图片画布修改图片", + price_mud_points, + &payload, + external_idempotency_key, + ) + .await +} + pub(crate) async fn edit_editor_image_for_owner( state: &AppState, request_context: &RequestContext, @@ -4802,49 +4832,15 @@ pub async fn generate_editor_icon_spritesheet( Extension(authenticated): Extension, payload: Result, JsonRejection>, ) -> Result, AppError> { - let Json(mut payload) = parse_editor_generation_json_payload(payload)?; - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + let Json(payload) = parse_editor_generation_json_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { - ensure_editor_reference_image_source_is_stable( - payload.reference_image_src.as_str(), - "editor-icon-spritesheet", - "referenceImageSrc", - "图标素材规范", - )?; - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-icon-spritesheet", - "referenceImageSrcs", - "图标素材参考图", - )?; - let generation_options = normalize_editor_generation_options( - payload.model.as_deref(), - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ); - let price_mud_points = u64::from( - resolve_editor_icon_spritesheet_price( - &state, - Some(generation_options.model), - Some(generation_options.image_size), - ) - .await?, - ); - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-icon-spritesheet-generation", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_icon_spritesheet_generation_for_owner( &state, &request_context, - caller.owner_user_id.as_str(), - EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成图标素材", - price_mud_points, - &payload, + &caller, + payload, + None, ) .await?; return Ok(json_success_body( @@ -4857,6 +4853,58 @@ pub async fn generate_editor_icon_spritesheet( generate_editor_icon_spritesheet_for_owner(&state, &request_context, caller, payload).await } +pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorIconSpritesheetGenerationRequest, + external_idempotency_key: Option<&str>, +) -> Result { + payload.generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + ensure_editor_reference_image_source_is_stable( + payload.reference_image_src.as_str(), + "editor-icon-spritesheet", + "referenceImageSrc", + "图标素材规范", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let price_mud_points = u64::from( + resolve_editor_icon_spritesheet_price( + state, + Some(generation_options.model), + Some(generation_options.image_size), + ) + .await?, + ); + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + "editor-icon-spritesheet-generation", + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成图标素材", + price_mud_points, + &payload, + external_idempotency_key, + ) + .await +} + pub(crate) async fn generate_editor_icon_spritesheet_for_owner( state: &AppState, request_context: &RequestContext, @@ -4878,6 +4926,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标素材参考图", )?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + let user_prompt = icon_descriptions.join("\n"); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), true); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 @@ -4954,7 +5003,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, screen_color: requested_screen_color.clone(), - prompt: icon_descriptions.join("\n"), + prompt: user_prompt.clone(), icon_descriptions: icon_descriptions.clone(), reference_count, source_image_data_url: None, @@ -5033,7 +5082,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller.owner_user_id.as_str(), generated.task_id.as_str(), image, - prompt.as_str(), + user_prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", @@ -5052,7 +5101,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), width: source_width, height: source_height, - prompt: prompt.clone(), + prompt: user_prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), @@ -5218,7 +5267,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( owner_user_id.as_str(), generated.task_id.as_str(), &image, - "去除纯色背景", + user_prompt.as_str(), None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", @@ -5241,7 +5290,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, - prompt: "去除纯色背景".to_string(), + prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: removal_provider.to_string(), @@ -5284,7 +5333,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), group_task_id: None, - prompt: "自动拆分图集".to_string(), + prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), @@ -5866,50 +5915,16 @@ pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(mut payload): Json, + Json(payload): Json, ) -> Result, AppError> { - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-ui-design-asset-extraction", - "sourceImageSrc", - "UI设计图", - )?; - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-ui-design-asset-extraction", - "referenceImageSrcs", - "UI素材参考图", - )?; - let generation_options = normalize_editor_ui_design_asset_extraction_options( - payload.model.as_deref(), - payload.aspect_ratio.as_str(), - payload.image_size.as_str(), - )?; - let price_mud_points = u64::from( - resolve_editor_ui_design_asset_extraction_price( - &state, - Some(generation_options.model), - Some(generation_options.image_size), - ) - .await?, - ); - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-ui-design-asset-extraction", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_ui_design_asset_extraction_for_owner( &state, &request_context, - caller.owner_user_id.as_str(), - EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, - source_entity_id, - "图片画布提取UI设计图素材", - price_mud_points, - &payload, + &caller, + payload, + None, ) .await?; return Ok(json_success_body( @@ -5922,6 +5937,58 @@ pub async fn extract_editor_ui_design_assets( extract_editor_ui_design_assets_for_owner(&state, &request_context, caller, payload).await } +pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( + state: &AppState, + request_context: &RequestContext, + caller: &EditorGenerationCaller, + mut payload: EditorUiDesignAssetExtractionRequest, + external_idempotency_key: Option<&str>, +) -> Result { + payload.generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "editor-ui-design-asset-extraction", + "sourceImageSrc", + "UI设计图", + )?; + ensure_editor_reference_image_sources_are_stable( + payload.reference_image_srcs.as_deref(), + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; + let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.model.as_deref(), + payload.aspect_ratio.as_str(), + payload.image_size.as_str(), + )?; + let price_mud_points = u64::from( + resolve_editor_ui_design_asset_extraction_price( + state, + Some(generation_options.model), + Some(generation_options.image_size), + ) + .await?, + ); + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + "editor-ui-design-asset-extraction", + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + caller.owner_user_id.as_str(), + EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, + source_entity_id, + "图片画布提取UI设计图素材", + price_mud_points, + &payload, + external_idempotency_key, + ) + .await +} + pub(crate) async fn extract_editor_ui_design_assets_for_owner( state: &AppState, request_context: &RequestContext, @@ -8429,7 +8496,7 @@ async fn persist_editor_generated_image_data( task_id: &str, image: GeneratedImageAssetDataUrl, prompt: &str, - actual_prompt: Option<&str>, + _actual_prompt: Option<&str>, asset_kind: &str, path_kind: &str, file_stem: &str, @@ -8499,7 +8566,9 @@ async fn persist_editor_generated_image_data( AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, - Some(actual_prompt.unwrap_or(prompt).to_string()), + // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider + // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 + Some(prompt.to_string()), asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), @@ -11531,6 +11600,43 @@ mod tests { assert!(prompt.contains("角色设定:菜市场卖菜大妈")); } + #[test] + fn editor_generated_asset_persistence_keeps_user_prompt_separate_from_system_prompt() { + let source = include_str!("editor_project.rs"); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &[ + "image,\n role_setting.as_str(),\n generated.actual_prompt.as_deref(),", + "output_prompt = role_setting.clone();", + ], + ); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + &[ + "let user_prompt = icon_descriptions.join(\"\\n\");", + "image,\n user_prompt.as_str(),\n generated.actual_prompt.as_deref(),", + "prompt: user_prompt.clone(),", + "&image,\n user_prompt.as_str(),\n None,", + ], + ); + assert_function_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["Some(prompt.to_string())"], + ); + assert_function_not_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["actual_prompt.unwrap_or(prompt)"], + ); + } + #[test] fn editor_canvas_generation_completion_inserts_result_layer_and_keeps_composer_closed() { let layers = json!([ diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs index e7ee85b29..5f6f5044d 100644 --- a/server-rs/crates/api-server/src/external_api_auth.rs +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -1,9 +1,13 @@ use axum::{ extract::{Request, State}, - http::{HeaderMap, StatusCode, header::AUTHORIZATION}, + http::{ + HeaderMap, HeaderValue, StatusCode, + header::{AUTHORIZATION, WWW_AUTHENTICATE}, + }, middleware::Next, response::Response, }; +use serde_json::json; use spacetime_client::ExternalApiKeyAuthenticateRecordInput; use tracing::warn; @@ -74,6 +78,71 @@ pub async fn require_external_api_key( Ok(response) } +pub async fn require_external_mcp_api_key( + State(state): State, + request: Request, + next: Next, +) -> Result { + let request_context = request.extensions().get::().cloned(); + match require_external_api_key(State(state), request, next).await { + Ok(response) => Ok(response), + Err(error) if error.status_code() == StatusCode::UNAUTHORIZED => { + Ok(map_external_mcp_authentication_error(error) + .into_response_with_context(request_context.as_ref())) + } + Err(error) => Err(error), + } +} + +fn map_external_mcp_authentication_error(error: AppError) -> AppError { + debug_assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED); + external_mcp_authentication_guide_error() +} + +fn external_mcp_authentication_guide_error() -> AppError { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("连接陶泥儿托管 MCP 需要开发者 API Key") + .with_details(json!({ + "guide": { + "reason": "MCP_AUTHENTICATION_REQUIRED", + "action": "CONFIGURE_BEARER_API_KEY", + "authentication": { + "scheme": "Bearer", + "header": "Authorization", + "valueFormat": "Bearer " + }, + "keyManagement": { + "navigationLabel": "开发者 API Key", + "rawKeyShownOnce": true + }, + "retry": { + "method": "POST", + "path": "/api/external/v1/mcp", + "rpcMethod": "initialize" + }, + "steps": [ + "登录陶泥儿,在「开发者 API Key」中创建密钥;原始密钥只显示一次", + "把密钥配置为 MCP 连接的 Bearer token;不要粘贴到聊天或写入仓库", + "使用相同 MCP URL 重新发送 initialize" + ], + "credentialSafety": { + "rawKeyShownOnce": true, + "neverPasteIntoChat": true, + "neverStoreInRepository": true + }, + "publicDiscovery": { + "manifest": "/api/external/v1/agent-integration.json", + "skill": "/api/external/v1/skill/SKILL.md", + "openapi": "/api/external/v1/openapi.json" + } + } + })) + .with_header( + WWW_AUTHENTICATE.as_str(), + HeaderValue::from_static("Bearer realm=\"genarrative-external-editor\""), + ) +} + fn extract_external_api_bearer(headers: &HeaderMap) -> Result { let authorization = headers .get(AUTHORIZATION) @@ -89,3 +158,32 @@ fn extract_external_api_bearer(headers: &HeaderMap) -> Result .map(ToOwned::to_owned) .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_authentication_guide_replaces_sensitive_key_diagnostics() { + let error = AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ + "provider": "external-api-key", + "message": "SENSITIVE_KEY_LURE 不存在或已失效" + })); + + let mapped = map_external_mcp_authentication_error(error); + let serialized = serde_json::to_string( + mapped + .details() + .expect("mapped authentication error should contain guide details"), + ) + .expect("guide should serialize"); + assert_eq!(mapped.status_code(), StatusCode::UNAUTHORIZED); + assert_eq!(mapped.message(), "连接陶泥儿托管 MCP 需要开发者 API Key"); + assert!(serialized.contains("MCP_AUTHENTICATION_REQUIRED")); + assert!(serialized.contains("CONFIGURE_BEARER_API_KEY")); + assert!(!serialized.contains("SENSITIVE_KEY_LURE")); + assert!(!serialized.contains("provider")); + assert!(!serialized.contains("不存在")); + assert!(!serialized.contains("已失效")); + } +} diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 583203d07..1da75d7b5 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -1,46 +1,57 @@ use axum::{ Json, extract::{Extension, Path, State, rejection::JsonRejection}, - http::{StatusCode, header::CONTENT_TYPE}, + http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse, + ExternalGenerationJobStatus, +}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, + EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::{ api_response::json_success_body, character_animation_assets::{ - generate_editor_character_animation_for_owner, generate_editor_video_for_owner, + enqueue_editor_character_animation_for_owner, enqueue_editor_video_generation_for_owner, }, + editor_generation_queue::editor_generation_queue_state, editor_project::{ EDITOR_ASSET_FOLDER_ID_PREFIX, EDITOR_ASSET_ID_PREFIX, EDITOR_PROJECT_DEFAULT_TITLE, EDITOR_PROJECT_ID_PREFIX, EDITOR_RESOURCE_ID_PREFIX, EditorAssetFolderPayload, EditorAssetLibraryPayload, EditorAssetPayload, EditorCanvasViewportPayload, EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, EditorProjectPayload, EditorProjectResourcePayload, - EditorUiDesignAssetExtractionRequest, current_utc_micros, edit_editor_image_for_owner, + EditorUiDesignAssetExtractionRequest, current_utc_micros, editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, editor_asset_payload_from_record, editor_project_payload_from_record, - editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, + editor_project_resource_payload_from_record, + enqueue_editor_icon_spritesheet_generation_for_owner, enqueue_editor_image_edit_for_owner, + enqueue_editor_image_generation_for_owner, + enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, + normalize_editor_persisted_media_src, normalize_optional_string, parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs, save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, }, external_api_auth::ExternalApiPrincipal, + external_generation::map_external_generation_job_status_detail, http_error::AppError, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ - generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, + enqueue_editor_background_music_generation_for_owner, + enqueue_editor_sound_effect_generation_for_owner, }, }; @@ -51,6 +62,8 @@ const SCOPE_EDITOR_IMAGE_GENERATE: &str = "editor:image-generate"; const SCOPE_EDITOR_ASSET: &str = "editor:asset"; const OPENAPI_JSON: &str = include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); +const EXTERNAL_GENERATION_POLL_AFTER_MS: u64 = 1_500; +const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -609,140 +622,328 @@ pub async fn generate_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, -) -> Result, AppError> { +) -> Result { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_image_for_owner( + let idempotency_key = require_idempotency_key(&headers)?; + let project_id = payload.project_id.clone(); + let job = enqueue_editor_image_generation_for_owner( &state, &request_context, - editor_generation_caller(&principal, payload.project_id.clone()), + &editor_generation_caller(&principal, project_id), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn edit_external_editor_image( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, Json(payload): Json, -) -> Result, AppError> { +) -> Result { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - edit_editor_image_for_owner( + let idempotency_key = require_idempotency_key(&headers)?; + let project_id = payload.project_id.clone(); + let job = enqueue_editor_image_edit_for_owner( &state, &request_context, - editor_generation_caller(&principal, payload.project_id.clone()), + &editor_generation_caller(&principal, project_id), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, -) -> Result, AppError> { +) -> Result { let Json(payload) = parse_editor_generation_json_payload(payload)?; require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_icon_spritesheet_for_owner( + let idempotency_key = require_idempotency_key(&headers)?; + let project_id = payload.project_id.clone(); + let job = enqueue_editor_icon_spritesheet_generation_for_owner( &state, &request_context, - editor_generation_caller(&principal, payload.project_id.clone()), + &editor_generation_caller(&principal, project_id), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn extract_external_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, Json(payload): Json, -) -> Result, AppError> { +) -> Result { require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - extract_editor_ui_design_assets_for_owner( + let idempotency_key = require_idempotency_key(&headers)?; + let project_id = payload.project_id.clone(); + let job = enqueue_editor_ui_design_asset_extraction_for_owner( &state, &request_context, - editor_generation_caller(&principal, payload.project_id.clone()), + &editor_generation_caller(&principal, project_id), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_character_animation( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result< Json, JsonRejection, >, -) -> Result, Response> { +) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_character_animation_for_owner( - state, - request_context, - principal.owner_user_id().to_string(), + let idempotency_key = require_idempotency_key(&headers) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; + let job = enqueue_editor_character_animation_for_owner( + &state, + &request_context, + principal.owner_user_id(), payload, - None, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_video( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, -) -> Result, Response> { +) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_video_for_owner( - state, - request_context, - principal.owner_user_id().to_string(), + let idempotency_key = require_idempotency_key(&headers) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; + let job = enqueue_editor_video_generation_for_owner( + &state, + &request_context, + principal.owner_user_id(), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_sound_effect( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result< Json, JsonRejection, >, -) -> Result, Response> { +) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_sound_effect_for_owner( - state, - request_context, - principal.owner_user_id().to_string(), + let idempotency_key = require_idempotency_key(&headers) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; + let job = enqueue_editor_sound_effect_generation_for_owner( + &state, + &request_context, + principal.owner_user_id(), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) } pub async fn generate_external_editor_background_music( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + headers: HeaderMap, payload: Result< Json, JsonRejection, >, -) -> Result, Response> { +) -> Result { require_scope_response(&request_context, &principal, SCOPE_EDITOR_IMAGE_GENERATE)?; - generate_editor_background_music_for_owner( - state, - request_context, - principal.owner_user_id().to_string(), + let idempotency_key = require_idempotency_key(&headers) + .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + let Json(payload) = parse_external_generation_json_payload(&request_context, payload)?; + let job = enqueue_editor_background_music_generation_for_owner( + &state, + &request_context, + principal.owner_user_id(), payload, + Some(idempotency_key), ) - .await + .await?; + Ok(external_generation_accepted_response(&request_context, job)) +} + +pub async fn get_external_editor_generation_job( + State(state): State, + Path(operation_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + let input = ExternalGenerationJobGetRecordInput { + job_id: operation_id, + owner_user_id: principal.owner_user_id().to_string(), + }; + let summary = state + .spacetime_client() + .get_external_generation_job_summary(input.clone()) + .await + .map_err(map_external_generation_lookup_error)?; + let detail = map_external_generation_job_status_detail(summary.clone()); + let result = if detail.status.status == ExternalGenerationJobStatus::Completed { + let artifacts = state + .spacetime_client() + .get_external_generation_job_generated_artifacts(input) + .await + .map_err(map_external_generation_lookup_error)?; + let payload = artifacts + .result_payload_json + .as_deref() + .and_then(|payload| serde_json::from_str::(payload).ok()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": "生成任务已完成,但结果暂时不可读取。", + })) + })?; + Some(payload.get("result").cloned().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": "生成任务已完成,但稳定结果引用缺失。", + })) + })?) + } else { + None + }; + let poll_after_ms = matches!( + detail.status.status, + ExternalGenerationJobStatus::Queued | ExternalGenerationJobStatus::Running + ) + .then_some(EXTERNAL_GENERATION_POLL_AFTER_MS); + + Ok(json_success_body( + Some(&request_context), + ExternalEditorGenerationJobResponse { + operation_id: detail.status.operation_id, + kind: summary.job_kind, + status: detail.status.status, + phase_label: detail.status.phase_label, + phase_detail: detail.status.phase_detail, + progress: detail.status.progress, + error: detail.status.error, + warning: detail.warning, + result, + poll_after_ms, + updated_at_micros: detail.status.updated_at_micros, + }, + )) +} + +fn external_generation_accepted_response( + request_context: &RequestContext, + job: ExternalGenerationJobRecord, +) -> Response { + let kind = job.job_kind.clone(); + let status = editor_generation_queue_state(job); + let status_url = format!("/api/external/v1/generations/{}", status.operation_id); + let mut response = ( + StatusCode::ACCEPTED, + json_success_body( + Some(request_context), + ExternalEditorGenerationSubmissionResponse { + operation_id: status.operation_id, + kind, + status: status.status, + status_url: status_url.clone(), + poll_after_ms: EXTERNAL_GENERATION_POLL_AFTER_MS, + updated_at_micros: status.updated_at_micros, + }, + ), + ) + .into_response(); + if let Ok(location) = HeaderValue::from_str(&status_url) { + response.headers_mut().insert("location", location); + } + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("2")); + response +} + +fn require_idempotency_key(headers: &HeaderMap) -> Result<&str, AppError> { + let value = headers + .get(IDEMPOTENCY_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": "生成请求必须携带 Idempotency-Key 请求头。", + })) + })?; + if value.len() > 128 || !value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": "Idempotency-Key 必须是 1-128 个可打印 ASCII 字符,且不能包含空格。", + })), + ); + } + Ok(value) +} + +fn parse_external_generation_json_payload( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result, Response> { + payload.map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": error.body_text(), + })) + .into_response_with_context(Some(request_context)) + }) +} + +fn map_external_generation_lookup_error(error: SpacetimeClientError) -> AppError { + if error.to_string().contains("不存在") { + AppError::from_status(StatusCode::NOT_FOUND) + } else { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": EXTERNAL_EDITOR_PROVIDER, + "message": "生成任务状态暂时不可用。", + })) + } } fn editor_generation_caller( @@ -797,6 +998,47 @@ fn serialize_external_editor_generation_inputs( mod tests { use super::*; + fn external_generation_job_fixture(status: &str) -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "task-external-test".to_string(), + dedupe_key: "external-api-generation:editor_image_generation:fingerprint".to_string(), + job_kind: "editor_image_generation".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "editor-canvas".to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片画布生成图片".to_string(), + request_payload_json: "{}".to_string(), + status: status.to_string(), + attempt: 0, + max_attempts: 1, + last_error_message: None, + worker_id: None, + lease_expires_at: None, + available_at: "2026-07-31T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-07-31T00:00:00Z".to_string(), + started_at: None, + completed_at: None, + updated_at: "2026-07-31T00:00:00Z".to_string(), + updated_at_micros: 1_785_456_000_000_000, + lease_token: None, + price_mud_points: 2, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: None, + } + } + + fn request_context(wants_envelope: bool) -> RequestContext { + RequestContext::new( + "req-external-generation-test".to_string(), + "POST /api/external/v1/editor/images/generations".to_string(), + std::time::Duration::ZERO, + wants_envelope, + ) + } + #[test] fn external_editor_canvas_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ @@ -835,6 +1077,117 @@ mod tests { assert!(parsed.get("mattingModel").is_none()); } + #[test] + fn external_generation_requires_bounded_printable_idempotency_key() { + let missing = HeaderMap::new(); + let error = require_idempotency_key(&missing).expect_err("外部生成必须显式提供幂等键"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert!(error.body_text().contains("Idempotency-Key")); + + let mut whitespace = HeaderMap::new(); + whitespace.insert(IDEMPOTENCY_KEY_HEADER, HeaderValue::from_static(" ")); + assert!(require_idempotency_key(&whitespace).is_err()); + + let mut valid = HeaderMap::new(); + let longest_valid = "x".repeat(128); + valid.insert( + IDEMPOTENCY_KEY_HEADER, + HeaderValue::from_str(&longest_valid).expect("128 字节可打印 ASCII 应是合法 header"), + ); + assert_eq!( + require_idempotency_key(&valid).expect("边界长度幂等键应通过"), + longest_valid + ); + + for invalid in [ + "x".repeat(129), + "contains space".to_string(), + "中文".to_string(), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + IDEMPOTENCY_KEY_HEADER, + HeaderValue::from_str(&invalid).expect("测试值应可构造为 HTTP header"), + ); + let error = require_idempotency_key(&headers) + .expect_err("超长、含空格或非 ASCII 的幂等键必须拒绝"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + } + + #[tokio::test] + async fn external_generation_submission_is_accepted_with_poll_contract() { + let response = external_generation_accepted_response( + &request_context(false), + external_generation_job_fixture("pending"), + ); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + Some("/api/external/v1/generations/task-external-test") + ); + assert_eq!( + response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()), + Some("2") + ); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("submission body 应可读取"); + let payload: Value = serde_json::from_slice(&body).expect("submission body 应为 JSON"); + + assert_eq!(payload["operationId"], json!("task-external-test")); + assert_eq!(payload["kind"], json!("editor_image_generation")); + assert_eq!(payload["status"], json!("queued")); + assert_eq!( + payload["statusUrl"], + json!("/api/external/v1/generations/task-external-test") + ); + assert_eq!( + payload["pollAfterMs"], + json!(EXTERNAL_GENERATION_POLL_AFTER_MS) + ); + } + + #[tokio::test] + async fn idempotent_completed_submission_reports_completed_in_envelope() { + let response = external_generation_accepted_response( + &request_context(true), + external_generation_job_fixture("completed"), + ); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("submission envelope 应可读取"); + let payload: Value = serde_json::from_slice(&body).expect("submission envelope 应为 JSON"); + + assert_eq!(payload["ok"], json!(true)); + assert_eq!(payload["data"]["operationId"], json!("task-external-test")); + assert_eq!(payload["data"]["status"], json!("completed")); + assert_eq!( + payload["data"]["pollAfterMs"], + json!(EXTERNAL_GENERATION_POLL_AFTER_MS) + ); + } + + #[test] + fn generation_lookup_hides_cross_owner_jobs_as_not_found() { + let not_found = map_external_generation_lookup_error(SpacetimeClientError::Procedure( + "external_generation_job 不存在".to_string(), + )); + assert_eq!(not_found.status_code(), StatusCode::NOT_FOUND); + + let unavailable = + map_external_generation_lookup_error(SpacetimeClientError::ConnectDropped); + assert_eq!(unavailable.status_code(), StatusCode::BAD_GATEWAY); + assert!(!unavailable.body_text().contains("ConnectDropped")); + } + #[test] fn exported_openapi_json_contains_external_editor_routes_and_security() { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); @@ -890,6 +1243,43 @@ mod tests { .get("/api/external/v1/editor/images/generations") .is_some() ); + for path in [ + "/api/external/v1/editor/images/generations", + "/api/external/v1/editor/images/edits", + "/api/external/v1/editor/icon-spritesheets/generations", + "/api/external/v1/editor/ui-designs/assets/extractions", + "/api/external/v1/editor/character-animations/generations", + "/api/external/v1/editor/videos/generations", + "/api/external/v1/editor/audios/sound-effects/generations", + "/api/external/v1/editor/audios/background-music/generations", + ] { + let operation = &parsed["paths"][path]["post"]; + assert!(operation["responses"].get("202").is_some(), "{path}"); + assert!(operation["responses"].get("200").is_none(), "{path}"); + assert!( + operation["parameters"] + .as_array() + .is_some_and(|parameters| { + parameters.iter().any(|parameter| { + parameter.get("$ref").and_then(Value::as_str) + == Some("#/components/parameters/IdempotencyKey") + }) + }) + ); + } + assert!( + parsed["paths"] + .get("/api/external/v1/generations/{operationId}") + .is_some() + ); + for path in [ + "/api/external/v1/agent-integration.json", + "/api/external/v1/skill/SKILL.md", + "/api/external/v1/skill.zip", + "/api/external/v1/mcp", + ] { + assert!(parsed["paths"].get(path).is_some(), "{path}"); + } assert!( parsed["components"]["schemas"]["EditorImageGenerationRequest"]["required"] .as_array() diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index b3ad859d3..1fba1ce65 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -217,7 +217,7 @@ fn user_visible_external_generation_error(job_kind: &str, error: Option) error } -fn map_external_generation_job_status_detail( +pub(crate) fn map_external_generation_job_status_detail( job: ExternalGenerationJobSummaryRecord, ) -> ExternalGenerationJobStatusDetailRecord { let warning = job.warning_message.clone(); diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 2c3db1624..b2b3bdcc9 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1213,6 +1213,14 @@ fn editor_generation_result_payload_json( compact_editor_generation_result(response.clone()), ); } + if is_external_api_generation_job(job) + && let Some(object) = payload.as_object_mut() + { + object.insert( + "result".to_string(), + compact_external_api_generation_result(response.clone()), + ); + } if let Some(warning) = extract_editor_generation_warning(response) && let Some(object) = payload.as_object_mut() { @@ -1221,6 +1229,12 @@ fn editor_generation_result_payload_json( payload.to_string() } +fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool { + job.dedupe_key + .trim() + .starts_with("external-api-generation:") +} + fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { serde_json::from_str::(job.request_payload_json.as_str()) .ok() @@ -1289,6 +1303,177 @@ fn compact_editor_generation_result(mut result: Value) -> Value { result } +fn compact_external_api_generation_result(result: Value) -> Value { + let mut result = result.get("data").cloned().unwrap_or(result); + let Some(object) = result.as_object_mut() else { + return Value::Null; + }; + object.retain(|key, _| { + matches!( + key.as_str(), + "ok" | "imageSrc" + | "videoSrc" + | "audioSrc" + | "previewVideoPath" + | "thumbnailSrc" + | "objectKey" + | "assetObjectId" + | "width" + | "height" + | "sourceType" + | "model" + | "taskId" + | "durationSeconds" + | "resolution" + | "priceMudPoints" + | "audioKind" + | "spritesheetImageSrc" + | "spritesheetWidth" + | "spritesheetHeight" + | "iconImageSrcs" + | "frames" + | "frameCount" + | "frameWidth" + | "frameHeight" + | "fps" + | "resource" + | "asset" + | "spritesheetResource" + | "spritesheetAsset" + | "warning" + | "sliceWarning" + ) + }); + for field in ["resource", "spritesheetResource"] { + if let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) { + compact_external_generation_resource(resource); + } + } + for field in ["asset", "spritesheetAsset"] { + if let Some(asset) = object.get_mut(field).and_then(Value::as_object_mut) { + compact_external_generation_asset(asset); + } + } + if let Some(icons) = object + .get_mut("iconImageSrcs") + .and_then(Value::as_array_mut) + { + for icon in icons { + let Some(icon) = icon.as_object_mut() else { + continue; + }; + icon.retain(|key, _| { + matches!( + key.as_str(), + "name" | "imageSrc" | "objectKey" | "width" | "height" | "resource" | "asset" + ) + }); + if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) { + compact_external_generation_resource(resource); + } + if let Some(asset) = icon.get_mut("asset").and_then(Value::as_object_mut) { + compact_external_generation_asset(asset); + } + remove_unstable_external_generation_media_fields(icon); + } + } + if let Some(frames) = object.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + let Some(frame) = frame.as_object_mut() else { + continue; + }; + frame.retain(|key, _| { + matches!( + key.as_str(), + "frameIndex" | "imageSrc" | "objectKey" | "width" | "height" + ) + }); + remove_unstable_external_generation_media_fields(frame); + } + } + for field in ["warning", "sliceWarning"] { + if let Some(warning) = object.get_mut(field).and_then(Value::as_object_mut) + && let Some(reason) = warning.get_mut("reason") + && let Some(value) = reason.as_str() + { + *reason = Value::String(normalize_editor_generation_warning_reason(value)); + } + } + remove_unstable_external_generation_media_fields(object); + result +} + +fn compact_external_generation_resource(resource: &mut serde_json::Map) { + resource.retain(|key, _| { + matches!( + key.as_str(), + "resourceId" + | "projectId" + | "objectKey" + | "assetObjectId" + | "imageSrc" + | "width" + | "height" + | "sourceType" + | "assetKind" + | "taskId" + ) + }); + remove_unstable_external_generation_media_fields(resource); +} + +fn compact_external_generation_asset(asset: &mut serde_json::Map) { + asset.retain(|key, _| { + matches!( + key.as_str(), + "assetId" + | "folderId" + | "objectKey" + | "assetObjectId" + | "imageSrc" + | "thumbnailSrc" + | "width" + | "height" + | "sourceType" + | "assetKind" + | "taskId" + ) + }); + remove_unstable_external_generation_media_fields(asset); +} + +fn remove_unstable_external_generation_media_fields(object: &mut serde_json::Map) { + object.retain(|key, value| { + if !matches!( + key.as_str(), + "imageSrc" + | "videoSrc" + | "audioSrc" + | "previewVideoPath" + | "thumbnailSrc" + | "spritesheetImageSrc" + ) { + return true; + } + value + .as_str() + .is_some_and(is_stable_external_generation_media_reference) + }); +} + +fn is_stable_external_generation_media_reference(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && value.starts_with('/') + && !value.starts_with("//") + && !value.contains('?') + && !value.contains('#') + && !value.to_ascii_lowercase().starts_with("data:") + && !value.to_ascii_lowercase().starts_with("blob:") + && !value.to_ascii_lowercase().starts_with("http://") + && !value.to_ascii_lowercase().starts_with("https://") +} + fn is_editor_internal_processing_model(model: &str) -> bool { matches!( model.trim().to_ascii_lowercase().as_str(), @@ -1348,7 +1533,13 @@ fn extract_editor_generation_warning(response: &Value) -> Option { fn normalize_editor_generation_warning_reason(reason: &str) -> String { let normalized = reason.to_ascii_lowercase(); - if normalized.contains("data:") || normalized.contains("blob:") { + if normalized.contains("data:") + || normalized.contains("blob:") + || normalized.contains("http://") + || normalized.contains("https://") + || normalized.contains("x-amz-") + || normalized.contains("signature=") + { return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string(); } let mut chars = reason.chars(); @@ -2051,6 +2242,135 @@ mod tests { assert!(!payload.to_string().contains("data:image")); } + #[test] + fn external_api_result_keeps_stable_artifacts_and_removes_unstable_media() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.dedupe_key = "external-api-generation:editor_image_generation:fingerprint".to_string(); + let response = json!({ + "data": { + "imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", + "videoSrc": "blob:https://example.test/video", + "audioSrc": "https://cdn.example.test/audio.mp3?X-Amz-Signature=secret", + "previewVideoPath": "https://cdn.example.test/stable-looking-but-external.mp4", + "thumbnailSrc": "/api/assets/object/thumbnail.png?expires=1&signature=secret", + "objectKey": "users/user-1/generated/main.png", + "assetObjectId": "asset-object-main", + "width": 1024, + "height": 1024, + "provider": "internal-provider-must-not-persist", + "resource": { + "resourceId": "resource-main", + "projectId": "project-1", + "objectKey": "users/user-1/generated/main.png", + "assetObjectId": "asset-object-main", + "imageSrc": "https://cdn.example.test/main.png?signature=secret", + "width": 1024, + "height": 1024, + "prompt": "不应复制完整资源元数据" + }, + "asset": { + "assetId": "asset-main", + "folderId": "folder-1", + "objectKey": "users/user-1/generated/main.png", + "assetObjectId": "asset-object-main", + "imageSrc": "/api/assets/object/main.png", + "thumbnailSrc": "https://cdn.example.test/thumb.png?signature=secret", + "width": 1024, + "height": 1024, + "generationInputs": {"private": true} + }, + "project": { + "projectId": "project-1", + "canvas": {"layers": ["large-layout-must-not-persist"]} + }, + "warning": { + "code": "dimension-restore-fallback", + "reason": "已保留 provider 实际输出尺寸。" + } + }, + "meta": { + "requestId": "worker-envelope-must-not-persist" + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("外部生成结果应是合法 JSON"); + let result = &payload["result"]; + + assert!(result.get("project").is_none()); + assert!(result.get("provider").is_none()); + for unstable_field in [ + "imageSrc", + "videoSrc", + "audioSrc", + "previewVideoPath", + "thumbnailSrc", + ] { + assert!( + result.get(unstable_field).is_none(), + "不稳定媒体字段 {unstable_field} 不得持久化" + ); + } + assert_eq!( + result["objectKey"], + json!("users/user-1/generated/main.png") + ); + assert_eq!(result["assetObjectId"], json!("asset-object-main")); + assert_eq!(result["resource"]["resourceId"], json!("resource-main")); + assert_eq!( + result["resource"]["objectKey"], + json!("users/user-1/generated/main.png") + ); + assert!(result["resource"].get("imageSrc").is_none()); + assert!(result["resource"].get("prompt").is_none()); + assert_eq!(result["asset"]["assetId"], json!("asset-main")); + assert_eq!( + result["asset"]["imageSrc"], + json!("/api/assets/object/main.png") + ); + assert!(result["asset"].get("thumbnailSrc").is_none()); + assert!(result["asset"].get("generationInputs").is_none()); + assert_eq!( + result["warning"], + json!({ + "code": "dimension-restore-fallback", + "reason": "已保留 provider 实际输出尺寸。" + }) + ); + assert_eq!(payload["warning"], result["warning"]); + assert!(result.get("prompt").is_none()); + assert!(result.get("actualPrompt").is_none()); + let serialized = payload.to_string().to_ascii_lowercase(); + for forbidden in [ + "data:", + "blob:", + "x-amz-signature", + "?signature=", + "large-layout", + ] { + assert!( + !serialized.contains(forbidden), + "compact result 不应包含 {forbidden}" + ); + } + } + + #[test] + fn non_external_job_does_not_publish_query_result() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let payload: Value = serde_json::from_str(&editor_generation_result_payload_json( + &job, + &json!({ + "objectKey": "users/user-1/generated/main.png", + "resource": {"resourceId": "resource-main"} + }), + )) + .expect("普通编辑器任务结果应为合法 JSON"); + + assert!(payload.get("result").is_none()); + } + #[test] fn worker_job_timeout_uses_long_budget_for_image_and_video_jobs() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs new file mode 100644 index 000000000..a7f9d60fe --- /dev/null +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -0,0 +1,985 @@ +use std::sync::{Arc, LazyLock}; + +use axum::{ + body::Body, + http::{ + Method, Request, + header::{AUTHORIZATION, CONTENT_TYPE}, + }, +}; +use http_body_util::BodyExt; +use rmcp::{ + RoleServer, ServerHandler, + model::{ + CallToolRequestParams, CallToolResult, ErrorData, Implementation, ListResourcesResult, + ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, + Resource, ResourceContents, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, + }, + service::RequestContext as McpRequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, +}; +use serde_json::{Map, Value, json}; +use tower::ServiceExt; + +use crate::{modules, request_context::RequestContext, state::AppState}; + +const OPENAPI_JSON: &str = + include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json"); +const SKILL_MD: &str = + include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"); +const SKILL_CAPABILITY_ROUTING_MD: &str = include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md" +); +const SKILL_API_OPERATIONS_MD: &str = include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md" +); +const SKILL_AUTHENTICATION_AND_SAFETY_MD: &str = include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md" +); +const SKILL_REQUESTS_AND_OUTPUTS_MD: &str = include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md" +); +const USAGE_URI: &str = "genarrative://external-editor/usage"; +const OPENAPI_URI: &str = "genarrative://external-editor/openapi"; +const SKILL_URI: &str = "genarrative://external-editor/skill"; +const SKILL_CAPABILITY_ROUTING_URI: &str = + "genarrative://external-editor/skill/references/capability-routing.md"; +const SKILL_API_OPERATIONS_URI: &str = + "genarrative://external-editor/skill/references/api-operations.md"; +const SKILL_AUTHENTICATION_AND_SAFETY_URI: &str = + "genarrative://external-editor/skill/references/authentication-and-safety.md"; +const SKILL_REQUESTS_AND_OUTPUTS_URI: &str = + "genarrative://external-editor/skill/references/requests-and-outputs.md"; +const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; + +#[derive(Clone, Debug)] +struct McpOperation { + tool_name: String, + operation_id: String, + method: Method, + path_template: String, + description: String, + input_schema: Arc>, + requires_idempotency_key: bool, +} + +static MCP_OPERATIONS: LazyLock> = LazyLock::new(build_mcp_operations); + +#[derive(Clone, Debug, Default)] +pub(crate) struct GenarrativeExternalMcp; + +pub(crate) type GenarrativeExternalMcpService = + StreamableHttpService; + +pub(crate) fn service() -> GenarrativeExternalMcpService { + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(false) + .with_json_response(true) + .with_sse_keep_alive(None) + .with_allowed_hosts([ + "www.genarrative.world", + "genarrative.world", + "localhost", + "127.0.0.1", + "::1", + ]) + .with_allowed_origins([ + "https://www.genarrative.world", + "https://genarrative.world", + "http://localhost:3000", + "http://127.0.0.1:3000", + ]); + StreamableHttpService::new( + || Ok(GenarrativeExternalMcp), + Arc::new(LocalSessionManager::default()), + config, + ) +} + +impl ServerHandler for GenarrativeExternalMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info( + Implementation::new("genarrative-external-editor", env!("CARGO_PKG_VERSION")) + .with_title("陶泥儿外部编辑器") + .with_description("通过托管式 MCP 使用陶泥儿画布、素材库和异步生成 API") + .with_website_url("https://www.genarrative.world"), + ) + .with_instructions(MCP_INSTRUCTIONS) + } + + async fn list_tools( + &self, + _request: Option, + _context: McpRequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items( + MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(), + )) + } + + fn get_tool(&self, name: &str) -> Option { + MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == name) + .map(mcp_operation_tool) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: McpRequestContext, + ) -> Result { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == request.name.as_ref()) + .ok_or_else(|| ErrorData::invalid_params("未知的陶泥儿外部 API 工具", None))?; + let arguments = request.arguments.unwrap_or_default(); + match dispatch_operation(operation, arguments, &context).await { + Ok(value) => Ok(CallToolResult::structured(value)), + Err(value) => Ok(CallToolResult::structured_error(value)), + } + } + + async fn list_resources( + &self, + _request: Option, + _context: McpRequestContext, + ) -> Result { + Ok(ListResourcesResult::with_all_items(mcp_resources())) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: McpRequestContext, + ) -> Result { + let (text, mime_type) = mcp_resource_contents(request.uri.as_str()) + .ok_or_else(|| ErrorData::resource_not_found("资源不存在", None))?; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, request.uri).with_mime_type(mime_type), + ])) + } +} + +fn mcp_resources() -> Vec { + vec![ + Resource::new(USAGE_URI, "usage") + .with_title("陶泥儿外部编辑器使用说明") + .with_description("画布、素材、上传、异步生成和告警处理工作流") + .with_mime_type("text/markdown"), + Resource::new(OPENAPI_URI, "openapi") + .with_title("陶泥儿外部编辑器 OpenAPI") + .with_description("MCP 工具所映射的完整 REST 契约") + .with_mime_type("application/json"), + Resource::new(SKILL_URI, "skill") + .with_title("陶泥儿外部编辑器 Skill") + .with_description("外部编辑器 Skill 主入口;细节按 references 渐进读取") + .with_mime_type("text/markdown"), + Resource::new(SKILL_CAPABILITY_ROUTING_URI, "skill-capability-routing") + .with_title("陶泥儿外部编辑器能力路由") + .with_description("按用户意图选择 MCP tool 或 External v1 API") + .with_mime_type("text/markdown"), + Resource::new(SKILL_API_OPERATIONS_URI, "skill-api-operations") + .with_title("陶泥儿外部编辑器 API 操作") + .with_description("项目、素材、上传、异步生成和任务查询操作表") + .with_mime_type("text/markdown"), + Resource::new( + SKILL_AUTHENTICATION_AND_SAFETY_URI, + "skill-authentication-and-safety", + ) + .with_title("陶泥儿外部编辑器认证与安全") + .with_description("API Key、幂等、重试、本地文件和安全边界") + .with_mime_type("text/markdown"), + Resource::new(SKILL_REQUESTS_AND_OUTPUTS_URI, "skill-requests-and-outputs") + .with_title("陶泥儿外部编辑器请求与输出") + .with_description("请求构造、异步轮询、完成结果和告警处理") + .with_mime_type("text/markdown"), + ] +} + +fn mcp_resource_contents(uri: &str) -> Option<(&'static str, &'static str)> { + match uri { + USAGE_URI => Some((MCP_INSTRUCTIONS, "text/markdown")), + OPENAPI_URI => Some((OPENAPI_JSON, "application/json")), + SKILL_URI => Some((SKILL_MD, "text/markdown")), + SKILL_CAPABILITY_ROUTING_URI => Some((SKILL_CAPABILITY_ROUTING_MD, "text/markdown")), + SKILL_API_OPERATIONS_URI => Some((SKILL_API_OPERATIONS_MD, "text/markdown")), + SKILL_AUTHENTICATION_AND_SAFETY_URI => { + Some((SKILL_AUTHENTICATION_AND_SAFETY_MD, "text/markdown")) + } + SKILL_REQUESTS_AND_OUTPUTS_URI => Some((SKILL_REQUESTS_AND_OUTPUTS_MD, "text/markdown")), + _ => None, + } +} + +fn build_mcp_operations() -> Vec { + let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse"); + let mut operations = Vec::new(); + let Some(paths) = openapi.get("paths").and_then(Value::as_object) else { + return operations; + }; + for (path, path_item) in paths { + let Some(path_item) = path_item.as_object() else { + continue; + }; + for method_name in ["get", "post", "patch", "put", "delete"] { + let Some(operation) = path_item.get(method_name).and_then(Value::as_object) else { + continue; + }; + if operation.get("x-mcp-excluded").and_then(Value::as_bool) == Some(true) { + continue; + } + let Some(operation_id) = operation.get("operationId").and_then(Value::as_str) else { + continue; + }; + let method = Method::from_bytes(method_name.to_ascii_uppercase().as_bytes()) + .expect("known HTTP method"); + let requires_idempotency_key = matches!( + operation_id, + "generateExternalEditorImage" + | "editExternalEditorImage" + | "generateExternalEditorIconSpritesheet" + | "extractExternalEditorUiDesignAssets" + | "generateExternalEditorCharacterAnimation" + | "generateExternalEditorVideo" + | "generateExternalEditorSoundEffect" + | "generateExternalEditorBackgroundMusic" + ); + let description = operation + .get("description") + .or_else(|| operation.get("summary")) + .and_then(Value::as_str) + .unwrap_or("调用陶泥儿外部编辑器 API"); + operations.push(McpOperation { + tool_name: camel_to_snake(operation_id), + operation_id: operation_id.to_string(), + method, + path_template: path.clone(), + description: format!("{description}({} {path})", method_name.to_uppercase()), + input_schema: Arc::new(build_operation_input_schema( + &openapi, + path_item, + operation, + requires_idempotency_key, + )), + requires_idempotency_key, + }); + } + } + operations.sort_by(|left, right| left.tool_name.cmp(&right.tool_name)); + operations +} + +fn build_operation_input_schema( + openapi: &Value, + path_item: &Map, + operation: &Map, + requires_idempotency_key: bool, +) -> Map { + let mut properties = Map::new(); + let parameters = path_item + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .chain( + operation + .get("parameters") + .and_then(Value::as_array) + .into_iter() + .flatten(), + ) + .filter_map(|parameter| resolve_openapi_reference(openapi, parameter)) + .collect::>(); + let mut top_level_required = Vec::new(); + for location in ["path", "query"] { + let mut parameter_properties = Map::new(); + let mut required = Vec::new(); + for parameter in ¶meters { + if parameter.get("in").and_then(Value::as_str) != Some(location) { + continue; + } + let Some(name) = parameter.get("name").and_then(Value::as_str) else { + continue; + }; + parameter_properties.insert( + name.to_string(), + parameter + .get("schema") + .cloned() + .unwrap_or_else(|| json!({})), + ); + if parameter.get("required").and_then(Value::as_bool) == Some(true) { + required.push(Value::String(name.to_string())); + } + } + if !parameter_properties.is_empty() { + let mut schema = json!({ + "type": "object", + "properties": parameter_properties, + "additionalProperties": false, + }); + if !required.is_empty() { + schema["required"] = Value::Array(required); + top_level_required.push(Value::String(format!("{location}Parameters"))); + } + properties.insert(format!("{location}Parameters"), schema); + } + } + if let Some(request_body) = operation + .get("requestBody") + .and_then(|value| resolve_openapi_reference(openapi, value)) + { + let body_schema = request_body + .get("content") + .and_then(|content| content.get("application/json")) + .and_then(|media_type| media_type.get("schema")) + .map(|schema| inline_openapi_schema(openapi, schema, 0)) + .unwrap_or_else(|| { + json!({ + "type": "object", + "description": "请求体。精确字段、枚举和约束见 genarrative://external-editor/openapi。", + "additionalProperties": true, + }) + }); + properties.insert("body".to_string(), body_schema); + if request_body.get("required").and_then(Value::as_bool) == Some(true) { + top_level_required.push(json!("body")); + } + } + if requires_idempotency_key { + properties.insert( + "idempotencyKey".to_string(), + json!({ + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "本次逻辑生成请求的稳定幂等键;结果不确定时必须复用原值。" + }), + ); + top_level_required.push(json!("idempotencyKey")); + } + let mut schema = Map::from_iter([ + ("type".to_string(), json!("object")), + ("properties".to_string(), Value::Object(properties)), + ("additionalProperties".to_string(), json!(false)), + ]); + if !top_level_required.is_empty() { + top_level_required.sort_by(|left, right| left.as_str().cmp(&right.as_str())); + top_level_required.dedup(); + schema.insert("required".to_string(), Value::Array(top_level_required)); + } + schema +} + +fn resolve_openapi_reference<'a>(openapi: &'a Value, value: &'a Value) -> Option<&'a Value> { + let Some(reference) = value.get("$ref").and_then(Value::as_str) else { + return Some(value); + }; + let pointer = reference.strip_prefix('#')?; + openapi.pointer(pointer) +} + +fn inline_openapi_schema(openapi: &Value, schema: &Value, depth: usize) -> Value { + if depth >= 32 { + return json!({"type": "object"}); + } + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) + && let Some(pointer) = reference.strip_prefix('#') + && let Some(resolved) = openapi.pointer(pointer) + { + return inline_openapi_schema(openapi, resolved, depth + 1); + } + match schema { + Value::Array(values) => Value::Array( + values + .iter() + .map(|value| inline_openapi_schema(openapi, value, depth + 1)) + .collect(), + ), + Value::Object(values) => Value::Object( + values + .iter() + .map(|(key, value)| { + ( + key.clone(), + inline_openapi_schema(openapi, value, depth + 1), + ) + }) + .collect(), + ), + value => value.clone(), + } +} + +fn mcp_operation_tool(operation: &McpOperation) -> Tool { + let read_only = operation.method == Method::GET; + let destructive = operation.method == Method::DELETE || operation.requires_idempotency_key; + let annotations = ToolAnnotations::new() + .read_only(read_only) + .destructive(destructive) + .idempotent(read_only || operation.requires_idempotency_key) + .open_world(operation.requires_idempotency_key); + let mut tool = Tool::new( + operation.tool_name.clone(), + operation.description.clone(), + operation.input_schema.clone(), + ); + tool.title = Some(operation.operation_id.clone()); + tool.annotations = Some(annotations); + tool +} + +async fn dispatch_operation( + operation: &McpOperation, + arguments: Map, + context: &McpRequestContext, +) -> Result { + let parts = context + .extensions + .get::() + .ok_or_else(|| json!({"error": "MCP HTTP 请求上下文缺失"}))?; + let state = parts + .extensions + .get::() + .cloned() + .ok_or_else(|| json!({"error": "MCP 应用状态缺失"}))?; + let request_context = parts + .extensions + .get::() + .cloned() + .ok_or_else(|| json!({"error": "MCP request_id 上下文缺失"}))?; + let authorization = parts + .headers + .get(AUTHORIZATION) + .cloned() + .ok_or_else(|| json!({"error": "Authorization 请求头缺失"}))?; + + let mut path = operation.path_template.clone(); + if let Some(path_parameters) = arguments.get("pathParameters").and_then(Value::as_object) { + for (name, value) in path_parameters { + let value = json_scalar_string(value) + .ok_or_else(|| json!({"error": format!("路径参数 {name} 必须是标量")}))?; + path = path.replace(&format!("{{{name}}}"), urlencoding::encode(&value).as_ref()); + } + } + if path.contains('{') { + return Err(json!({"error": "缺少必填路径参数"})); + } + if let Some(query) = arguments.get("queryParameters").and_then(Value::as_object) { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in query { + match value { + Value::Array(values) => { + for value in values { + if let Some(value) = json_scalar_string(value) { + serializer.append_pair(name, &value); + } + } + } + value => { + if let Some(value) = json_scalar_string(value) { + serializer.append_pair(name, &value); + } + } + } + } + let query = serializer.finish(); + if !query.is_empty() { + path.push('?'); + path.push_str(&query); + } + } + + let body = arguments.get("body").cloned().unwrap_or(Value::Null); + let body = if body.is_null() { + Body::empty() + } else { + Body::from(body.to_string()) + }; + let mut request = Request::builder() + .method(operation.method.clone()) + .uri(path) + .header(AUTHORIZATION, authorization) + .body(body) + .map_err(|_| json!({"error": "无法构造内部 API 请求"}))?; + request.extensions_mut().insert(request_context); + if arguments.get("body").is_some() { + request.headers_mut().insert( + CONTENT_TYPE, + "application/json".parse().expect("valid content type"), + ); + } + if operation.requires_idempotency_key { + let idempotency_key = arguments + .get("idempotencyKey") + .and_then(Value::as_str) + .ok_or_else(|| json!({"error": "生成工具必须提供 idempotencyKey"}))?; + request.headers_mut().insert( + "idempotency-key", + idempotency_key + .parse() + .map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?, + ); + } + + let response = modules::external_api::router(state.clone()) + .with_state(state) + .oneshot(request) + .await + .unwrap_or_else(|never| match never {}); + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .map_err(|_| json!({"error": "读取外部 API 响应失败"}))? + .to_bytes(); + if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES { + return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"})); + } + let payload = serde_json::from_slice::(&bytes).unwrap_or_else(|_| { + json!({ + "status": status.as_u16(), + "message": "外部 API 返回了非 JSON 响应" + }) + }); + if status.is_success() { + Ok(unwrap_external_api_success_payload(payload)) + } else { + Err(json!({ + "status": status.as_u16(), + "response": payload, + })) + } +} + +fn unwrap_external_api_success_payload(payload: Value) -> Value { + payload + .get("data") + .filter(|_| payload.get("ok").and_then(Value::as_bool) == Some(true)) + .cloned() + .unwrap_or(payload) +} + +fn json_scalar_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +fn camel_to_snake(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 8); + for (index, character) in value.chars().enumerate() { + if character.is_ascii_uppercase() { + if index > 0 { + output.push('_'); + } + output.push(character.to_ascii_lowercase()); + } else { + output.push(character); + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::AppConfig, request_context::attach_request_context}; + use axum::{ + http::{ + StatusCode, + header::{ACCEPT, HOST}, + }, + middleware, + }; + + #[test] + fn mcp_resources_expose_complete_progressive_skill_documents() { + let resources = + serde_json::to_string(&mcp_resources()).expect("resources should serialize"); + let expected = [ + (USAGE_URI, MCP_INSTRUCTIONS, "text/markdown"), + (OPENAPI_URI, OPENAPI_JSON, "application/json"), + (SKILL_URI, SKILL_MD, "text/markdown"), + ( + SKILL_CAPABILITY_ROUTING_URI, + SKILL_CAPABILITY_ROUTING_MD, + "text/markdown", + ), + ( + SKILL_API_OPERATIONS_URI, + SKILL_API_OPERATIONS_MD, + "text/markdown", + ), + ( + SKILL_AUTHENTICATION_AND_SAFETY_URI, + SKILL_AUTHENTICATION_AND_SAFETY_MD, + "text/markdown", + ), + ( + SKILL_REQUESTS_AND_OUTPUTS_URI, + SKILL_REQUESTS_AND_OUTPUTS_MD, + "text/markdown", + ), + ]; + assert_eq!(mcp_resources().len(), expected.len()); + for (uri, contents, mime_type) in expected { + assert!(resources.contains(uri), "missing MCP resource {uri}"); + assert_eq!(mcp_resource_contents(uri), Some((contents, mime_type))); + } + } + + #[test] + fn openapi_operations_become_unique_mcp_tools() { + let names: std::collections::BTreeMap<_, _> = MCP_OPERATIONS + .iter() + .map(|operation| { + ( + operation.tool_name.as_str(), + operation.path_template.as_str(), + ) + }) + .collect(); + assert_eq!(names.len(), MCP_OPERATIONS.len()); + assert!(names.contains_key("generate_external_editor_image")); + assert!(names.contains_key("get_external_editor_generation_job")); + + let list_projects = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "list_editor_projects") + .expect("project list tool should exist"); + assert_eq!(list_projects.method, Method::GET); + + let create_project = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "create_editor_project") + .expect("project create tool should exist"); + assert_eq!(create_project.method, Method::POST); + } + + #[test] + fn generation_tools_require_idempotency_key() { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "generate_external_editor_image") + .expect("image generation tool should exist"); + assert!(operation.requires_idempotency_key); + assert_eq!( + operation.input_schema.get("required"), + Some(&json!(["body", "idempotencyKey"])) + ); + assert_eq!( + operation.input_schema["properties"]["body"]["properties"]["projectId"]["type"], + json!(["string", "null"]) + ); + } + + #[test] + fn referenced_path_parameters_are_exposed_to_agents() { + let operation = MCP_OPERATIONS + .iter() + .find(|operation| operation.tool_name == "get_editor_project") + .expect("project lookup tool should exist"); + assert_eq!( + operation.input_schema["required"], + json!(["pathParameters"]) + ); + assert_eq!( + operation.input_schema["properties"]["pathParameters"]["required"], + json!(["projectId"]) + ); + } + + #[test] + fn tool_catalog_has_self_contained_bounded_schemas() { + let tools = MCP_OPERATIONS + .iter() + .map(mcp_operation_tool) + .collect::>(); + let serialized = serde_json::to_vec(&tools).expect("tool catalog should serialize"); + assert!(serialized.len() < 512 * 1024); + for operation in MCP_OPERATIONS.iter() { + let serialized = serde_json::to_string(&operation.input_schema) + .expect("tool input schema should serialize"); + assert!(serialized.len() < 64 * 1024, "{}", operation.tool_name); + assert!(!serialized.contains("\"$ref\""), "{}", operation.tool_name); + assert_eq!(operation.input_schema.get("type"), Some(&json!("object"))); + } + } + + #[test] + fn openapi_documents_mcp_authentication_guide() { + let openapi: Value = + serde_json::from_str(OPENAPI_JSON).expect("external OpenAPI should parse"); + let unauthorized = &openapi["paths"]["/api/external/v1/mcp"]["post"]["responses"]["401"]; + assert_eq!( + unauthorized["headers"]["WWW-Authenticate"]["schema"]["const"], + json!("Bearer realm=\"genarrative-external-editor\"") + ); + assert_eq!( + unauthorized["content"]["application/json"]["schema"]["$ref"], + json!("#/components/schemas/McpAuthenticationGuideResponse") + ); + let guide = &openapi["components"]["schemas"]["McpAuthenticationGuideResponse"]["properties"] + ["error"]["properties"]["details"]["properties"]["guide"]; + assert_eq!( + guide["properties"]["reason"]["const"], + json!("MCP_AUTHENTICATION_REQUIRED") + ); + assert_eq!( + guide["properties"]["action"]["const"], + json!("CONFIGURE_BEARER_API_KEY") + ); + } + + #[test] + fn mcp_tools_return_business_data_without_rest_envelope() { + assert_eq!( + unwrap_external_api_success_payload(json!({ + "ok": true, + "data": {"operationId": "task-1", "status": "queued"}, + "meta": {"requestId": "request-1"} + })), + json!({"operationId": "task-1", "status": "queued"}) + ); + } + + #[tokio::test] + async fn streamable_http_initialize_is_stateless_json() { + let request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, "localhost") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream") + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test-agent","version":"1.0"}}}"#, + )) + .expect("initialize request should build"); + + let response = service() + .oneshot(request) + .await + .expect("MCP service should be infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get("mcp-session-id").is_none()); + assert_eq!( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + let payload: Value = serde_json::from_slice( + &response + .into_body() + .collect() + .await + .expect("initialize response body should read") + .to_bytes(), + ) + .expect("initialize response should be JSON"); + assert_eq!(payload["id"], json!(1)); + assert_eq!( + payload["result"]["serverInfo"]["name"], + json!("genarrative-external-editor") + ); + assert!(payload["result"]["capabilities"]["tools"].is_object()); + assert!(payload["result"]["capabilities"]["resources"].is_object()); + assert!( + payload["result"]["instructions"] + .as_str() + .is_some_and(|value| value.contains("异步提交")) + ); + + for (method, assertion) in [ + ("tools/list", "generate_external_editor_image"), + ("resources/list", USAGE_URI), + ] { + let request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, "localhost") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-11-25") + .body(Body::from( + json!({"jsonrpc": "2.0", "id": 2, "method": method}).to_string(), + )) + .expect("catalog request should build"); + let response = service() + .oneshot(request) + .await + .expect("MCP service should be infallible"); + assert_eq!(response.status(), StatusCode::OK, "{method}"); + let body = response + .into_body() + .collect() + .await + .expect("catalog response body should read") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("catalog response should be JSON"); + assert!( + payload["result"].to_string().contains(assertion), + "{method}" + ); + } + + for (id, uri, expected_text) in [ + (3, OPENAPI_URI, "陶泥儿外部编辑器 OpenAPI"), + ( + 4, + SKILL_REQUESTS_AND_OUTPUTS_URI, + "All eight generation POST routes require", + ), + ] { + let request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, "localhost") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-11-25") + .body(Body::from( + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "resources/read", + "params": {"uri": uri} + }) + .to_string(), + )) + .expect("resource read request should build"); + let response = service() + .oneshot(request) + .await + .expect("MCP service should be infallible"); + assert_eq!(response.status(), StatusCode::OK); + let payload: Value = serde_json::from_slice( + &response + .into_body() + .collect() + .await + .expect("resource response body should read") + .to_bytes(), + ) + .expect("resource response should be JSON"); + assert!( + payload["result"]["contents"][0]["text"] + .as_str() + .is_some_and(|value| value.contains(expected_text)), + "{uri}" + ); + } + } + + #[tokio::test] + async fn mounted_mcp_route_requires_external_api_key() { + let state = AppState::new(AppConfig::default()).expect("test state should build"); + let mut errors = Vec::new(); + for authorization in [None, Some("Basic not-a-bearer-token")] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, "localhost") + .header("x-request-id", "mcp-auth-guide-test") + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream"); + if let Some(authorization) = authorization { + request = request.header(AUTHORIZATION, authorization); + } + let request = request + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test-agent","version":"1.0"}}}"#, + )) + .expect("initialize request should build"); + let response = modules::external_api::router(state.clone()) + .with_state(state.clone()) + .layer(middleware::from_fn(attach_request_context)) + .oneshot(request) + .await + .expect("external router should be infallible"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response.headers().get("mcp-session-id").is_none()); + assert!( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")) + ); + assert_eq!( + response + .headers() + .get("www-authenticate") + .and_then(|value| value.to_str().ok()), + Some("Bearer realm=\"genarrative-external-editor\"") + ); + let payload: Value = serde_json::from_slice( + &response + .into_body() + .collect() + .await + .expect("authentication guide should read") + .to_bytes(), + ) + .expect("authentication guide should be JSON"); + assert_eq!(payload["error"]["code"], json!("UNAUTHORIZED")); + assert_eq!( + payload["error"]["message"], + json!("连接陶泥儿托管 MCP 需要开发者 API Key") + ); + assert_eq!( + payload["error"]["details"]["guide"]["reason"], + json!("MCP_AUTHENTICATION_REQUIRED") + ); + assert_eq!( + payload["error"]["details"]["guide"]["action"], + json!("CONFIGURE_BEARER_API_KEY") + ); + assert_eq!( + payload["error"]["details"]["guide"]["authentication"]["valueFormat"], + json!("Bearer ") + ); + assert_eq!( + payload["error"]["details"]["guide"]["publicDiscovery"]["manifest"], + json!("/api/external/v1/agent-integration.json") + ); + assert_eq!( + payload["error"]["details"]["guide"]["credentialSafety"]["neverPasteIntoChat"], + json!(true) + ); + assert_eq!(payload["meta"]["requestId"], json!("mcp-auth-guide-test")); + assert_eq!( + payload["meta"]["operation"], + json!("POST /api/external/v1/mcp") + ); + let error = payload["error"].clone(); + let serialized = payload.to_string(); + assert!(!serialized.contains("tools")); + assert!(!serialized.contains("resources")); + assert!(!serialized.contains("provider")); + assert!(!serialized.contains("procedure")); + assert!(!serialized.contains("owner")); + assert!(!serialized.contains("SENSITIVE_KEY_LURE")); + errors.push(error); + } + assert_eq!(errors[0], errors[1]); + } +} diff --git a/server-rs/crates/api-server/src/external_skill_api.rs b/server-rs/crates/api-server/src/external_skill_api.rs new file mode 100644 index 000000000..2472b90ea --- /dev/null +++ b/server-rs/crates/api-server/src/external_skill_api.rs @@ -0,0 +1,180 @@ +use std::io::{Cursor, Write}; + +use axum::{ + Json, + body::Body, + http::{ + HeaderValue, StatusCode, + header::{CONTENT_DISPOSITION, CONTENT_TYPE}, + }, + response::{IntoResponse, Response}, +}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use zip::{ZipWriter, write::SimpleFileOptions}; + +use crate::http_error::AppError; + +const SKILL_ROOT: &str = "genarrative-external-editor-api"; +const SKILL_FILES: [(&str, &str); 7] = [ + ( + "SKILL.md", + include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"), + ), + ( + "references/capability-routing.md", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/capability-routing.md" + ), + ), + ( + "references/api-operations.md", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/api-operations.md" + ), + ), + ( + "references/authentication-and-safety.md", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/authentication-and-safety.md" + ), + ), + ( + "references/requests-and-outputs.md", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md" + ), + ), + ( + "scripts/genarrative_external_api.py", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py" + ), + ), + ( + "agents/openai.yaml", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/agents/openai.yaml" + ), + ), +]; + +pub async fn get_external_skill_entry() -> Response { + let mut response = Body::from(SKILL_FILES[0].1).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/markdown; charset=utf-8"), + ); + response +} + +pub async fn download_external_skill_archive() -> Result { + let bytes = build_external_skill_archive()?; + let mut response = Body::from(bytes).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/zip")); + response.headers_mut().insert( + CONTENT_DISPOSITION, + HeaderValue::from_static( + "attachment; filename=\"genarrative-external-editor-api.skill.zip\"", + ), + ); + Ok(response) +} + +pub async fn get_external_agent_integration_manifest() -> Result, AppError> { + let archive = build_external_skill_archive()?; + let sha256 = format!("{:x}", Sha256::digest(&archive)); + Ok(Json(json!({ + "name": SKILL_ROOT, + "version": env!("CARGO_PKG_VERSION"), + "mcp": { + "transport": "streamable-http", + "url": "/api/external/v1/mcp", + "authentication": "bearer-api-key", + "credentialSetup": { + "action": "CONFIGURE_BEARER_API_KEY", + "authorizationValueFormat": "Bearer ", + "navigationLabel": "开发者 API Key", + "guide": "/api/external/v1/skill/SKILL.md" + } + }, + "openapi": "/api/external/v1/openapi.json", + "skill": { + "entry": "/api/external/v1/skill/SKILL.md", + "archive": "/api/external/v1/skill.zip", + "archiveSha256": sha256, + "files": SKILL_FILES.map(|(path, _)| format!("{SKILL_ROOT}/{path}")), + } + }))) +} + +fn build_external_skill_archive() -> Result, AppError> { + let cursor = Cursor::new(Vec::new()); + let mut archive = ZipWriter::new(cursor); + let options = SimpleFileOptions::default().unix_permissions(0o644); + for (path, contents) in SKILL_FILES { + archive + .start_file(format!("{SKILL_ROOT}/{path}"), options) + .map_err(skill_archive_error)?; + archive + .write_all(contents.as_bytes()) + .map_err(skill_archive_error)?; + } + archive + .finish() + .map(Cursor::into_inner) + .map_err(skill_archive_error) +} + +fn skill_archive_error(error: impl std::fmt::Display) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "external-skill-archive", + "message": format!("构建外部 Skill 包失败:{error}"), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_contains_complete_skill_bundle() { + let bytes = build_external_skill_archive().expect("skill archive should build"); + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("archive should parse"); + assert_eq!(archive.len(), SKILL_FILES.len()); + for (path, contents) in SKILL_FILES { + let name = format!("{SKILL_ROOT}/{path}"); + let mut file = archive.by_name(&name).expect("skill file should exist"); + let mut actual = String::new(); + std::io::Read::read_to_string(&mut file, &mut actual).expect("skill file should read"); + assert_eq!(actual, contents); + } + } + + #[tokio::test] + async fn integration_manifest_matches_complete_skill_archive() { + let bytes = build_external_skill_archive().expect("skill archive should build"); + let Json(manifest) = get_external_agent_integration_manifest() + .await + .expect("integration manifest should build"); + let expected_files = SKILL_FILES + .map(|(path, _)| format!("{SKILL_ROOT}/{path}")) + .to_vec(); + assert_eq!(manifest["skill"]["files"], json!(expected_files)); + assert_eq!( + manifest["mcp"]["credentialSetup"], + json!({ + "action": "CONFIGURE_BEARER_API_KEY", + "authorizationValueFormat": "Bearer ", + "navigationLabel": "开发者 API Key", + "guide": "/api/external/v1/skill/SKILL.md" + }) + ); + assert_eq!( + manifest["skill"]["archiveSha256"], + json!(format!("{:x}", Sha256::digest(&bytes))) + ); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 4f2b485dc..80872cf47 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -37,6 +37,8 @@ mod external_editor_api; mod external_generation; mod external_generation_worker; mod external_generation_worker_controller; +mod external_mcp; +mod external_skill_api; mod frontend_runtime_config; mod generated_image_assets; mod health; diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index a3341c2ee..ac2fbf362 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::DefaultBodyLimit, middleware, routing::{get, patch, post}, @@ -7,7 +7,7 @@ use axum::{ use crate::{ editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, - external_api_auth::require_external_api_key, + external_api_auth::{require_external_api_key, require_external_mcp_api_key}, external_assets_api::{ confirm_external_asset_object, create_external_direct_upload_ticket, get_external_asset_read_url, @@ -21,17 +21,43 @@ use crate::{ generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, generate_external_editor_image, generate_external_editor_sound_effect, generate_external_editor_video, get_external_editor_asset_library, - get_external_editor_project, list_external_editor_projects, - load_recent_external_editor_project, openapi_json, rename_external_editor_project, - save_external_editor_canvas, update_external_editor_asset, + get_external_editor_generation_job, get_external_editor_project, + list_external_editor_projects, load_recent_external_editor_project, openapi_json, + rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset, update_external_editor_asset_folder, }, + external_mcp, + external_skill_api::{ + download_external_skill_archive, get_external_agent_integration_manifest, + get_external_skill_entry, + }, state::AppState, }; pub fn router(state: AppState) -> Router { + let mcp_router = Router::new() + .nest_service("/api/external/v1/mcp", external_mcp::service()) + .layer(Extension(state.clone())) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_mcp_api_key, + )); + Router::new() + .merge(mcp_router) .route("/api/external/v1/openapi.json", get(openapi_json)) + .route( + "/api/external/v1/agent-integration.json", + get(get_external_agent_integration_manifest), + ) + .route( + "/api/external/v1/skill/SKILL.md", + get(get_external_skill_entry), + ) + .route( + "/api/external/v1/skill.zip", + get(download_external_skill_archive), + ) .route( "/api/external/v1/assets/direct-upload-tickets", post(create_external_direct_upload_ticket).route_layer(middleware::from_fn_with_state( @@ -139,6 +165,13 @@ pub fn router(state: AppState) -> Router { require_external_api_key, )), ) + .route( + "/api/external/v1/generations/{operation_id}", + get(get_external_editor_generation_job).route_layer(middleware::from_fn_with_state( + state.clone(), + require_external_api_key, + )), + ) .route( "/api/external/v1/editor/images/generations", post(generate_external_editor_image).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs index 1e6a698b8..9021cb656 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs @@ -6,7 +6,9 @@ mod publish; mod settings; mod types; -pub use generation::{generate_editor_background_music, generate_editor_sound_effect}; pub(crate) use generation::{ - generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner, + enqueue_editor_background_music_generation_for_owner, + enqueue_editor_sound_effect_generation_for_owner, generate_editor_background_music_for_owner, + generate_editor_sound_effect_for_owner, }; +pub use generation::{generate_editor_background_music, generate_editor_sound_effect}; diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index d02ffca32..6b1f77cb9 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -9,6 +9,7 @@ use platform_oss::LegacyAssetPrefix; use serde_json::Value; use serde_json::json; use shared_contracts::assets; +use spacetime_client::ExternalGenerationJobRecord; use crate::{ api_response::json_success_body, @@ -17,7 +18,7 @@ use crate::{ editor_generation_queue::{ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, - editor_generation_source_entity_id, enqueue_editor_generation_job, + editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, }, editor_project::{ EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, @@ -142,33 +143,14 @@ pub async fn generate_editor_sound_effect( let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - .into_response_with_context(Some(&request_context)) - })?; - let normalized = - normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-sound-effect", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_sound_effect_generation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成音效", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -180,6 +162,40 @@ pub async fn generate_editor_sound_effect( .await } +pub(crate) async fn enqueue_editor_sound_effect_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: assets::EditorSoundEffectGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + let pricing = state.editor_generation_pricing().await.map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + .into_response_with_context(Some(request_context)) + })?; + let normalized = normalize_editor_sound_effect_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| error.into_response_with_context(Some(request_context)))?; + let source_entity_id = + editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-sound-effect"); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成音效", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_sound_effect_for_owner( state: AppState, request_context: RequestContext, @@ -358,33 +374,14 @@ pub async fn generate_editor_background_music( let Json(payload) = parse_json_payload(&request_context, payload)?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })) - .into_response_with_context(Some(&request_context)) - })?; - let normalized = - normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing) - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-background-music", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_background_music_generation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成背景音乐", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -401,6 +398,43 @@ pub async fn generate_editor_background_music( .await } +pub(crate) async fn enqueue_editor_background_music_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: assets::EditorBackgroundMusicGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + let pricing = state.editor_generation_pricing().await.map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })) + .into_response_with_context(Some(request_context)) + })?; + let normalized = + normalize_editor_background_music_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| error.into_response_with_context(Some(request_context)))?; + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + "editor-background-music", + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成背景音乐", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_background_music_for_owner( state: AppState, request_context: RequestContext, diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index e379a8588..353b55661 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -30,7 +30,7 @@ | --- | --- | --- | --- | --- | | `OpenAiChat` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `choices[0].message.tool_calls` | `delta.tool_calls[].index`;首片提供 id/name,后续拼接 arguments | | `OpenAiResponses` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `output[].type=function_call` | `output_index`;`output_item.added` 提供身份,`function_call_arguments.delta` 拼接,`.done` 覆盖完整参数 | -| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层和 `strict` | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数 | +| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层;只有 endpoint/model 配置显式声明支持且 schema / 请求复杂度满足 Anthropic 当前边界时才发送 `strict: true`。strict 传输 schema 会剥离不支持的约束,调用方原 schema 保持不变;最后一项带 ephemeral cache breakpoint | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数;`message_start/message_delta` 合并 cache/input/output usage | Responses 如果只发送 `response.completed` 或 `response.incomplete`,解析器会从其中的 `response.output[]` 恢复 `function_call`;恢复时使用 output 数组下标作为 slot。`response.incomplete` 表示上游没有完成本轮生成:其中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,纯正文则保留为可用的降级结果。三种协议的并行工具调用只在平台层做 slot 聚合,不代表工具会在平台层并发执行。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 207614051..51857995b 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -59,6 +59,7 @@ pub struct LlmConfig { max_retries: u32, retry_backoff_ms: u64, official_fallback: bool, + anthropic_strict_tool_support: bool, } // 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。 @@ -417,12 +418,22 @@ struct AnthropicInputMessage { content: String, } -// Anthropic 工具与 OpenAI 的差异:schema 字段名为 input_schema,且没有 function 包装层与 strict。 +// Anthropic 工具与 OpenAI 的差异:schema 字段名为 input_schema,且没有 function 包装层。 #[derive(Serialize)] struct AnthropicTool { name: String, description: String, input_schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + strict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, +} + +#[derive(Serialize)] +struct AnthropicCacheControl { + #[serde(rename = "type")] + cache_type: &'static str, } // Anthropic 的 tool_choice 必须是对象,发送裸字符串会被上游拒绝。 @@ -611,9 +622,25 @@ struct AnthropicUsage { #[serde(default)] input_tokens: u64, #[serde(default)] + cache_creation_input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, + #[serde(default)] output_tokens: u64, } +fn map_anthropic_usage(usage: AnthropicUsage) -> LlmTokenUsage { + let prompt_tokens = usage + .input_tokens + .saturating_add(usage.cache_creation_input_tokens) + .saturating_add(usage.cache_read_input_tokens); + LlmTokenUsage { + prompt_tokens, + completion_tokens: usage.output_tokens, + total_tokens: prompt_tokens.saturating_add(usage.output_tokens), + } +} + struct OpenAiCompatibleSseParser { buffer: String, raw_text: String, @@ -994,6 +1021,7 @@ impl LlmConfig { max_retries, retry_backoff_ms, official_fallback: false, + anthropic_strict_tool_support: false, }) } @@ -1002,6 +1030,15 @@ impl LlmConfig { self } + /// 显式声明当前 Anthropic endpoint 与 model 组合支持 strict tool use。 + /// + /// 该能力不能由 `api_kind` 推断:旧 Claude 模型和 Anthropic-compatible + /// 网关未必接受 `strict: true`。因此默认关闭,仅允许已验证的配置启用。 + pub fn with_anthropic_strict_tool_support(mut self, supported: bool) -> Self { + self.anthropic_strict_tool_support = supported; + self + } + pub fn with_raw_log_dir(mut self, raw_log_dir: impl Into) -> Self { self.raw_log_dir = raw_log_dir.into(); self @@ -1055,6 +1092,10 @@ impl LlmConfig { self.official_fallback } + pub fn anthropic_strict_tool_support(&self) -> bool { + self.anthropic_strict_tool_support + } + pub fn chat_completions_url(&self) -> String { format!( "{}/{}", @@ -2107,7 +2148,23 @@ where } if let Some(event_usage) = event_usage { - accumulation.usage = Some(event_usage); + accumulation.usage = Some(match accumulation.usage.take() { + Some(previous) => { + let prompt_tokens = previous.prompt_tokens.max(event_usage.prompt_tokens); + let completion_tokens = previous + .completion_tokens + .max(event_usage.completion_tokens); + LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens: previous + .total_tokens + .max(event_usage.total_tokens) + .max(prompt_tokens.saturating_add(completion_tokens)), + } + } + None => event_usage, + }); } // 工具调用只累加,不进 on_delta:调用方的流式通道仍然只承载文本。 @@ -2242,6 +2299,7 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) request, fallback_model, stream, + config.anthropic_strict_tool_support(), )), } } @@ -2268,7 +2326,12 @@ fn build_anthropic_messages_request_body( request: &LlmRunRequest, fallback_model: &str, stream: bool, + strict_tool_support: bool, ) -> AnthropicMessagesRequestBody { + // capability 绑定 LlmConfig 的 endpoint/model 组合;请求级 model override 没有经过 + // 同一轮能力确认,即使协议仍是 Anthropic 也必须 fail closed。 + let strict_tool_support = + strict_tool_support && request.resolved_model(fallback_model) == fallback_model; let system = request .messages .iter() @@ -2290,13 +2353,26 @@ fn build_anthropic_messages_request_body( .collect(); let tools = (!request.function_tools.is_empty()).then(|| { + let strict_schemas = + anthropic_strict_transport_schemas(&request.function_tools, strict_tool_support); + let last_index = request.function_tools.len().saturating_sub(1); request .function_tools .iter() - .map(|function| AnthropicTool { - name: function.name.clone(), - description: function.description.clone(), - input_schema: function.parameters.clone(), + .enumerate() + .map(|(index, function)| { + let strict_schema = strict_schemas[index].as_ref(); + AnthropicTool { + name: function.name.clone(), + description: function.description.clone(), + input_schema: strict_schema + .cloned() + .unwrap_or_else(|| function.parameters.clone()), + strict: strict_schema.is_some().then_some(true), + cache_control: (index == last_index).then_some(AnthropicCacheControl { + cache_type: "ephemeral", + }), + } }) .collect() }); @@ -2316,6 +2392,388 @@ fn build_anthropic_messages_request_body( } } +#[derive(Default)] +struct AnthropicStrictSchemaComplexity { + optional_parameters: usize, + union_parameters: usize, +} + +// Anthropic 官方 SDK 同样会先把调用方 schema 转成服务端可编译的传输 schema,再用 +// 原 schema 做本地校验。这里绝不修改 LlmFunctionTool.parameters,只剥离 strict grammar +// 不支持的约束。未显式列入支持或可安全剥离集合的 keyword 一律拒绝 strict,避免新 keyword +// 穿透有限黑名单后把整轮请求变成 400。 +fn anthropic_strict_transport_schema(schema: &serde_json::Value) -> Option { + const BASIC_TYPES: &[&str] = &[ + "object", "array", "string", "integer", "number", "boolean", "null", + ]; + const SUPPORTED_FORMATS: &[&str] = &[ + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "uri", + "ipv4", + "ipv6", + "uuid", + ]; + + fn transform_schema_map( + object: &serde_json::Map, + ) -> Option> { + let mut transformed = serde_json::Map::new(); + for (keyword, value) in object { + match keyword.as_str() { + "type" => { + let supported = match value { + serde_json::Value::String(value) => { + BASIC_TYPES.contains(&value.as_str()) + } + serde_json::Value::Array(values) => { + !values.is_empty() + && values.iter().all(|value| { + value.as_str().is_some_and(|value| BASIC_TYPES.contains(&value)) + }) + && values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + .len() + == values.len() + } + _ => false, + }; + if !supported { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "properties" | "$defs" | "definitions" => { + let children = value.as_object()?; + let mut transformed_children = serde_json::Map::new(); + for (name, child) in children { + transformed_children + .insert(name.clone(), anthropic_strict_transport_schema(child)?); + } + transformed.insert( + keyword.clone(), + serde_json::Value::Object(transformed_children), + ); + } + "items" => { + transformed.insert( + keyword.clone(), + anthropic_strict_transport_schema(value)?, + ); + } + "anyOf" | "allOf" => { + let children = value.as_array().filter(|children| !children.is_empty())?; + let transformed_children = children + .iter() + .map(anthropic_strict_transport_schema) + .collect::>>()?; + // Anthropic 明确不支持 allOf 中的 $ref;该组合不能靠删除 $ref + // 降级,否则会丢失整个结构定义。 + if keyword == "allOf" + && transformed_children.iter().any(anthropic_schema_uses_ref) + { + return None; + } + transformed.insert( + keyword.clone(), + serde_json::Value::Array(transformed_children), + ); + } + "$ref" => { + if !value + .as_str() + .is_some_and(|reference| reference.starts_with("#/")) + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "required" => { + let values = value.as_array()?; + let names = values + .iter() + .map(serde_json::Value::as_str) + .collect::>>()?; + if names.iter().collect::>().len() + != names.len() + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "additionalProperties" => { + if value != &serde_json::Value::Bool(false) { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "enum" => { + let values = value.as_array().filter(|values| !values.is_empty())?; + if values.len() > 100 + || values + .iter() + .any(|value| value.is_array() || value.is_object()) + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "const" => { + if value.is_array() || value.is_object() { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "title" | "description" => { + value.as_str()?; + transformed.insert(keyword.clone(), value.clone()); + } + "default" => { + // default 是普通 JSON 数据,不递归解释其中可能出现的 `$ref`。 + transformed.insert(keyword.clone(), value.clone()); + } + "format" => { + if value + .as_str() + .is_some_and(|format| SUPPORTED_FORMATS.contains(&format)) + { + transformed.insert(keyword.clone(), value.clone()); + } + } + "minItems" => { + if value.as_u64().is_some_and(|minimum| minimum <= 1) { + transformed.insert(keyword.clone(), value.clone()); + } + } + // 这些是 Anthropic strict grammar 不支持的约束,或(如 pattern)带有 + // 本适配器未完整校验的编译器子集。传输时剥离;调用方持有的原 schema + // 不变,仍可用于 ToolHost 入参校验。 + "minimum" + | "maximum" + | "exclusiveMinimum" + | "exclusiveMaximum" + | "multipleOf" + | "minLength" + | "maxLength" + | "maxItems" + | "uniqueItems" + | "contains" + | "minContains" + | "maxContains" + | "minProperties" + | "maxProperties" + | "pattern" + // Anthropic 未列这些 annotation 为传输 schema 支持项;安全删除不会 + // 改变结构,且不会递归误读其中的普通 JSON 数据。 + | "examples" + | "$comment" + | "deprecated" + | "readOnly" + | "writeOnly" => {} + // `$id`、`$anchor`、dependentRequired 等所有未声明 keyword 都在这里 + // fail closed,不能靠 apiKind 或“看起来像 JSON Schema”发送 strict。 + _ => return None, + } + } + Some(transformed) + } + + Some(serde_json::Value::Object(transform_schema_map( + schema.as_object()?, + )?)) +} + +fn anthropic_schema_uses_ref(schema: &serde_json::Value) -> bool { + let Some(object) = schema.as_object() else { + return false; + }; + if object.contains_key("$ref") { + return true; + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .any(anthropic_schema_uses_ref) + || ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object)) + .flat_map(|children| children.values()) + .any(anthropic_schema_uses_ref) + || ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array)) + .flat_map(|children| children.iter()) + .any(anthropic_schema_uses_ref) +} + +fn collect_anthropic_strict_schema_complexity( + schema: &serde_json::Value, + complexity: &mut AnthropicStrictSchemaComplexity, +) -> bool { + let Some(object) = schema.as_object() else { + return false; + }; + if object + .get("type") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| types.len() > 1) + || object.contains_key("anyOf") + { + complexity.union_parameters = complexity.union_parameters.saturating_add(1); + } + let is_object_schema = object.get("type").and_then(serde_json::Value::as_str) == Some("object") + || object + .get("type") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| types.iter().any(|value| value.as_str() == Some("object"))) + || object.contains_key("properties"); + if is_object_schema + && object.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) + { + return false; + } + if let Some(properties) = object + .get("properties") + .and_then(serde_json::Value::as_object) + { + let required = object + .get("required") + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + if required.len() + != object + .get("required") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .unwrap_or_default() + || required.iter().any(|name| !properties.contains_key(*name)) + { + return false; + } + complexity.optional_parameters = complexity.optional_parameters.saturating_add( + properties + .keys() + .filter(|name| !required.contains(name.as_str())) + .count(), + ); + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) + && ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object)) + .flat_map(|children| children.values()) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) + && ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array)) + .flat_map(|children| children.iter()) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) +} + +fn anthropic_strict_schema_refs_are_supported(schema: &serde_json::Value) -> bool { + fn visit( + value: &serde_json::Value, + root: &serde_json::Value, + active_refs: &mut std::collections::BTreeSet, + ) -> bool { + match value { + serde_json::Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) { + // Anthropic strict 不支持递归 schema;为避免把命名 anchor 或外部 + // resource 误判为可编译,只接受能在当前 document 内解析的 Pointer。 + if !reference.starts_with("#/") { + return false; + } + let Some(target) = root.pointer(reference.trim_start_matches('#')) else { + return false; + }; + if !active_refs.insert(reference.to_string()) { + return false; + } + let target_is_supported = visit(target, root, active_refs); + active_refs.remove(reference); + if !target_is_supported { + return false; + } + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .all(|child| visit(child, root, active_refs)) + && ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| { + object.get(keyword).and_then(serde_json::Value::as_object) + }) + .flat_map(|children| children.values()) + .all(|child| visit(child, root, active_refs)) + && ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| { + object.get(keyword).and_then(serde_json::Value::as_array) + }) + .flat_map(|children| children.iter()) + .all(|child| visit(child, root, active_refs)) + } + _ => false, + } + } + + visit(schema, schema, &mut std::collections::BTreeSet::new()) +} + +fn anthropic_strict_transport_schemas( + functions: &[LlmFunctionTool], + strict_tool_support: bool, +) -> Vec> { + const MAX_STRICT_TOOLS: usize = 20; + const MAX_OPTIONAL_PARAMETERS: usize = 24; + const MAX_UNION_PARAMETERS: usize = 16; + + let mut strict_count = 0usize; + let mut optional_parameters = 0usize; + let mut union_parameters = 0usize; + functions + .iter() + .map(|function| { + if !strict_tool_support || !function.strict || strict_count >= MAX_STRICT_TOOLS { + return None; + } + let transport_schema = anthropic_strict_transport_schema(&function.parameters)?; + let mut complexity = AnthropicStrictSchemaComplexity::default(); + if !anthropic_strict_schema_refs_are_supported(&transport_schema) + || !collect_anthropic_strict_schema_complexity(&transport_schema, &mut complexity) + || optional_parameters.saturating_add(complexity.optional_parameters) + > MAX_OPTIONAL_PARAMETERS + || union_parameters.saturating_add(complexity.union_parameters) + > MAX_UNION_PARAMETERS + { + return None; + } + strict_count = strict_count.saturating_add(1); + optional_parameters = + optional_parameters.saturating_add(complexity.optional_parameters); + union_parameters = union_parameters.saturating_add(complexity.union_parameters); + Some(transport_schema) + }) + .collect() +} + fn map_chat_completions_input_messages( messages: &[LlmMessage], ) -> Vec { @@ -2692,11 +3150,7 @@ fn parse_anthropic_response( text: content, finish_reason: parsed.stop_reason, response_id: parsed.id, - usage: parsed.usage.map(|usage| LlmTokenUsage { - prompt_tokens: usage.input_tokens, - completion_tokens: usage.output_tokens, - total_tokens: usage.input_tokens.saturating_add(usage.output_tokens), - }), + usage: parsed.usage.map(map_anthropic_usage), tool_calls, }) } @@ -3219,6 +3673,21 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .unwrap_or_default(); match event_type { + "message_start" => Ok(parsed + .get("message") + .and_then(|message| message.get("usage")) + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LlmError::Deserialize(format!( + "解析 LLM Anthropic message_start usage 失败:{error}" + )) + })? + .map(|usage| ParsedStreamEvent { + usage: Some(map_anthropic_usage(usage)), + ..Default::default() + })), // tool_use block 的 id 与 name 只在 content_block_start 出现;此时 input 恒为空对象, // 不能拿它初始化参数,否则会和后续 input_json_delta 拼出非法 JSON。 "content_block_start" => { @@ -3292,11 +3761,23 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .and_then(|value| value.get("stop_reason")) .and_then(serde_json::Value::as_str) .map(str::to_string); + let usage = parsed + .get("usage") + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LlmError::Deserialize(format!( + "解析 LLM Anthropic message_delta usage 失败:{error}" + )) + })? + .map(map_anthropic_usage); Ok(Some(ParsedStreamEvent { is_completion: stop_reason .as_deref() .is_some_and(|reason| !reason.trim().is_empty()), finish_reason: stop_reason, + usage, ..Default::default() })) } @@ -3468,6 +3949,27 @@ mod tests { assert!(config.with_official_fallback(true).official_fallback()); } + #[test] + fn llm_config_anthropic_strict_tool_support_is_opt_in() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://api.anthropic.com".to_string(), + "secret".to_string(), + "claude-sonnet-4-5".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid"); + + assert!(!config.anthropic_strict_tool_support()); + assert!( + config + .with_anthropic_strict_tool_support(true) + .anthropic_strict_tool_support() + ); + } + #[test] fn run_request_defaults_to_openai_responses_api_kind() { let request = LlmRunRequest::single_turn("系统", "用户"); @@ -3507,7 +4009,12 @@ mod tests { LlmFunctionTool::new( "get_weather", "查询天气", - serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } } }), + serde_json::json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + }), ) .with_strict(true), ]) @@ -3520,13 +4027,240 @@ mod tests { assert_eq!(json["tools"][0]["name"], "get_weather"); assert_eq!(json["tools"][0]["description"], "查询天气"); assert_eq!(json["tools"][0]["input_schema"]["type"], "object"); - // Anthropic 没有 parameters / strict 字段,映射时必须丢弃。 + // apiKind 不能证明 endpoint/model 支持 strict,未显式声明 capability 时必须关闭。 assert!(json["tools"][0].get("parameters").is_none()); assert!(json["tools"][0].get("strict").is_none()); + assert_eq!( + json["tools"][0]["cache_control"], + serde_json::json!({"type": "ephemeral"}) + ); // tool_choice 必须是对象;Required 对应 Anthropic 的 any。 assert_eq!(json["tool_choice"], serde_json::json!({ "type": "any" })); } + #[test] + fn anthropic_strict_uses_transformed_schema_without_mutating_the_original() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://example.com/anthropic".to_string(), + "secret".to_string(), + "model-a".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid") + .with_anthropic_strict_tool_support(true); + let request = LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_function_tools(vec![ + LlmFunctionTool::new( + "bounded_text", + "包含 Anthropic strict 暂不支持的长度约束", + serde_json::json!({ + "type": "object", + "properties": { + "value": {"type": "string", "minLength": 1}, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": {"type": "string", "maxLength": 240} + } + }, + "required": ["value", "steps"], + "additionalProperties": false + }), + ) + .with_strict(true), + LlmFunctionTool::new( + "plain_text", + "支持严格模式的简单 schema", + serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]); + + let json = serde_json::to_value(build_request_body(&request, &config, false)) + .expect("body should serialize"); + assert_eq!(json["tools"][0]["strict"], true); + assert!( + json["tools"][0]["input_schema"]["properties"]["value"] + .get("minLength") + .is_none() + ); + assert_eq!( + request.function_tools[0].parameters["properties"]["value"]["minLength"], + 1 + ); + assert_eq!( + json["tools"][0]["input_schema"]["properties"]["steps"]["minItems"], + 1 + ); + assert!( + json["tools"][0]["input_schema"]["properties"]["steps"] + .get("maxItems") + .is_none() + ); + assert_eq!( + request.function_tools[0].parameters["properties"]["steps"]["maxItems"], + 8 + ); + assert!(json["tools"][0].get("cache_control").is_none()); + assert_eq!(json["tools"][1]["strict"], true); + assert_eq!( + json["tools"][1]["cache_control"], + serde_json::json!({"type": "ephemeral"}) + ); + } + + #[test] + fn anthropic_request_model_override_does_not_reuse_config_scoped_strict_capability() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://api.anthropic.com".to_string(), + "secret".to_string(), + "claude-sonnet-4-5".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid") + .with_anthropic_strict_tool_support(true); + let request = LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_model("claude-3-5-sonnet-latest") + .with_function_tools(vec![ + LlmFunctionTool::new( + "plain_text", + "simple schema", + serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]); + + let json = serde_json::to_value(build_request_body(&request, &config, false)) + .expect("body should serialize"); + assert!(json["tools"][0].get("strict").is_none()); + } + + #[test] + fn anthropic_strict_rejects_unclosed_objects_recursive_or_missing_refs_and_complex_enums() { + let schemas = [ + serde_json::json!({"type": "object"}), + serde_json::json!({ + "type": "object", + "$defs": { + "Node": { + "type": "object", + "properties": {"next": {"$ref": "#/$defs/Node"}}, + "additionalProperties": false + } + }, + "properties": {"node": {"$ref": "#/$defs/Node"}}, + "required": ["node"], + "additionalProperties": false + }), + serde_json::json!({ + "type": "object", + "properties": {"value": {"$ref": "#/$defs/Missing"}}, + "required": ["value"], + "additionalProperties": false + }), + serde_json::json!({ + "type": "object", + "properties": {"value": {"enum": [{"nested": true}]}}, + "required": ["value"], + "additionalProperties": false + }), + ]; + for schema in schemas { + let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)]; + assert_eq!(anthropic_strict_transport_schemas(&tools, true), vec![None]); + } + + let valid_ref = LlmFunctionTool::new( + "valid_ref", + "valid ref", + serde_json::json!({ + "type": "object", + "$defs": {"Value": {"type": "string"}}, + "properties": {"value": {"$ref": "#/$defs/Value"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true); + assert!(anthropic_strict_transport_schemas(&[valid_ref], true)[0].is_some()); + } + + #[test] + fn anthropic_strict_rejects_unknown_or_scope_changing_keywords() { + for (keyword, value) in [ + ("$id", serde_json::json!("nested.json")), + ("$anchor", serde_json::json!("node")), + ("dependentRequired", serde_json::json!({"value": ["other"]})), + ] { + let mut schema = serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }); + schema + .as_object_mut() + .expect("schema should be an object") + .insert(keyword.to_string(), value); + let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)]; + assert_eq!( + anthropic_strict_transport_schemas(&tools, true), + vec![None], + "{keyword} must fail closed" + ); + } + } + + #[test] + fn anthropic_strict_does_not_interpret_refs_inside_default_data() { + let tool = LlmFunctionTool::new( + "default_payload", + "default payload", + serde_json::json!({ + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {"$ref": "#/literal-data"} + } + }, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true); + + let transformed = anthropic_strict_transport_schemas(&[tool], true)[0] + .clone() + .expect("data-valued ref must not disable strict"); + assert_eq!( + transformed["properties"]["value"]["default"]["$ref"], + "#/literal-data" + ); + } + #[test] fn anthropic_request_body_omits_tool_fields_without_tools() { let config = LlmConfig::new( @@ -4937,7 +5671,7 @@ mod tests { MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}"#.to_string(), + body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":3}}"#.to_string(), extra_headers: Vec::new(), }, ); @@ -4966,9 +5700,9 @@ mod tests { assert_eq!( response.usage, Some(LlmTokenUsage { - prompt_tokens: 5, + prompt_tokens: 12, completion_tokens: 3, - total_tokens: 8, + total_tokens: 15, }) ); assert_eq!(request_json["model"], serde_json::json!("test-model")); @@ -4985,9 +5719,10 @@ mod tests { status_line: "200 OK", content_type: "text/event-stream; charset=utf-8", body: concat!( + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"cache_creation_input_tokens\":4,\"cache_read_input_tokens\":3,\"output_tokens\":0}}}\n\n", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n", - "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n", "data: {\"type\":\"message_stop\"}\n\n" ) .to_string(), @@ -5013,6 +5748,14 @@ mod tests { response.response_id.as_deref(), Some("req_anthropic_stream_01") ); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 12, + completion_tokens: 3, + total_tokens: 15, + }) + ); } // 以下三个流式工具用例使用取自真实端点的 checked-in SSE fixture:Anthropic 与 diff --git a/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs b/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs index d40ee8def..fe35e7891 100644 --- a/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs +++ b/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs @@ -3,10 +3,10 @@ //! 仓库根目录没有 Cargo.toml,必须显式指定 workspace manifest: //! //! ```powershell -//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.minimaxi.com/anthropic' +//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.anthropic.com' //! $env:PLATFORM_LLM_LIVE_API_KEY = '...' -//! $env:PLATFORM_LLM_LIVE_MODEL = 'MiniMax-M3' -//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' # 或 openai_chat / openai_responses +//! $env:PLATFORM_LLM_LIVE_MODEL = '<当前支持 strict tool use 的 Claude 模型>' +//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' //! cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture //! ``` //! @@ -107,7 +107,8 @@ async fn live_stream_run_returns_native_tool_calls() { 0, 1_000, ) - .expect("live config should be valid"); + .expect("live config should be valid") + .with_anthropic_strict_tool_support(api_kind == LlmApiKind::Anthropic); let client = LlmClient::new(config).expect("live client should be created"); let request = LlmRunRequest::new(vec![ @@ -116,15 +117,27 @@ async fn live_stream_run_returns_native_tool_calls() { ]) .with_api_kind(api_kind) .with_max_output_tokens(512) - .with_function_tools(vec![LlmFunctionTool::new( - "get_weather", - "查询指定城市的当前天气。", - serde_json::json!({ - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"] - }), - )]) + .with_function_tools(vec![ + LlmFunctionTool::new( + "get_weather", + "查询指定城市的当前天气。", + serde_json::json!({ + "type": "object", + "$defs": { + "WeatherRequest": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + } + }, + "properties": { "request": { "$ref": "#/$defs/WeatherRequest" } }, + "required": ["request"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]) .with_tool_choice(LlmToolChoice::Required); let mut streamed_chars = 0usize; @@ -157,7 +170,10 @@ async fn live_stream_run_returns_native_tool_calls() { let arguments: serde_json::Value = serde_json::from_str(&call.arguments).expect("参数必须是完整 JSON"); assert!( - arguments.get("city").is_some(), - "参数应包含 city,实际为 {arguments}" + arguments + .get("request") + .and_then(|request| request.get("city")) + .is_some(), + "参数应包含 request.city,实际为 {arguments}" ); } diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs index a93705986..945c8adf7 100644 --- a/server-rs/crates/shared-contracts/src/external_generation.rs +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -51,6 +52,37 @@ pub struct ExternalGenerationJobStatusResponse { pub job: ExternalGenerationJobStatusDetailRecord, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorGenerationSubmissionResponse { + pub operation_id: String, + pub kind: String, + pub status: ExternalGenerationJobStatus, + pub status_url: String, + pub poll_after_ms: u64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalEditorGenerationJobResponse { + pub operation_id: String, + pub kind: String, + pub status: ExternalGenerationJobStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub poll_after_ms: Option, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalGenerationTaskRecord { diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index ad0cf6db0..0d2225abe 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -275,7 +275,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "确定视觉方向与规范图", GameCreationAppAgentGroup::Art, "Director", - ["design-director"], + [], [ ".agent/passes/pass-*/groups/art/director.md", "assets/art-spec.png", @@ -287,7 +287,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "确定玩法规格与界面原型", GameCreationAppAgentGroup::Design, "Gameplay", - ["art-director"], + ["design-director", "art-director"], [ "memory/project.md", "game/game_design.md", @@ -358,12 +358,7 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "拆解程序实现", GameCreationAppAgentGroup::Code, "Director", - [ - "design-foundation", - "balance-seed", - "art-polish", - "audio-asset-plan", - ], + [], [".agent/passes/pass-*/groups/code/director.md"], ["渲染、输入、状态和数据读取边界明确"], ), @@ -372,7 +367,12 @@ pub fn new_game_creation_app_seed_tasks() -> Vec { "生成可运行原型", GameCreationAppAgentGroup::Code, "Code", - ["code-director"], + [ + "code-director", + "balance-seed", + "art-polish", + "audio-asset-plan", + ], ["game/"], ["本地 Web 游戏项目可以通过 HTTP server 打开"], ), @@ -1375,6 +1375,14 @@ mod tests { ] ); assert_eq!(manifest.tasks[0].group, GameCreationAppAgentGroup::Design); + for director_id in ["design-director", "art-director", "code-director"] { + let director = manifest + .tasks + .iter() + .find(|task| task.id == director_id) + .expect("director task"); + assert!(director.dependencies.is_empty()); + } let design = manifest .tasks .iter() @@ -1384,7 +1392,7 @@ mod tests { assert_eq!(design.group, GameCreationAppAgentGroup::Design); assert_eq!(design.role, "Gameplay"); assert_eq!(design.status, GameCreationAppTaskStatus::Pending); - assert_eq!(design.dependencies, ["art-director"]); + assert_eq!(design.dependencies, ["design-director", "art-director"]); assert_eq!( design.artifacts, [ @@ -1420,6 +1428,20 @@ mod tests { "角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记" ] ); + let code_prototype = manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .expect("code prototype task"); + assert_eq!( + code_prototype.dependencies, + [ + "code-director", + "balance-seed", + "art-polish", + "audio-asset-plan" + ] + ); } #[test] @@ -1431,30 +1453,17 @@ mod tests { .iter() .map(|task| task.id.as_str()) .collect::>(), - vec!["design-director"] + vec!["design-director", "art-director", "code-director"] ); - manifest - .tasks - .iter_mut() - .find(|task| task.id == "design-director") - .unwrap() - .status = GameCreationAppTaskStatus::Completed; - - assert_eq!( - select_game_creation_app_ready_tasks(&manifest) - .iter() - .map(|task| task.id.as_str()) - .collect::>(), - vec!["art-director"] - ); - - manifest - .tasks - .iter_mut() - .find(|task| task.id == "art-director") - .unwrap() - .status = GameCreationAppTaskStatus::Completed; + for director_id in ["design-director", "art-director", "code-director"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == director_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } assert_eq!( select_game_creation_app_ready_tasks(&manifest) @@ -1478,6 +1487,96 @@ mod tests { .collect::>(), vec!["balance-director", "art-asset-plan", "audio-director"] ); + + for task_id in ["balance-director", "art-asset-plan", "audio-director"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["balance-seed", "art-polish", "audio-asset-plan"] + ); + + manifest + .tasks + .iter_mut() + .find(|task| task.id == "balance-seed") + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["art-polish", "audio-asset-plan"] + ); + + for task_id in ["art-polish", "audio-asset-plan"] { + manifest + .tasks + .iter_mut() + .find(|task| task.id == task_id) + .unwrap() + .status = GameCreationAppTaskStatus::Completed; + } + assert_eq!( + select_game_creation_app_ready_tasks(&manifest) + .iter() + .map(|task| task.id.as_str()) + .collect::>(), + vec!["code-prototype"] + ); + } + + #[test] + fn seed_task_graph_is_acyclic_and_references_known_dependencies() { + let tasks = new_game_creation_app_seed_tasks(); + let known_ids = tasks + .iter() + .map(|task| task.id.as_str()) + .collect::>(); + assert!(tasks.iter().all(|task| { + task.dependencies + .iter() + .all(|dependency| known_ids.contains(dependency.as_str())) + })); + + let mut remaining = tasks + .iter() + .map(|task| { + ( + task.id.as_str(), + task.dependencies + .iter() + .map(String::as_str) + .collect::>(), + ) + }) + .collect::>(); + let mut visited = 0; + while !remaining.is_empty() { + let ready_ids = remaining + .iter() + .filter(|(_, dependencies)| dependencies.is_empty()) + .map(|(id, _)| *id) + .collect::>(); + assert!(!ready_ids.is_empty(), "seed task graph contains a cycle"); + remaining.retain(|(id, _)| !ready_ids.contains(id)); + visited += ready_ids.len(); + for (_, dependencies) in &mut remaining { + for ready_id in &ready_ids { + dependencies.remove(ready_id); + } + } + } + assert_eq!(visited, tasks.len()); } #[test] diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index c307a36ee..ffe305bff 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -428,6 +428,24 @@ describe('ImageCanvasEditorView generation integration', () => { }), ], layers: [ + ...projectLayers, + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${canvasCompletion.dialogId}`, + resourceId: `generation-dialog:${canvasCompletion.dialogId}`, + dialog: { + id: canvasCompletion.dialogId, + mode: dialogMode, + prompt, + status: 'idle', + composerOpen: false, + generatedLayerId: layerId, + placeholder, + imageModel: model, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + }, { layerId, resourceId, @@ -450,24 +468,6 @@ describe('ImageCanvasEditorView generation integration', () => { assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, }, - { - itemType: 'generation-dialog', - layerId: `generation-dialog:${canvasCompletion.dialogId}`, - resourceId: `generation-dialog:${canvasCompletion.dialogId}`, - dialog: { - id: canvasCompletion.dialogId, - mode: dialogMode, - prompt, - status: 'idle', - composerOpen: true, - generatedLayerId: layerId, - placeholder, - imageModel: model, - aspectRatio: input.aspectRatio, - imageSize: input.imageSize, - }, - }, - ...projectLayers, ], updatedAt: '2026-06-19T00:00:00.000Z', }, @@ -887,10 +887,7 @@ describe('ImageCanvasEditorView generation integration', () => { .getByAltText(/画布图片:生成图片/) .closest('button')!; expect(generatedLayer).toBeTruthy(); - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect( Number.isFinite( Number.parseFloat((generatedLayer as HTMLElement).style.top), @@ -978,10 +975,7 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; - const anchoredGenerateDialog = screen.getByRole('dialog', { - name: '生成图片', - }); - expect(anchoredGenerateDialog).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect( Number.parseFloat((generatedLayer as HTMLElement).style.left) + @@ -1522,7 +1516,28 @@ describe('ImageCanvasEditorView generation integration', () => { expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); - expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 1, + layers: expect.arrayContaining([ + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + mode: 'spec', + status: 'idle', + generatedLayerId: 'layer-editor-spec-role-1', + specValues: expect.objectContaining({ + playSetting: '平台跳跃玩法', + artStyle: '低多边形卡通', + bodyRatio: '4', + characterView: '左向三分之二侧身站姿', + }), + }), + }), + ]), + }), + ); }); it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { @@ -2094,10 +2109,11 @@ describe('ImageCanvasEditorView generation integration', () => { const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); const generatedLayerButton = generatedImage.closest('button')!; + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + fireEvent.click(generatedLayerButton); expect(generatedLayerButton.className).toContain( 'image-canvas-editor__layer--selected', ); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, @@ -3652,7 +3668,7 @@ describe('ImageCanvasEditorView generation integration', () => { .closest('button') as HTMLElement; expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); - expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); const metadataCornerButton = screen.getAllByRole('button', { name: /查看生成图片 .*图片信息/, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e6f8a7236..f608fe9c1 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1152,6 +1152,8 @@ export function ImageCanvasEditorView({ viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, + selectedLayerIdRef, + selectedLayerIdsRef, }), [], ); @@ -1161,6 +1163,8 @@ export function ImageCanvasEditorView({ setProjectRenameValue, setViewport, setLayers, + setSelectedLayerId, + setSelectedLayerIds, selectSingleLayer, setLayerCounter: (value: number) => { layerCounterRef.current = value; @@ -1170,6 +1174,8 @@ export function ImageCanvasEditorView({ }), [ applyCanvasBackgroundColor, + setSelectedLayerId, + setSelectedLayerIds, restoreCanvasGenerationDialogs, selectSingleLayer, setLayers, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 03f7d4439..3dae18b95 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -5,7 +5,10 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; -import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; +import type { + EditorProjectLayerSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { DEFAULT_CANVAS_BACKGROUND_COLOR, normalizeCanvasBackgroundHex, @@ -15,7 +18,10 @@ import type { CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; -import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; +import { + mergeAuthoritativeCanvasLayoutWithPendingLocalLayout, + useImageCanvasProjectPersistence, +} from './useImageCanvasProjectPersistence'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn()); @@ -84,6 +90,245 @@ function createDeferred() { return { promise, resolve, reject }; } +function createCompletedEditorProjectSnapshot( + revision = 1, +): EditorProjectSnapshot { + return { + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:01.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '后端完成生成器', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-generated', + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 420, + originalHeight: 420, + }, + }, + }, + { + layerId: 'layer-generated', + resourceId: 'resource-generated', + title: '生成结果', + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 420, + originalHeight: 420, + zIndex: 1, + sourceType: 'generated', + }, + ], + resources: [ + { + resourceId: 'resource-generated', + projectId: 'editor-project-default', + imageSrc: '/generated/result.png', + objectKey: 'generated/result.png', + assetObjectId: 'asset-object-result', + width: 420, + height: 420, + sourceType: 'generated', + }, + ], + updatedAt: '2026-06-12T00:00:01.000Z', + }; +} + +it('merges pending geometry and dialog edits while retaining backend additions and deletions', () => { + const authoritativeItems: EditorProjectLayerSnapshot[] = [ + { + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#FFFFFF', + }, + { + layerId: 'layer-existing', + resourceId: 'resource-existing-v2', + title: '后端标题', + x: 10, + y: 20, + zIndex: 1, + sourceType: 'generated', + objectKey: 'generated/new.png', + }, + { + layerId: 'layer-deleted-locally', + resourceId: 'resource-deleted-locally', + title: '本地已删除', + x: 0, + y: 0, + zIndex: 2, + sourceType: 'generated', + }, + { + layerId: 'layer-generated-by-backend', + resourceId: 'resource-generated-by-backend', + title: '后端新增结果', + x: 40, + y: 50, + zIndex: 3, + sourceType: 'generated', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:dialog-1', + resourceId: 'generation-dialog:dialog-1', + dialog: { + id: 'dialog-1', + mode: 'generate', + prompt: '后端原提示词', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-generated-by-backend', + placeholder: { + x: 40, + y: 50, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }, + ]; + const pendingItems: EditorProjectLayerSnapshot[] = [ + { + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#AABBCC', + }, + { + layerId: 'layer-existing', + resourceId: 'resource-existing-v1', + title: '本地标题', + x: 88, + y: 99, + zIndex: 7, + sourceType: 'generated', + objectKey: 'generated/old.png', + }, + { + layerId: 'layer-deleted-by-backend', + resourceId: 'resource-deleted-by-backend', + title: '后端已删除', + x: 1, + y: 2, + zIndex: 8, + sourceType: 'generated', + }, + { + layerId: 'layer-added-locally', + resourceId: 'resource-added-locally', + title: '本地新增', + x: 3, + y: 4, + zIndex: 9, + sourceType: 'generated', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:dialog-1', + resourceId: 'generation-dialog:dialog-1', + dialog: { + id: 'dialog-1', + mode: 'generate', + prompt: '请求在途期间的新提示词', + status: 'generating', + composerOpen: true, + placeholder: { + x: 88, + y: 99, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }, + ]; + + const merged = mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({ + authoritativeItems, + pendingItems, + previousAuthoritativeItemIds: new Set([ + 'canvas-settings:default', + 'layer-existing', + 'layer-deleted-locally', + 'layer-deleted-by-backend', + 'generation-dialog:dialog-1', + ]), + }); + + expect(merged).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'canvas-settings:default', + canvasBackgroundColor: '#AABBCC', + }), + expect.objectContaining({ + layerId: 'layer-existing', + resourceId: 'resource-existing-v2', + title: '本地标题', + x: 88, + y: 99, + zIndex: 7, + objectKey: 'generated/new.png', + }), + expect.objectContaining({ + layerId: 'layer-generated-by-backend', + resourceId: 'resource-generated-by-backend', + }), + expect.objectContaining({ + layerId: 'layer-added-locally', + resourceId: 'resource-added-locally', + }), + expect.objectContaining({ + layerId: 'generation-dialog:dialog-1', + dialog: expect.objectContaining({ + prompt: '请求在途期间的新提示词', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-generated-by-backend', + placeholder: expect.objectContaining({ x: 88, y: 99 }), + }), + }), + ]), + ); + expect(merged.some((item) => item.layerId === 'layer-deleted-locally')).toBe( + false, + ); + expect( + merged.some((item) => item.layerId === 'layer-deleted-by-backend'), + ).toBe(false); +}); + function ProjectPersistenceHarness({ canAccessProtectedData = true, currentUserId = 'user-test', @@ -114,11 +359,14 @@ function ProjectPersistenceHarness({ const [projectTitle, setProjectTitle] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState(''); const [flushCompleted, setFlushCompleted] = useState(false); + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [selectedLayerIds, setSelectedLayerIds] = useState([]); const layersRef = useRef(layers); const viewportRef = useRef(viewport); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); - const selectedLayerRef = useRef(null); + const selectedLayerRef = useRef(selectedLayerId); + const selectedLayerIdsRef = useRef(selectedLayerIds); const layerCounterRef = useRef(0); const openEditorLoginModalRef = useRef(vi.fn()); @@ -126,8 +374,11 @@ function ProjectPersistenceHarness({ viewportRef.current = viewport; canvasGenerationDialogsRef.current = generationDialogs; canvasBackgroundColorRef.current = canvasBackgroundColor; + selectedLayerRef.current = selectedLayerId; + selectedLayerIdsRef.current = selectedLayerIds; const selectSingleLayer = useCallback((layerId: string | null) => { - selectedLayerRef.current = layerId; + setSelectedLayerId(layerId); + setSelectedLayerIds(layerId ? [layerId] : []); }, []); const setLayerCounter = useCallback((value: number) => { layerCounterRef.current = value; @@ -146,6 +397,8 @@ function ProjectPersistenceHarness({ viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, + selectedLayerIdRef: selectedLayerRef, + selectedLayerIdsRef, }), [], ); @@ -155,6 +408,8 @@ function ProjectPersistenceHarness({ setProjectRenameValue, setViewport, setLayers, + setSelectedLayerId, + setSelectedLayerIds, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs: setGenerationDialogs, @@ -194,6 +449,9 @@ function ProjectPersistenceHarness({ .join(',')} {selectedLayerRef.current ?? '-'} + + {selectedLayerIdsRef.current.join(',') || '-'} + {layerCounterRef.current} {viewport.x},{viewport.y},{viewport.scale} @@ -330,6 +588,16 @@ function ProjectPersistenceHarness({ > append generated + + +