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/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/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 3d079a82f..562e9baaf 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -300,80 +300,52 @@ function stopChild(child, signal = 'SIGTERM') { } } -function parseLinuxProcessStat(value) { - const commandEnd = value.lastIndexOf(')'); - if (commandEnd < 0) { - return null; - } - const fields = value - .slice(commandEnd + 1) - .trim() - .split(/\s+/); - if (fields.length < 3 || fields[0].length !== 1) { - return null; - } - const processGroupId = Number(fields[2]); - if (!Number.isInteger(processGroupId)) { - return null; - } - return { state: fields[0], processGroupId }; -} - -function readLinuxProcessGroupRunning( +function readLinuxProcessGroupAlive( processGroupId, - { - procRoot = '/proc', - readdirImpl = readdirSync, - readFileImpl = readFileSync, - } = {}, + { readdirImpl = readdirSync, readFileImpl = readFileSync } = {}, ) { - let entries; + let processIds; try { - entries = readdirImpl(procRoot, { withFileTypes: true }); + processIds = readdirImpl('/proc'); } catch { return null; } - let inspectedProcess = false; - for (const entry of entries) { - const name = typeof entry === 'string' ? entry : entry.name; - if (!/^\d+$/.test(name)) { - continue; - } - if (typeof entry !== 'string' && !entry.isDirectory()) { + for (const processId of processIds) { + if (!/^\d+$/.test(processId)) { continue; } let stat; try { - stat = readFileImpl(join(procRoot, name, 'stat'), 'utf8'); - } catch (error) { - // 进程可能在枚举后立刻退出;继续检查同组的其它成员。 - if (error?.code === 'ENOENT' || error?.code === 'ESRCH') { - continue; - } - return null; + stat = readFileImpl(`/proc/${processId}/stat`, 'utf8'); + } catch { + continue; } - const parsed = parseLinuxProcessStat(stat); - if (!parsed) { - return null; + const commandEnd = stat.lastIndexOf(') '); + if (commandEnd < 0) { + continue; } - inspectedProcess = true; + const [state, , processGroup] = stat + .slice(commandEnd + 2) + .trim() + .split(/\s+/); if ( - parsed?.processGroupId === processGroupId && - !['Z', 'X', 'x'].includes(parsed.state) + Number(processGroup) === processGroupId && + state !== 'Z' && + state !== 'X' ) { return true; } } - return inspectedProcess ? false : null; + return false; } -function isProcessGroupRunning( +function isProcessGroupAlive( processGroupId, { platform = process.platform, killImpl = process.kill, - readLinuxProcessGroup = readLinuxProcessGroupRunning, + readLinuxGroupAlive = readLinuxProcessGroupAlive, } = {}, ) { if (!Number.isInteger(processGroupId)) { @@ -384,16 +356,13 @@ function isProcessGroupRunning( } catch (error) { return error?.code !== 'ESRCH'; } - if (platform !== 'linux') { - return true; - } - try { - // Linux 的 kill(-PGID, 0) 会把尚未被容器 PID 1 回收的 zombie 也视为 - // 存在;zombie 已不能执行代码,不应让有界清理被误判为失败。 - return readLinuxProcessGroup(processGroupId) ?? true; - } catch { - return true; + if (platform === 'linux') { + const linuxGroupAlive = readLinuxGroupAlive(processGroupId); + if (typeof linuxGroupAlive === 'boolean') { + return linuxGroupAlive; + } } + return true; } async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { @@ -499,7 +468,7 @@ async function terminateChildTree( stopChild(child, 'SIGTERM'); if ( await waitUntil( - () => !isProcessGroupRunning(processGroupId, { platform, killImpl }), + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), gracefulTimeoutMs, ) ) { @@ -514,7 +483,7 @@ async function terminateChildTree( } } const stopped = await waitUntil( - () => !isProcessGroupRunning(processGroupId, { platform, killImpl }), + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), forceTimeoutMs, ); return { stopped, forced: true }; @@ -684,10 +653,10 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, - isProcessGroupRunning, + isProcessGroupAlive, preflightExistingVite, readChildFailure, - readLinuxProcessGroupRunning, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, 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 cbaaae623..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 @@ -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 } 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/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/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ba11e9fd4..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 @@ -503,6 +503,7 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { #[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" @@ -559,10 +560,10 @@ fn non_stream_professional_final_reply_remains_queryable_after_later_project_rev &[], ) .expect("finalize non-stream professional reply"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); + 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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 8f4a1992b..153fd95dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -332,28 +332,51 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( session_id: &str, fallback: AgentRuntimeState, ) -> AgentBackgroundTaskOutcome { - let runtime = latest_game_chat_deadline_runtime_at(root, fallback); + let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback); let pending_action = read_game_creator_agent_runtime_pending_tool_action( root, &runtime.agent_id, &runtime.run_id, ) .ok(); - let error = format!( - "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", - GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS - ); - // The generic background failure helper deliberately preserves a durable - // needs-reconciliation state. A hard deadline is different: no action may - // remain recoverable after the root budget expires. Persist the terminal - // failure first, explicitly bypassing that guard, and only then remove the - // recovery material. - let terminal_failure_error = fail_game_creator_agent_runtime_turn_at( - root, - runtime.clone(), - &redact_agent_runtime_error(root, &error, 500), - ) - .err(); + let preserves_external_reconciliation = pending_action.as_ref().is_some_and(|pending| { + pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.action.tool == "canvas.asset_generate" + }); + let external_generation_record_preserved = preserves_external_reconciliation + && game_creator_agent_runtime_external_generation_exists( + root, + &runtime.agent_id, + &runtime.run_id, + ); + let error = if preserves_external_reconciliation { + format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;外部生成结果未知,已结束本轮并保留人工对账证据", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ) + } else { + format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ) + }; + let terminal_failure_error = if let Some(pending) = pending_action + .as_ref() + .filter(|_| preserves_external_reconciliation) + { + mark_game_creator_agent_runtime_needs_reconciliation_at(root, &mut runtime, pending, &error) + .err() + } else { + // Local and read-only work remains safe to cancel at the product hard + // deadline. Unknown external generation side effects are handled above + // and must retain their durable pending action instead. + fail_game_creator_agent_runtime_turn_at( + root, + runtime.clone(), + &redact_agent_runtime_error(root, &error, 500), + ) + .err() + }; let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime); let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root)); let process_cleanup_error = terminate_process_sessions_for_run_at( @@ -362,18 +385,7 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( &terminal_runtime.run_id, ) .err(); - let mut cleanup_errors = Vec::new(); - for result in [ - remove_game_creator_agent_runtime_pending_tool_action( - root, - &terminal_runtime.agent_id, - &terminal_runtime.run_id, - ), - remove_game_creator_agent_runtime_provider_action_batch( - root, - &terminal_runtime.agent_id, - &terminal_runtime.run_id, - ), + let mut cleanup_results = vec![ remove_game_creator_agent_runtime_confirmations( root, &terminal_runtime.agent_id, @@ -384,7 +396,21 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( &terminal_runtime.agent_id, &terminal_runtime.run_id, ), - ] { + ]; + if !preserves_external_reconciliation { + cleanup_results.push(remove_game_creator_agent_runtime_pending_tool_action( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + )); + cleanup_results.push(remove_game_creator_agent_runtime_provider_action_batch( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + )); + } + let mut cleanup_errors = Vec::new(); + for result in cleanup_results { if let Err(error) = result { cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); } @@ -406,6 +432,8 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( "hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, "pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()), "pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()), + "reconciliationPreserved": preserves_external_reconciliation, + "externalGenerationRecordPreserved": external_generation_record_preserved, "previewStopped": preview_stopped, "cleanupErrorCount": cleanup_errors.len(), }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 9c584173a..843dc4c06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -62,8 +62,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() assert_eq!(result.expect("in-flight action completes"), "completed"); } -#[test] -fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() { +#[tokio::test] +async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() { let root = std::env::temp_dir().join(format!( "genarrative-game-chat-deadline-reconciliation-{}-{}", std::process::id(), @@ -74,11 +74,218 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea )); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-deadline-reconciliation-root-run", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous game-chat root profile"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + "art-director", + "game-chat-deadline-reconciliation-run", + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()), + delegation_id: None, + }), + ) + .expect("bind autonomous game-chat art profile"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "art-director", + "执行可能悬挂的外部图片生成", + "game-chat-deadline-reconciliation-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 queued_action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("验证同批次后续外部生成不会在恢复时重放".to_string()), + input: serde_json::json!({ + "prompt": "生成首版角色立绘", + "outputPath": "assets/hero.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备外部图片生成".to_string(), + plan_update: None, + plan: vec![ + "生成首版统一视觉规范图".to_string(), + "生成首版角色立绘".to_string(), + ], + actions: vec![action.clone(), queued_action], + response: String::new(), + }; + let project_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; + let prepared_batch = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &project_revision, + &repository_fingerprint, + ) + .await + .expect("prepare durable provider action batch"); + let batch = match prepared_batch { + AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch, + other => panic!("expected ready provider action batch, got {other:?}"), + }; + assert_eq!(batch.actions.len(), 2); + let mut pending = batch.actions[0].clone(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + 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 External Editor generation ledger"); + update_game_creator_agent_runtime_provider_batch_member(&root, &pending) + .expect("persist executing provider batch member"); + let executing_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read executing provider action batch"); + assert_eq!( + executing_batch.actions[0].status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + assert_eq!( + executing_batch.actions[1].status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(game_creator_agent_runtime_external_generation_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "running".to_string(); + runtime.phase = "action".to_string(); + runtime.current_action = "调用工具 canvas.asset_generate".to_string(); + runtime.waiting_on.clear(); + runtime.next_step = "等待外部生成结果".to_string(); + runtime.error = None; + append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state"); + + let outcome = finish_game_chat_absolute_deadline_timeout_at( + &root, + &runtime.agent_id, + &runtime.session_id, + runtime.clone(), + ); + + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) + .expect("read terminal runtime") + .state; + assert_eq!(terminal.run_id, runtime.run_id); + assert_eq!(terminal.status, "failed"); + assert_eq!(terminal.phase, "needs-reconciliation"); + assert!(terminal.pending_tool_action.is_some()); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + let durable_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("preserved external generation pending action"); + assert_eq!( + durable_pending.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + let preserved_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read preserved provider action batch"); + assert_eq!(preserved_batch, executing_batch); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"reconciliationPreserved\":true")); + assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true")); + assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation")); + assert!(!agent_db.contains("test-operation-id")); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("scan durable runtime state after simulated runner restart"); + assert!(resumed.iter().any(|result| { + result.state.agent_id == runtime.agent_id + && result.state.run_id == runtime.run_id + && result.state.phase == "needs-reconciliation" + })); + let recovered_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read pending action after recovery scan"); + assert_eq!(recovered_pending, durable_pending); + let recovered_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read provider action batch after recovery scan"); + assert_eq!(recovered_batch, preserved_batch); + assert!(game_creator_agent_runtime_external_generation_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + assert_eq!( + fs::read_to_string(root.join(".agent/agent.db")).expect("agent db after recovery scan"), + agent_db, + "needs-reconciliation recovery barrier must not append a replay receipt" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { + let root = std::env::temp_dir().join(format!( + "genarrative-game-chat-deadline-local-cleanup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") + .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( &root, "code-prototype", "执行可能悬挂的首版写入", - "game-chat-deadline-reconciliation-run", + "game-chat-deadline-local-cleanup-run", "agent-ready-task-scheduler", "正在执行首版写入", vec!["执行首版写入".to_string()], @@ -87,7 +294,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea runtime.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "file.write".to_string(), - reason: Some("模拟截止时仍在途的写入".to_string()), + reason: Some("模拟截止时仍在途的本地写入".to_string()), input: serde_json::json!({ "path": "game/index.html", "content": "deadline" @@ -105,7 +312,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea let repository_fingerprint = build_repository_startup_context_at(&root) .expect("repository context") .fingerprint; - let pending = build_game_creator_agent_runtime_pending_tool_action( + let mut pending = build_game_creator_agent_runtime_pending_tool_action( &root, &runtime, &runtime.current_task, @@ -120,17 +327,12 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea None, ) .expect("build pending action"); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write pending action"); runtime.pending_tool_action = Some(pending.summary()); - runtime.status = "failed".to_string(); - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "等待人工核对在途动作".to_string(); - runtime.waiting_on = "开发者核对副作用".to_string(); - runtime.next_step = "核对后恢复".to_string(); - runtime.error = Some("模拟 needs-reconciliation".to_string()); - append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task"); - write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state"); + append_game_creator_agent_runtime_task(&root, &runtime).expect("append runtime task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write runtime state"); let outcome = finish_game_chat_absolute_deadline_timeout_at( &root, @@ -143,7 +345,6 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) .expect("read terminal runtime") .state; - assert_eq!(terminal.run_id, runtime.run_id); assert_eq!(terminal.status, "failed"); assert_eq!(terminal.phase, "failed"); assert!(terminal.pending_tool_action.is_none()); @@ -152,11 +353,8 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea &runtime.agent_id, &runtime.run_id )); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - &root, - &runtime.agent_id, - &runtime.run_id - )); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"reconciliationPreserved\":false")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index 672ea98f7..6940911ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -1,5 +1,18 @@ use super::*; +async fn run_join_owned_pending_task( + future: std::pin::Pin + Send + 'static>>, +) -> Result +where + T: Send + 'static, +{ + let mut tasks = tokio::task::JoinSet::new(); + tasks.spawn(future); + tasks + .join_next() + .await + .expect("pending continuation task must exist") +} async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -10,39 +23,13 @@ where // poll frames. The boxed future keeps that large frame out of its caller before a joined child // task gives it an independent poll boundary. JoinSet still aborts the child if its parent // continuation is dropped. - let mut tasks = tokio::task::JoinSet::new(); - tasks.spawn(future); - match tasks - .join_next() - .await - .expect("pending continuation task must exist") - { + match run_join_owned_pending_task(future).await { Ok(output) => output, Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), Err(error) => panic!("pending continuation task was cancelled: {error}"), } } -async fn run_game_creator_agent_background_task_after_pending_stack_boundary( - root: PathBuf, - agent_id: String, - task: String, - runtime: AgentRuntimeState, - continuation: AgentRuntimeContinuationContext, -) -> AgentBackgroundTaskOutcome { - run_after_pending_stack_boundary(Box::pin(async move { - run_game_creator_agent_background_task_with_context( - root, - agent_id, - task, - runtime, - continuation, - ) - .await - })) - .await -} - async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( root: PathBuf, agent_id: String, @@ -53,6 +40,115 @@ async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boun .await; } +async fn run_recovered_game_creator_context_on_fresh_task( + root: PathBuf, + agent_id: String, + task: String, + runtime: AgentRuntimeState, + continuation: AgentRuntimeContinuationContext, +) -> Result { + run_join_owned_pending_task(Box::pin(async move { + run_game_creator_agent_background_task_with_context( + root, + agent_id, + task, + runtime, + continuation, + ) + .await + })) + .await + .map_err(|error| format!("恢复 Agent Runtime continuation 的独立任务异常结束:{error}")) +} + +fn mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root: &Path, + runtime: &mut AgentRuntimeState, + error: &str, +) -> Result<(), String> { + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string(); + runtime.waiting_on = "开发者核对已持久化工具观察与 Provider 状态".to_string(); + runtime.next_step = "核对外部结果后显式取消或恢复当前 run".to_string(); + runtime.pending_tool_action = None; + runtime.error = Some(redact_agent_runtime_error(root, error, 500)); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_event( + root, + runtime, + "runtime.continuation.needs_reconciliation", + "failed", + "needs-reconciliation", + "恢复后的 Runtime continuation 异常结束,已保留持久化证据并停止自动续跑。", + runtime.error.as_deref(), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.continuation.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + )?; + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + +fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + root: &Path, + runtime: &mut AgentRuntimeState, + join_error: &str, + persistence_error: &str, +) { + let error = redact_agent_runtime_error( + root, + &format!( + "恢复 continuation 异常结束,且正式 reconciliation 持久化不完整;joinError={join_error};persistenceError={persistence_error}" + ), + 500, + ); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string(); + runtime.waiting_on = "开发者核对 Runtime 持久化证据".to_string(); + runtime.next_step = "修复持久化链后显式取消或恢复当前 run".to_string(); + runtime.pending_tool_action = None; + runtime.error = Some(error.clone()); + runtime.updated_at = unix_timestamp(); + let _ = append_game_creator_agent_runtime_task(root, runtime); + let _ = refresh_game_creator_agent_runtime_task_queue(root, runtime); + let _ = write_game_creator_agent_runtime_state(root, runtime); + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "runtime.continuation.reconciliation_persistence_failed", + "failed", + "needs-reconciliation", + "Runtime continuation 异常后的正式对账记录未完整落盘,已尝试写入紧急阻断。", + Some(&error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.continuation.reconciliation_persistence_failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": error, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); +} pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, @@ -129,14 +225,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); continuation.context_stalled = false; continuation.applied_steer_cursor = batch.planned_steer_cursor; - let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( + let runtime_fallback = runtime.clone(); + let outcome = match run_recovered_game_creator_context_on_fresh_task( root.clone(), agent_id.clone(), pending.task.clone(), runtime, continuation, ) - .await; + .await + { + Ok(outcome) => outcome, + Err(error) => { + let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id) + .ok() + .filter(|current| current.state.run_id == pending.run_id) + .map(|current| current.state) + .unwrap_or(runtime_fallback); + if let Err(persistence_error) = + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + &root, + &mut failed_runtime, + &error, + ) + { + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + &root, + &mut failed_runtime, + &error, + &persistence_error, + ); + } + return; + } + }; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( root, agent_id, @@ -276,7 +398,48 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( &root, &pending, ); } - let observation = + let observation = if action.tool == "canvas.asset_generate" + && game_creator_agent_runtime_external_generation_exists( + &root, + &pending.agent_id, + &pending.run_id, + ) { + // Recovery already adds a deep pending/runtime continuation stack. Poll the + // durable external-generation execution in a fresh Tokio task so the normal + // 2 MiB worker stack is sufficient while this task keeps the Agent lock held. + let execution_root = root.clone(); + let execution_agent_id = agent_id.clone(); + let execution_pending = pending.clone(); + let execution_action = action.clone(); + match tauri::async_runtime::spawn(async move { + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &execution_root, + &execution_agent_id, + &execution_pending.run_id, + &execution_pending.task, + &execution_action, + Some(&execution_pending.action_id), + Some(&execution_pending), + ) + .await + }) + .await + { + Ok(observation) => observation, + Err(error) => AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + .to_string(), + summary: "External Editor 恢复执行任务异常结束,结果需要人工核对" + .to_string(), + detail: Some(redact_agent_runtime_error( + &root, + &error.to_string(), + 500, + )), + }, + } + } else { execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, &agent_id, @@ -286,7 +449,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( Some(&pending.action_id), Some(&pending), ) - .await; + .await + }; if observation.is_waiting_for_confirmation() && auto_execution { pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); @@ -870,14 +1034,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( return; } } - let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( + let runtime_fallback = runtime.clone(); + let outcome = match run_recovered_game_creator_context_on_fresh_task( root.clone(), agent_id.clone(), pending.task.clone(), runtime, continuation, ) - .await; + .await + { + Ok(outcome) => outcome, + Err(error) => { + let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id) + .ok() + .filter(|current| current.state.run_id == pending.run_id) + .map(|current| current.state) + .unwrap_or(runtime_fallback); + if let Err(persistence_error) = + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + &root, + &mut failed_runtime, + &error, + ) + { + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + &root, + &mut failed_runtime, + &error, + &persistence_error, + ); + } + return; + } + }; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) .await; @@ -999,6 +1189,12 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec runtime: &mut AgentRuntimeState, error: &str, ) -> Result<(), String> { + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .ok(); runtime.status = "failed".to_string(); runtime.phase = "needs-reconciliation".to_string(); runtime.current_action = "Provider action 批次需要人工核对".to_string(); @@ -1027,9 +1223,137 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec "taskId": runtime.task_id, "sessionId": runtime.session_id, "runId": runtime.run_id, + "batchId": batch.as_ref().map(|batch| batch.batch_id.as_str()), + "nextActionIndex": batch.as_ref().map(|batch| batch.next_action_index), + "actionCount": batch.as_ref().map(|batch| batch.actions.len()), "error": runtime.error, }), )?; emit_game_creator_agent_runtime_update(root, &runtime.agent_id); Ok(()) } + +#[cfg(test)] +mod continuation_join_error_tests { + use super::*; + + struct PendingChildDropSignal(Option>); + + impl Drop for PendingChildDropSignal { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + fn started_runtime(root: &Path, run_id: &str) -> AgentRuntimeState { + init_local_game_project_at(root, "continuation-join-error", "continuation 对账测试") + .expect("init project"); + start_game_creator_agent_runtime_task_at( + root, + "code-prototype", + "恢复已持久化工具观察", + run_id, + "agent-ready-task-scheduler", + "恢复 continuation", + vec!["恢复 continuation".to_string()], + ) + .expect("start runtime") + } + + #[test] + fn continuation_join_error_persists_full_redacted_reconciliation_projection() { + let temporary = tempfile::tempdir().expect("create continuation reconciliation project"); + let root = temporary.path(); + let mut runtime = started_runtime(root, "continuation-reconciliation-run"); + let sensitive_error = format!( + "panic at {}/private.rs with api key sk-test-secret-value", + root.display() + ); + + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root, + &mut runtime, + &sensitive_error, + ) + .expect("persist continuation reconciliation"); + + let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read reconciled runtime") + .state; + assert_eq!(current.phase, "needs-reconciliation"); + assert!(current.task_queue.failed >= 1); + let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB"); + assert!(persisted.contains("agent.runtime.continuation.needs_reconciliation")); + assert!(!persisted.contains(&root.display().to_string())); + assert!(!persisted.contains("sk-test-secret-value")); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + root, + &runtime.agent_id, + )) + .expect("read runtime events"); + assert!(events.contains("runtime.continuation.needs_reconciliation")); + } + + #[test] + fn continuation_join_error_uses_emergency_audit_when_formal_audit_fails() { + let temporary = tempfile::tempdir().expect("create continuation emergency project"); + let root = temporary.path(); + let mut runtime = started_runtime(root, "continuation-emergency-run"); + fs::create_dir_all(root.join(".agent/runtime")).expect("create runtime directory"); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + "agent.runtime.continuation.needs_reconciliation", + ) + .expect("inject formal audit failure"); + let join_error = "panic at /private/path with sk-emergency-secret"; + let persistence_error = + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root, + &mut runtime, + join_error, + ) + .expect_err("formal reconciliation audit must fail once"); + + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + root, + &mut runtime, + join_error, + &persistence_error, + ); + + let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read emergency reconciled runtime") + .state; + assert_eq!(current.phase, "needs-reconciliation"); + let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB"); + assert!(persisted.contains("agent.runtime.continuation.reconciliation_persistence_failed")); + assert!(!persisted.contains("/private/path")); + assert!(!persisted.contains("sk-emergency-secret")); + } + + #[tokio::test] + async fn pending_child_task_is_aborted_when_its_parent_is_cancelled() { + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel(); + let parent = tokio::spawn(async move { + run_join_owned_pending_task(Box::pin(async move { + let _drop_signal = PendingChildDropSignal(Some(dropped_sender)); + let _ = started_sender.send(()); + std::future::pending::<()>().await; + })) + .await + }); + + started_receiver + .await + .expect("pending child must start before parent cancellation"); + parent.abort(); + let _ = parent.await; + tokio::time::timeout(Duration::from_secs(1), dropped_receiver) + .await + .expect("owned pending child must be aborted with its parent") + .expect("pending child drop signal must be delivered"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 0a6e6de58..38c31ad35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -769,6 +769,51 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } } } + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.action.tool == "canvas.asset_generate" + { + match platform_art_generation_runtime_recovery_at(root, &pending) { + Ok( + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, + ) => { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending.observation = None; + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + } + Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "External Editor 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + Ok(PlatformArtGenerationRuntimeRecovery::Missing) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "canvas.asset_generate 已进入 executing 但缺少 durable External Editor 生成账本", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + Err(error) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + &format!("External Editor 生成账本无法通过恢复校验:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING { let recovered_mcp_observation = match recover_game_creator_mcp_observation_from_sidecar_at(root, &pending) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index ce4e26baf..974216689 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -434,6 +434,7 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( ".agent/runtime/pending-actions", ".agent/runtime/parallel-read-batches", ".agent/runtime/provider-action-batches", + ".agent/runtime/canvas-generation-requests", ".agent/runtime/cancel", ] { if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) { @@ -479,6 +480,143 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo false } +fn cleanup_orphaned_platform_art_generation_runtime_states_at( + root: &Path, +) -> Result { + let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?; + let agent_entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(format!( + "读取 External Editor 生成账本目录失败:{}: {error}", + directory.display() + )); + } + }; + let mut identities = std::collections::BTreeSet::<(String, String)>::new(); + for agent_entry in agent_entries { + let agent_entry = agent_entry.map_err(|error| { + format!( + "遍历 External Editor 生成账本 Agent 目录失败:{}: {error}", + directory.display() + ) + })?; + let agent_metadata = fs::symlink_metadata(agent_entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本 Agent 目录元数据失败:{}: {error}", + agent_entry.path().display() + ) + })?; + if agent_metadata.file_type().is_symlink() || !agent_metadata.is_dir() { + return Err("External Editor 生成账本 Agent 路径必须是普通目录".to_string()); + } + let entries = fs::read_dir(agent_entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本 Agent 目录失败:{}: {error}", + agent_entry.path().display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "遍历 External Editor 生成账本失败:{}: {error}", + agent_entry.path().display() + ) + })?; + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本元数据失败:{}: {error}", + entry.path().display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("External Editor 生成账本必须是普通文件".to_string()); + } + if metadata.len() > 256 * 1024 { + return Err("External Editor 生成账本超过 262144 字节上限".to_string()); + } + let file_name = entry + .file_name() + .to_str() + .map(str::to_string) + .ok_or_else(|| "External Editor 生成账本文件名不是 UTF-8".to_string())?; + if !file_name.ends_with(".json") && !file_name.ends_with(".json.previous") { + return Err(format!("External Editor 生成账本文件名无效:{file_name}")); + } + let payload = fs::read(&entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本失败:{}: {error}", + entry.path().display() + ) + })?; + let payload = + serde_json::from_slice::(&payload).map_err(|error| { + format!( + "解析 External Editor 生成账本失败:{}: {error}", + entry.path().display() + ) + })?; + let schema_version = payload + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + .unwrap_or("(missing)"); + if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + return Err(format!( + "External Editor 生成账本版本无效:{file_name}: {schema_version}" + )); + } + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本缺少 agentId".to_string())?; + let run_id = payload + .get("runId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本缺少 runId".to_string())?; + let expected_agent_component = + agent_runtime_confirmation_path_component(agent_id, "agent"); + let expected_file_name = format!( + "{}.json", + agent_runtime_confirmation_path_component(run_id, "run") + ); + let expected_backup_name = format!(".{expected_file_name}.previous"); + if agent_entry.file_name().to_str() != Some(expected_agent_component.as_str()) + || (file_name != expected_file_name && file_name != expected_backup_name) + { + return Err("External Editor 生成账本路径与内部身份不一致".to_string()); + } + identities.insert((agent_id.to_string(), run_id.to_string())); + } + } + + let mut removed = 0_usize; + for (agent_id, run_id) in identities { + if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) { + continue; + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)? + .ok_or_else(|| { + format!( + "External Editor 孤儿生成账本缺少所属任务,已保留供人工核对:agent={agent_id} runId={run_id}" + ) + })?; + if !matches!(task.status.as_str(), "completed" | "cancelled") + || task.phase == "needs-reconciliation" + { + return Err(format!( + "External Editor 孤儿生成账本所属任务未安全终结,已保留供人工核对:agent={agent_id} runId={run_id} status={} phase={}", + task.status, task.phase + )); + } + remove_platform_art_generation_runtime_state_at(root, &agent_id, &run_id)?; + removed = removed.saturating_add(1); + } + Ok(removed) +} + fn durable_process_session_recovery_exists_at(root: &Path) -> bool { let directory = root.join(".agent/runtime/process-sessions"); let entries = match fs::read_dir(&directory) { @@ -532,6 +670,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); } + cleanup_orphaned_platform_art_generation_runtime_states_at(root)?; let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?; @@ -1075,3 +1214,132 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at( } } } + +#[cfg(test)] +mod orphaned_external_generation_recovery_tests { + use super::*; + + #[test] + fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { + let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let root = temporary.path(); + let run_id = "orphan-generation-recovery-run"; + init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") + .expect("init project"); + 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; + 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, + ) + .expect("build pending identity without writing its sidecar"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write legacy orphan generation state"); + let active_error = resume_game_creator_agent_background_tasks_at(root) + .expect_err("active generation orphan must remain fail closed"); + assert!(active_error.contains("未安全终结")); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + runtime.status = "completed".to_string(); + runtime.phase = "completed".to_string(); + runtime.current_action = "测试任务已完成".to_string(); + runtime.waiting_on.clear(); + runtime.next_step.clear(); + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime).expect("append terminal task"); + write_game_creator_agent_runtime_state(root, &runtime).expect("write terminal state"); + + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert!(has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("orphan initially looks recoverable")); + + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("recovery scan cleans generation orphan"); + assert!(resumed.is_empty()); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert!( + !has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("cleaned orphan must not trigger permanent recovery") + ); + } + + #[cfg(unix)] + #[test] + fn orphan_scan_rejects_symlinked_generation_ledger_root() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("create orphan symlink project"); + let root = project.path(); + init_local_game_project_at(root, "orphan-symlink", "孤儿生成账本符号链接测试") + .expect("init project"); + let outside = tempfile::tempdir().expect("create outside orphan directory"); + let sentinel = outside.path().join("sentinel.json"); + fs::write(&sentinel, 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 orphan directory"); + + let error = cleanup_orphaned_platform_art_generation_runtime_states_at(root) + .expect_err("orphan scan must reject symlinked ledger root"); + assert!(error.contains("符号链接"), "{error}"); + assert_eq!( + fs::read(&sentinel).expect("outside sentinel remains"), + b"outside-sentinel" + ); + } +} 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 90c87ba3a..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 @@ -1549,6 +1549,7 @@ fn gui_ready_child_still_rejects_pending_manifest_status() { #[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) @@ -1613,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] @@ -2222,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", @@ -2243,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_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/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index e8769c440..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 @@ -141,12 +141,81 @@ fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Re 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, 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 1e1357c73..8aa4f0b35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -891,6 +891,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/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 344461628..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 @@ -791,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)) @@ -804,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":"#, @@ -850,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)) 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 c80615e14..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,9 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, - isProcessGroupRunning, + isProcessGroupAlive, preflightExistingVite, - readLinuxProcessGroupRunning, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, @@ -93,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(), @@ -156,40 +186,6 @@ describe('AI 游戏创作启动子进程生命周期', () => { } }); - test('Linux 进程组探活忽略已退出但尚未回收的 zombie', () => { - const stats = new Map([ - ['/proc/4822/stat', '4822 (node worker) Z 1 4821 4821'], - ['/proc/7001/stat', '7001 (other) S 1 7001 7001'], - ]); - const readLinuxGroup = () => - readLinuxProcessGroupRunning(4821, { - readdirImpl: () => [ - { name: '4822', isDirectory: () => true }, - { name: '7001', isDirectory: () => true }, - ], - readFileImpl: (path) => stats.get(path), - }); - - expect(readLinuxGroup()).toBe(false); - expect( - isProcessGroupRunning(4821, { - platform: 'linux', - killImpl: vi.fn(), - readLinuxProcessGroup: readLinuxGroup, - }), - ).toBe(false); - expect( - isProcessGroupRunning(4821, { - platform: 'linux', - killImpl: vi.fn(), - readLinuxProcessGroup: () => null, - }), - ).toBe(true); - - stats.set('/proc/4822/stat', '4822 (node worker) R 1 4821 4821'); - expect(readLinuxGroup()).toBe(true); - }); - test('后端句柄在 ready 等待前交给外层且异常时立即清理', async () => { const child = Object.assign(new EventEmitter(), { exitCode: null, 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 index 9193eb134..86a4ba18f 100644 --- a/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts +++ b/apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { describe, expect, test, vi } from 'vitest'; import { - isProcessGroupRunning, + isProcessGroupAlive, spawnChild, terminateChildTree, } from '../scripts/start-dev-stack.mjs'; @@ -204,7 +204,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => { }); expect(result).toBe(42); - expect(isProcessGroupRunning(cliChild.pid)).toBe(false); + expect(isProcessGroupAlive(cliChild.pid)).toBe(false); } finally { if (Number.isInteger(cliChild?.pid)) { try { 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 cc8dd701a..0f542c3f4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -254,6 +254,8 @@ - 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 等待与耗尽状态使用严格字段派生的安全摘要 @@ -3694,6 +3696,8 @@ - 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。 - 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。 - 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。 +- 决策:平台 portal 主题恢复下沉到 `UnifiedModal`;`portal=true` 默认从 `AuthUiContext` 注入当前 light / dark 主题,已显式给出主题的调用保留原选择,无 Provider 回退 light。`portalTheme="none"` 只用于全黑图片预览等完全自绘弹层,`portal=false` 仍使用原 DOM 主题作用域。图片信息、修改图片与画布快捷键弹窗在完整支持暗色样式前显式使用 `portalTheme="light"`,不将固定白底面板与暗色文本变量混用。共享业务壳不再重复读取 AuthUi 只为 portal 补 class,画布私有变量则继续通过 `ImageCanvasEditorPortal` 桥接。已退役玩法不因该底层修复恢复入口或维护范围。 +- 决策:`PlatformUtilityInfoModal` 未显式传主题时必须沿用 `UnifiedModal` 的 auto 主题,不在共享壳里默认锁定 light。`PublishShareModal` 跟随当前 light / dark 主题;`PlatformReportDialog` 因包含二维码 / 扫码展示区,显式固定 light 以保证白底对比度和识别率。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 - 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。 - 决策:`CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续收口到 `PlatformActionButton`,`pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。 @@ -5771,7 +5775,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())]` 或路由未挂载代替产物证明。 @@ -5973,3 +5977,19 @@ - 显式协作合同: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 8f26bcc0c..acc57dd6f 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -595,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 1aa39dcd2..30f5b50dc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3595,8 +3595,8 @@ - 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。 - 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell` 的 `background: var(--platform-modal-fill)` 和 `.platform-overlay` 的背景声明都会失效。 -- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题。 -- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。 +- 处理:`UnifiedModal` 在 `portal=true` 时默认把 `AuthUiContext.platformTheme` 注入 overlay,共享白底弹窗和直接调用都不应再手工拼接主题 class。完全自绘的黑底预览显式使用 `portalTheme="none"`;已明确固定主题的弹窗使用 `light` / `dark`;局部 CSS 仍固定白底且未完成暗色样式的弹窗,必须暂时显式固定 `light`,否则会出现白底白字或深浅样式混杂;`portal=false` 继续依赖原 DOM 主题作用域。裸 `createPortal` 若使用平台或画布 CSS 变量,必须改用相应的主题 portal 壳,不要用硬编码白底掩盖主题变量缺失。 +- 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap。 - 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。 ## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent @@ -3986,7 +3986,7 @@ - 处理:先以 CAS 单独 commit `queued -> executing`,成功后才调 ToolHost;调用返回后再 commit observation。恢复见到 executing 或 ToolHost 返回 Unknown 时只能进入 reconciliation,不得自动重执行。重复 resume 不得继续增 revision 或重复 event。 - 验证:在“ToolHost 已调用、observation commit 失败”处注入故障,序列化快照并用新 engine 重载;断言重复 resume 后 ToolHost 计数仍为 1,且只有显式 reconcile observation 才恢复 running。 -## 大型 async 状态机不能在同一 Tokio poll 调用栈连续嵌套(2026-08-03) +## 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 默认栈。 @@ -4027,8 +4027,9 @@ - 现象:旧 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`。Linux 容器的 PID 1 可能不及时回收已退出的孤儿后代,`kill(-PGID, 0)` 会继续命中 zombie;Linux 探活必须扫描 `/proc//stat`,只把同 PGID 的非 zombie 成员视为仍在运行,`/proc` 不可读时继续失败关闭。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 -- 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL、Linux 同组只剩 zombie 时视为已停止,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / 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) @@ -4038,13 +4039,31 @@ - 处理:从当前 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 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。 -- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态。 +- 生成器边界:`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) 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/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 9e7d27f4c..95936bc0b 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -283,6 +283,7 @@ 19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/`;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +19.3.56. `UnifiedModal` 默认在 `portal=true` 时读取 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 注入 portal overlay,从底层保证 `--platform-modal-fill`、`--platform-overlay-fill` 等 CSS 变量不因挂到 `document.body` 而丢失。新增 `portalTheme="auto|light|dark|none"`:默认 `auto` 优先保留调用方已给出的明确主题,否则使用当前主题并在无 Provider 时回退 light;纯黑底图片 / 素材预览使用 `none` 保持完全自绘;`portal=false` 不改变原 DOM 继承。`PlatformToolModalShell`、`PlatformDangerConfirmDialog`、认证与工具信息壳不再重复维护 portal 主题桥,但继续保留各自的 panel、间距、按钮和层级语义。编辑器裸 portal 菜单应复用 `ImageCanvasEditorPortal` 同时获得平台与画布变量;图片信息、修改图片和画布快捷键三个面板仍使用固定浅色 CSS,在完整支持暗色样式前显式传入 `portalTheme="light"`;已退役玩法目录不因本次收口重新进入维护范围。验证命令:`npm run test -- src/components/image-editor/UnifiedModalPortalTheme.test.tsx src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。 19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。 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 852c55885..a5037f384 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -259,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 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -346,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 暴露任意本机文件协议或绝对路径。 - 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview` 与 `read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。 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/【后端架构】外部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-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/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index ade82fdd1..165f831e2 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -65,6 +65,7 @@ - Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 +- 右上角删除当前对话的危险确认框继续使用 `PlatformDangerConfirmDialog`;其 portal 主题由 `UnifiedModal` 统一恢复,panel 使用 `platform-remap-surface`,且层级与附件选择弹窗一致,避免背景透明、错色或被画布控件遮挡。 - 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 - 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 - 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 @@ -78,7 +79,7 @@ - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); - 多选 + 底部「取消 / 应用」。 - 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 -- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme` 的 `UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。 +- 附件选择弹窗使用 `PlatformToolModalShell` 承接白底 panel 和标准间距;底层 `UnifiedModal` 会把当前 `platform-theme` 自动注入 portal overlay,保证 `--platform-modal-fill` 在 `document.body` 下仍有效。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 - 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-`、`_`、`.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。 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 450e72f36..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, @@ -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, @@ -5867,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( @@ -5923,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, 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/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/src/components/auth/PlatformAuthModalShell.tsx b/src/components/auth/PlatformAuthModalShell.tsx index e7ce21e21..c6c719a7e 100644 --- a/src/components/auth/PlatformAuthModalShell.tsx +++ b/src/components/auth/PlatformAuthModalShell.tsx @@ -57,9 +57,10 @@ export function PlatformAuthModalShell({ closeOnEscape={false} size={size} showHeader={showHeader} + portalTheme={platformTheme} zIndexClassName={zIndexClassName} overlayClassName={joinClassNames( - `platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`, + 'text-[var(--platform-text-strong)]', overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4', overlayClassName, )} diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index 052491da1..cf0fa8ab9 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -289,6 +289,7 @@ test('creative image input panel confirms before removing uploaded image', () => fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' })); const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '移除' })); expect(onMainImageRemove).toHaveBeenCalledTimes(1); diff --git a/src/components/common/LegalDocumentModal.tsx b/src/components/common/LegalDocumentModal.tsx index f0291cd60..d6c6e93e2 100644 --- a/src/components/common/LegalDocumentModal.tsx +++ b/src/components/common/LegalDocumentModal.tsx @@ -88,9 +88,7 @@ export function LegalDocumentModal({ size="md" closeLabel="关闭法律信息" zIndexClassName={zIndexClassName ?? 'z-[150]'} - overlayClassName={`platform-theme ${ - platformTheme ? `platform-theme--${platformTheme}` : '' - }`} + portalTheme={platformTheme ?? 'auto'} panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]" headerClassName="items-center" bodyClassName="px-4 py-0 sm:px-5" diff --git a/src/components/common/PlatformDangerConfirmDialog.test.tsx b/src/components/common/PlatformDangerConfirmDialog.test.tsx index 9db88d4c3..f52395f95 100644 --- a/src/components/common/PlatformDangerConfirmDialog.test.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.test.tsx @@ -24,6 +24,10 @@ test('renders a standard danger confirmation with cancel and confirm actions', ( const dialog = screen.getByRole('dialog', { name: '删除作品' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('z-[140]'); + expect(dialog.className).toContain('platform-remap-surface'); + expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]'); expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy(); expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy(); diff --git a/src/components/common/PlatformDangerConfirmDialog.tsx b/src/components/common/PlatformDangerConfirmDialog.tsx index 391bed0df..86e18ac29 100644 --- a/src/components/common/PlatformDangerConfirmDialog.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.tsx @@ -21,8 +21,13 @@ type PlatformDangerConfirmDialogProps = { panelClassName?: string; footerClassName?: string; confirmClassName?: string; + zIndexClassName?: string; }; +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(' '); +} + /** * 平台危险确认弹窗。 * 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。 @@ -46,6 +51,7 @@ export function PlatformDangerConfirmDialog({ panelClassName, footerClassName, confirmClassName, + zIndexClassName = 'z-[140]', }: PlatformDangerConfirmDialogProps) { return ( {children} diff --git a/src/components/common/PlatformImagePreviewModal.test.tsx b/src/components/common/PlatformImagePreviewModal.test.tsx index c09d35b2c..52a80d5cb 100644 --- a/src/components/common/PlatformImagePreviewModal.test.tsx +++ b/src/components/common/PlatformImagePreviewModal.test.tsx @@ -103,6 +103,7 @@ test('renders full-screen image preview with zoom controls and dark backdrop', ( expect( dialog.parentElement?.className.includes('!bg-black'), ).toBe(true); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy(); diff --git a/src/components/common/PlatformImagePreviewModal.tsx b/src/components/common/PlatformImagePreviewModal.tsx index 8b9c2cf57..ab29a5087 100644 --- a/src/components/common/PlatformImagePreviewModal.tsx +++ b/src/components/common/PlatformImagePreviewModal.tsx @@ -243,6 +243,8 @@ export function PlatformImagePreviewModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 全黑自绘查看器不需要平台变量;避免 light / dark remap 改写黑底、白字和暗色控件。 + portalTheme="none" zIndexClassName={zIndexClassName} overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none" panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none" diff --git a/src/components/common/PlatformReportDialog.test.tsx b/src/components/common/PlatformReportDialog.test.tsx index b6243ef07..993fa47cf 100644 --- a/src/components/common/PlatformReportDialog.test.tsx +++ b/src/components/common/PlatformReportDialog.test.tsx @@ -10,6 +10,10 @@ vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + afterEach(() => { vi.clearAllMocks(); }); @@ -31,6 +35,8 @@ test('renders report fields and copies the joined report lines', async () => { ); const dialog = screen.getByRole('dialog', { name: '统一报告' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain('platform-theme--dark'); expect(within(dialog).getByText('拼图草稿 puzzle-session-1')).toBeTruthy(); expect(within(dialog).getByText('已完成')).toBeTruthy(); diff --git a/src/components/common/PlatformReportDialog.tsx b/src/components/common/PlatformReportDialog.tsx index 8aaedf52f..92cc58bbc 100644 --- a/src/components/common/PlatformReportDialog.tsx +++ b/src/components/common/PlatformReportDialog.tsx @@ -54,6 +54,8 @@ export function PlatformReportDialog({ open={open} title={title} onClose={onClose} + // 报告中的二维码 / 扫码区固定使用浅色背景,保证对比度和识别率。 + platformTheme="light" overlayClassName={overlayClassName} panelClassName={panelClassName} bodyClassName="space-y-3" diff --git a/src/components/common/PlatformToolModalShell.tsx b/src/components/common/PlatformToolModalShell.tsx index ea069b80c..dbfb60907 100644 --- a/src/components/common/PlatformToolModalShell.tsx +++ b/src/components/common/PlatformToolModalShell.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; -import { useAuthUi } from '../auth/AuthUiContext'; import { UnifiedModal } from './UnifiedModal'; type PlatformToolModalShellProps = { @@ -50,9 +49,6 @@ export function PlatformToolModalShell({ bodyClassName, footerClassName, }: PlatformToolModalShellProps) { - const resolvedPlatformTheme = - useAuthUi()?.platformTheme ?? 'light'; - return ( ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + test('renders platform utility info modal shell with default platform styling', () => { render( ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + const payload: PublishShareModalPayload = { title: '暖灯猫街', publicWorkCode: 'PZ-00000001', @@ -119,7 +123,7 @@ describe('PublishShareModal', () => { const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); expect(dialog.parentElement?.className).toContain('!items-center'); - expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('platform-theme--dark'); expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('rounded-[1.75rem]'); expect(dialog.getAttribute('style')).toBeNull(); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index 16e193aa6..eac1385f4 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -10,7 +10,6 @@ import { openHostShare, openHostShareGrid, } from '../../services/host-bridge/hostBridge'; -import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; import { downloadPublishShareCardImage } from './publishShareCardImage'; @@ -77,7 +76,6 @@ export function PublishShareModal({ payload, onClose, }: PublishShareModalProps) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [copyState, setCopyState] = useState('idle'); const [downloadState, setDownloadState] = useState('idle'); const [gridState, setGridState] = useState('idle'); @@ -233,7 +231,6 @@ export function PublishShareModal({ open={open && Boolean(payload)} title="分享给朋友" onClose={onClose} - platformTheme={platformTheme} panelClassName="rounded-[1.75rem]" footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5" footer={ diff --git a/src/components/common/UnifiedConfirmDialog.tsx b/src/components/common/UnifiedConfirmDialog.tsx index 8bc3fb4b0..a6f59d718 100644 --- a/src/components/common/UnifiedConfirmDialog.tsx +++ b/src/components/common/UnifiedConfirmDialog.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; -import { UnifiedModal } from './UnifiedModal'; +import { UnifiedModal, type UnifiedModalPortalTheme } from './UnifiedModal'; type UnifiedConfirmDialogTone = 'primary' | 'danger'; @@ -24,6 +24,7 @@ type UnifiedConfirmDialogProps = { closeOnBackdrop?: boolean; showCloseButton?: boolean; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; size?: 'sm' | 'md'; overlayClassName?: string; panelClassName?: string; @@ -54,6 +55,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop = true, showCloseButton = true, portal = true, + portalTheme = 'auto', size = 'sm', overlayClassName, panelClassName, @@ -74,6 +76,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop={closeOnBackdrop && !busy} showCloseButton={showCloseButton} portal={portal} + portalTheme={portalTheme} size={size} overlayClassName={overlayClassName} panelClassName={panelClassName} diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index fc789b4f0..24f0a56e2 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -8,6 +8,8 @@ import { } from 'react'; import { createPortal } from 'react-dom'; +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; +import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; @@ -18,6 +20,8 @@ type UnifiedModalCloseIcon = ComponentProps< typeof PlatformModalCloseButton >['icon']; +export type UnifiedModalPortalTheme = PlatformTheme | 'auto' | 'none'; + type UnifiedModalProps = { open: boolean; title: string; @@ -37,6 +41,7 @@ type UnifiedModalProps = { closeVariant?: UnifiedModalCloseVariant; closeIcon?: UnifiedModalCloseIcon; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; zIndexClassName?: string; overlayClassName?: string; overlayStyle?: CSSProperties; @@ -63,6 +68,44 @@ function joinClassNames( return classNames.filter(Boolean).join(' '); } +function resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, +}: { + overlayClassName?: string; + portalTheme: UnifiedModalPortalTheme; + contextualTheme: PlatformTheme; +}) { + if (portalTheme === 'none') { + return overlayClassName; + } + + const classNames = overlayClassName?.split(/\s+/u).filter(Boolean) ?? []; + const explicitTheme = classNames.find( + (className): className is `platform-theme--${PlatformTheme}` => + className === 'platform-theme--light' || + className === 'platform-theme--dark', + ); + const resolvedTheme = + portalTheme === 'auto' + ? ((explicitTheme?.replace('platform-theme--', '') as + PlatformTheme | undefined) ?? contextualTheme) + : portalTheme; + const remainingClassNames = classNames.filter( + (className) => + className !== 'platform-theme' && + className !== 'platform-theme--light' && + className !== 'platform-theme--dark', + ); + + return joinClassNames( + 'platform-theme', + `platform-theme--${resolvedTheme}`, + ...remainingClassNames, + ); +} + function UnifiedModalContent({ open, title, @@ -91,7 +134,7 @@ function UnifiedModalContent({ bodyClassName, footerClassName, panelStyle, -}: Omit) { +}: Omit) { const generatedTitleId = useId(); const descriptionId = useId(); const titleId = titleIdProp ?? generatedTitleId; @@ -231,10 +274,30 @@ function UnifiedModalContent({ * 统一模态窗口外壳。 * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。 */ -export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) { +export function UnifiedModal({ + portal = true, + portalTheme = 'auto', + overlayClassName, + ...props +}: UnifiedModalProps) { + const contextualTheme = useAuthUi()?.platformTheme ?? 'light'; + const resolvedProps = { + ...props, + overlayClassName: portal + ? resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, + }) + : overlayClassName, + }; + if (!portal || typeof document === 'undefined') { - return ; + return ; } - return createPortal(, document.body); + return createPortal( + , + document.body, + ); } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index a85a5d614..d80b00858 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -569,6 +569,10 @@ describe('CreationLandingView', () => { ); const dialog = await screen.findByRole('dialog', { name: '角色英雄' }); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); + expect(dialog.parentElement?.className).toContain( + 'creation-landing__showcase-modal-overlay', + ); expect(within(dialog).getByText('赞')).toBeTruthy(); expect(within(dialog).getByText('3')).toBeTruthy(); expect(within(dialog).queryByRole('button', { name: /点赞/ })).toBeNull(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index b3496951f..1455f8c35 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -409,6 +409,8 @@ function CreationShowcaseModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。 + portalTheme="none" zIndexClassName="z-[120]" overlayClassName="creation-landing__showcase-modal-overlay" panelClassName="creation-landing__showcase-modal-panel" @@ -1174,7 +1176,8 @@ export function CreationLandingView({ showHeader={false} showCloseButton={false} size="sm" - overlayClassName="platform-theme platform-theme--light platform-mobile-home-welcome-overlay !items-center !p-4" + portalTheme="light" + overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4" panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog" bodyClassName="platform-mobile-home-welcome-dialog__body" footerClassName="platform-mobile-home-welcome-dialog__footer" diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 03eb280bf..2bc4f979c 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -338,9 +338,7 @@ describe('EditorAgentConversationPanelView', () => { expect( screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'), ).toBeTruthy(); - expect( - screen.getByRole('option', { name: '角色参考' }), - ).toBeTruthy(); + expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { @@ -530,9 +528,9 @@ describe('EditorAgentConversationPanelView', () => { (screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value, ).toBe('需要保留的草稿'); expect(screen.getByText('已经看到画布内容')).toBeTruthy(); - expect( - (screen.getByLabelText('当前对话') as HTMLSelectElement).value, - ).toBe('conversation-1'); + expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe( + 'conversation-1', + ); }); it('preserves newer draft edits while creating a conversation', async () => { @@ -1989,6 +1987,14 @@ describe('EditorAgentConversationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); + expect(confirmDialog.parentElement?.className).toContain( + 'platform-theme--light', + ); + expect(confirmDialog.parentElement?.className).toContain('z-[140]'); + expect(confirmDialog.className).toContain('platform-remap-surface'); + expect( + within(confirmDialog).getByText('确认删除这个对话吗?'), + ).toBeTruthy(); fireEvent.click( within(confirmDialog).getByRole('button', { name: '确认删除' }), ); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 4b553436f..43416aeea 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -391,7 +391,9 @@ export function EditorAgentConversationPanelView({ setDeleteConfirmOpen(false), ); }} - /> + > + 确认删除这个对话吗? + ); } diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 2702e41fb..b73105242 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -118,9 +118,11 @@ describe('MessageBubble', () => { clientX: 30, clientY: 40, }); + const menu = screen.getByRole('menu', { name: '消息右键菜单' }); + expect(menu.style.zIndex).toBe('60'); expect( - screen.getByRole('menu', { name: '消息右键菜单' }).style.zIndex, - ).toBe('60'); + menu.closest('.image-canvas-editor__portal-theme')?.className, + ).toContain('platform-theme--light'); fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' })); await waitFor(() => diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 39fe51442..4147147d7 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -1,8 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; +import { ImageCanvasEditorPortal } from '../ImageCanvasEditorPortal.tsx'; import { contextAssetMediaSrc, type EditorAgentContextAsset, @@ -158,110 +158,115 @@ export function MessageBubbleRightClickMenu({ }; }, [onClose]); - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
event.preventDefault()} - > - {target.kind === 'text' ? ( - - ) : ( - <> - {target.asset.kind === 'generated_media' && - target.asset.resourceId?.trim() ? ( - - ) : null} - {target.asset.mediaType === 'image' ? ( - <> - {contextAssetMediaSrc(target.asset).trim() ? ( - - ) : null} - - - ) : null} + return ( + +
event.preventDefault()} + > + {target.kind === 'text' ? ( - - )} -
, - document.body, + ) : ( + <> + {target.asset.kind === 'generated_media' && + target.asset.resourceId?.trim() ? ( + + ) : null} + {target.asset.mediaType === 'image' ? ( + <> + {contextAssetMediaSrc(target.asset).trim() ? ( + + ) : null} + + + ) : null} + + + )} +
+ ); } diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx index 381706120..082a6c920 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -1,12 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { useState } from 'react'; +import { type ComponentProps, type ReactNode, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createDialog( patch: Partial = {}, ): GenerateDialogState { @@ -48,8 +61,17 @@ function EditGenerationModalHarness({ describe('ImageCanvasEditGenerationModalView', () => { it('updates prompt and submits edit generation', () => { const submitEdit = vi.fn(); - render(); + render( + withDarkAuthUi(), + ); + const modal = screen + .getAllByRole('dialog', { name: '修改图片' }) + .find((element) => element.classList.contains('platform-modal-shell')); + expect(modal?.parentElement?.className).toContain('platform-theme--light'); + expect(modal?.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的修改提示' }, }); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 7ebb37464..114f31175 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -3,9 +3,7 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; -import { - calculateEditorImageGenerationPrice, -} from './ImageCanvasGenerationModel'; +import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; @@ -27,11 +25,13 @@ export function ImageCanvasEditGenerationModalView({ imageSize: dialogImageSize, }); + // TODO: Remove this override after full dark style support. return ( setGenerateDialog(null)} 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/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index e47cddfde..283468d8f 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -1,11 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; +import type { ComponentProps, ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', @@ -27,37 +41,45 @@ function createLayer(overrides: Partial = {}): CanvasLayer { describe('ImageCanvasMetadataModalView', () => { it('renders generated layer metadata with generation inputs and references', () => { render( - , + withDarkAuthUi( + , + ), ); const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).queryByText('生成主图')).toBeNull(); expect(within(dialog).getByText('生成图片')).toBeTruthy(); expect(within(dialog).getByText('生成提示词')).toBeTruthy(); expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy(); expect(within(dialog).getByText('参考图')).toBeTruthy(); expect(within(dialog).getByText('角色立绘')).toBeTruthy(); - expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy(); + expect( + within(dialog).getByText('项目资源 · resource-reference'), + ).toBeTruthy(); expect(within(dialog).getByText('Model')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); @@ -157,7 +179,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('上传图片')).toBeTruthy(); expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭图片信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -193,7 +217,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('kling3.0-omni')).toBeTruthy(); expect(within(dialog).getByText('1280 x 720 px')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭视频信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭视频信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 3b5a1768c..d0fb16e55 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -40,13 +40,15 @@ export function ImageCanvasMetadataModalView({ ? '视频' : layer?.mediaType === 'image-sequence' ? '序列帧' - : '图片'; + : '图片'; + // TODO: Remove this override after full dark style support. return (
Model
-
{getEditorLayerModelDisplayName(layer.model)}
+
+ {getEditorLayerModelDisplayName(layer.model)} +
{layer.mediaType === 'audio' ? ( <>
时长
-
- {formatLayerDurationForDisplay(layer)} -
+
{formatLayerDurationForDisplay(layer)}
) : ( <> diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx index 53dd3356e..5b12684ff 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx @@ -1,16 +1,30 @@ /* @vitest-environment jsdom */ import { render, screen, within } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + describe('ImageCanvasShortcutDialogView', () => { it('renders the Windows canvas shortcut table', () => { - render(); + render( + + + , + ); const dialog = screen.getByRole('dialog', { name: '画布快捷键' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).getByRole('heading', { name: '编辑' })).toBeTruthy(); expect(within(dialog).getByText('全选画布元素')).toBeTruthy(); expect(within(dialog).getAllByText('Ctrl').length).toBeGreaterThan(0); diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx index 265d88def..f8f56340e 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx @@ -10,11 +10,13 @@ export function ImageCanvasShortcutDialogView({ open, onClose, }: ImageCanvasShortcutDialogViewProps) { + // TODO: Remove this override after full dark style support. return ( ['value']; + +afterEach(() => { + cleanup(); +}); + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + +function getOverlay() { + return screen.getByRole('dialog', { name: '主题弹窗' }) + .parentElement as HTMLElement; +} + +test('restores the contextual platform theme on the portal overlay', () => { + render( + withDarkAuthUi( + {}}> + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme'); + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().parentElement).toBe(document.body); +}); + +test('falls back to light when no AuthUi provider is available', () => { + render( + {}}> + 弹窗内容 + , + ); + + expect(getOverlay().className).toContain('platform-theme--light'); +}); + +test('respects an existing explicit theme and supports a forced override', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--light'); + expect(getOverlay().className).not.toContain('platform-theme--dark'); + expect(getOverlay().className).toContain('custom-overlay'); + + rerender( + withDarkAuthUi( + {}} + portalTheme="dark" + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().className).not.toContain('platform-theme--light'); +}); + +test('keeps custom and inline modals outside automatic portal theming', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + portalTheme="none" + overlayClassName="custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('custom-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); + + rerender( + withDarkAuthUi( + {}} + portal={false} + overlayClassName="inline-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('inline-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); +}); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 75d7a4036..3dae18b95 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -108,6 +108,27 @@ function createCompletedEditorProjectSnapshot( }, 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', @@ -121,27 +142,6 @@ function createCompletedEditorProjectSnapshot( zIndex: 1, sourceType: 'generated', }, - { - 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: true, - generatedLayerId: 'layer-generated', - placeholder: { - x: 42, - y: 56, - width: 420, - height: 420, - originalWidth: 420, - originalHeight: 420, - }, - }, - }, ], resources: [ { @@ -314,7 +314,7 @@ it('merges pending geometry and dialog edits while retaining backend additions a dialog: expect.objectContaining({ prompt: '请求在途期间的新提示词', status: 'idle', - composerOpen: true, + composerOpen: false, generatedLayerId: 'layer-generated-by-backend', placeholder: expect.objectContaining({ x: 88, y: 99 }), }), @@ -359,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()); @@ -371,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; @@ -391,6 +397,8 @@ function ProjectPersistenceHarness({ viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, + selectedLayerIdRef: selectedLayerRef, + selectedLayerIdsRef, }), [], ); @@ -400,6 +408,8 @@ function ProjectPersistenceHarness({ setProjectRenameValue, setViewport, setLayers, + setSelectedLayerId, + setSelectedLayerIds, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs: setGenerationDialogs, @@ -439,6 +449,9 @@ function ProjectPersistenceHarness({ .join(',')} {selectedLayerRef.current ?? '-'} + + {selectedLayerIdsRef.current.join(',') || '-'} + {layerCounterRef.current} {viewport.x},{viewport.y},{viewport.scale} @@ -651,6 +664,28 @@ function ProjectPersistenceHarness({ > apply completed project + +