diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 07a5f736f..876d7d078 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -1,13 +1,15 @@ --- 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 external editor/canvas integration through its hosted remote MCP or asynchronous `/api/external/v1` OpenAPI. Use when a user needs to discover the hosted MCP or complete Skill package, infer the right canvas or asset operation, submit and poll image/video/audio generation safely, prepare canvas and asset-library context, upload references, draft HTTP/SDK requests, 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. +Use the live integration manifest as the discovery entry: `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. It advertises the hosted Streamable HTTP MCP endpoint, OpenAPI document, raw Skill entry, complete Skill archive and archive SHA-256. Use the live OpenAPI contract as the field-level 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`. -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 MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. The MCP exposes the OpenAPI operations as tools plus `usage`, `openapi`, and `skill` resources; it is hosted by Genarrative and does not require installing a local MCP server. Use this Skill package when the Agent does not support remote MCP or when local-file upload orchestration is required. + +Prefer the bundled Python helper for runnable REST examples. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload, asynchronous submission, polling, and result retrieval. ## Workflow @@ -25,11 +27,12 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext - 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. +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 post-completion fallback to create a library asset from the first returned frame when the generated result has no direct `asset`. 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. +8. Treat every generation POST as asynchronous. Send a stable `Idempotency-Key`, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`. Consume `result` only after `status=completed`; surface the safe error after `failed`. A timeout or lost response is not permission to submit again with a new key. +9. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or automatic submit-and-poll behavior. +10. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs. Never call internal worker, queue, or SpacetimeDB MCP endpoints. ## Core Routes @@ -47,6 +50,19 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext | 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` | +| Query generation | `GET /api/external/v1/generations/{operationId}` | `operationId` returned by the submission | + +All eight generation POST routes require `Idempotency-Key` and return HTTP `202`, not a completed media response. + +## Hosted Integration Discovery + +- Integration manifest: `GET /api/external/v1/agent-integration.json`. +- Hosted remote MCP: `POST /api/external/v1/mcp`, Streamable HTTP with the 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 Skill archive contains `SKILL.md`, `references/api-selection.md`, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing it. The discovery, OpenAPI, and Skill download routes are public; MCP and business API calls require the API Key. ## Art Spec Interface @@ -75,7 +91,7 @@ The external OpenAPI uses: Authorization: Bearer ``` -The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key. +The OpenAPI, integration manifest, and Skill download endpoints are public. The hosted MCP and all project, asset, upload, generation, and generation-query operations require the Bearer API Key. Use this fixed production base URL: @@ -113,6 +129,29 @@ Python smoke without printing the key: python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects ``` +## Asynchronous Generation Contract + +Generate one stable idempotency key per logical generation. Reuse it for transport retries of the same request body. A successful submission returns HTTP `202` with: + +```json +{ + "operationId": "task-...", + "kind": "editor_image_generation", + "status": "queued", + "statusUrl": "/api/external/v1/generations/task-...", + "pollAfterMs": 1500, + "updatedAtMicros": 1785456000000000 +} +``` + +Poll until a terminal status: + +- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress`; wait at least `pollAfterMs`. +- `completed`: consume the compact stable `result`, then reload the project/library snapshot if the caller needs the complete authoritative state. +- `failed`: surface the returned safe `error`; do not infer provider internals. + +The compact result may contain `objectKey`, `resourceId`, `assetId`, dimensions, media type, warnings, and other stable artifact references. It deliberately excludes a complete project/canvas snapshot, Data URL, Blob URL, expiring signed URL, worker lease data, and internal provider diagnostics. Use `/assets/read-url` when a stable `objectKey` needs a temporary download URL. + ## 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`. @@ -144,6 +183,8 @@ client.generate_image( ) ``` +The helper method blocks only in the local client while it submits and polls; the server request itself is asynchronous. For explicit control, call `client.submit_generation(...)`, persist the returned `operationId`, then call `client.get_generation(...)` or `client.wait_for_generation(...)`. + For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation: ```python @@ -209,7 +250,19 @@ Generate an image and save it into both the canvas and the asset-library folder: } ``` -Then call `POST /api/external/v1/editor/images/generations`. +Save the JSON above as `request.json`, then submit and poll with a stable key: + +```bash +idempotency_key="$(node -e 'process.stdout.write(require("node:crypto").randomUUID())')" +submission="$(curl -fsS "$api/api/external/v1/editor/images/generations" \ + "${auth[@]}" "${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")" +curl -fsS "$api/api/external/v1/generations/$operation_id" "${auth[@]}" +``` + +Continue polling according to `pollAfterMs`. If the submit response is lost, retry the same body with the same `Idempotency-Key`; never generate a replacement key merely because the outcome is unknown. 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. @@ -324,9 +377,9 @@ For character animation from an uploaded local image, set: 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. +The helper uses short HTTP requests for submission/status reads and an overall 1800-second local polling budget. Direct clients should use their own bounded polling budget without keeping the generation POST connection open. -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. +Character animation can complete without a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; after polling reaches `completed`, the helper creates a library asset from the first returned frame when needed. 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. @@ -334,12 +387,12 @@ For image edit/redraw that should replace an existing canvas layer, pass `projec 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 +## Completed Results 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. +Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can complete with warnings. The initial HTTP `202` means only that the task was accepted. Interpret warnings only after the query reaches `status=completed`: the query-level `warning` is display-ready text, while the compact `result.warning` / `result.sliceWarning` retain structured artifact semantics when present. -- 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. +- Apply the compact media references and then reload the authoritative project/library snapshots before interpreting optional derivatives. Character results use `resource` / `asset`, while icon spritesheet and UI extraction use `spritesheetResource` / `spritesheetAsset`. When `result.warning.code` is `postprocess-failed-source-preserved`, the saved source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction have neither a transparent spritesheet nor slices. Display the reason and do not synthesize missing derivatives or restart generation. +- `result.sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Continue using the complete transparent spritesheet and do not claim missing slices. - `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. @@ -356,13 +409,14 @@ Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec refere ## Guardrails -- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. +- Do not invent endpoints outside the OpenAPI, especially internal worker, runtime task-list, SpacetimeDB, or queue routes. The only external generation query is `/api/external/v1/generations/{operationId}`. - 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. +- Never treat HTTP `202` as a generated artifact. Preserve `operationId` until a terminal query result, and do not automatically replay an unknown-outcome request with a new idempotency key. +- A completed generation result is compact. Reload `project`, `resource`, or `asset` through their normal read endpoints when a complete authoritative snapshot is needed. ## 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. +- `scripts/genarrative_external_api.py`: stdlib Python helper for API Key loading, local reference upload, object confirm, project/canvas calls, asynchronous generation submission, polling, and result retrieval. 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-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index c3f18feb8..708f8f43e 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -5,10 +5,14 @@ Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`. ## Base - Fixed base URL: `https://www.genarrative.world/`. +- Integration discovery: `GET /api/external/v1/agent-integration.json` returns the hosted MCP URL, OpenAPI URL, raw Skill entry, complete Skill archive, archive file list, and archive SHA-256. +- Hosted MCP: `/api/external/v1/mcp`, Streamable HTTP, authenticated with the same Bearer API Key. It exposes the REST operations as tools and the usage/OpenAPI/Skill documents as resources. - Public contract: `GET /api/external/v1/openapi.json`. +- Public Skill fallback: `GET /api/external/v1/skill/SKILL.md` or the complete `GET /api/external/v1/skill.zip` package. - 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. +- All eight generation POST routes are asynchronous, require `Idempotency-Key`, and return HTTP `202`. Retain `operationId` and poll `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`; do not hold the submit connection open. +- Prefer hosted MCP for Agents that support remote Streamable HTTP plus custom Bearer tokens. Use the complete Skill package and helper when remote MCP is unavailable or local-file upload needs client-side orchestration. ## Canvas Session and Art Spec @@ -56,6 +60,8 @@ Ask a follow-up only when two routes could both be correct and produce different | User intent | Endpoint | Minimum request | | --- | --- | --- | | Read contract | `GET /api/external/v1/openapi.json` | No auth required | +| Read integration manifest | `GET /api/external/v1/agent-integration.json` | No auth required | +| Download complete Skill | `GET /api/external/v1/skill.zip` | No auth required; verify `archiveSha256` from the manifest | | 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 | @@ -70,9 +76,12 @@ Ask a follow-up only when two routes could both be correct and produce different | 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` | +| Query generation | `GET /api/external/v1/generations/{operationId}` | API Key and returned `operationId` | ## Generation Endpoints +Every row below requires a stable `Idempotency-Key` header and returns an `ExternalEditorGenerationSubmissionResponse`, not a media result. The request fields shown are body fields. + | 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` | @@ -84,6 +93,19 @@ Ask a follow-up only when two routes could both be correct and produce different | 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` | +## Submission and Polling + +1. Generate one printable ASCII `Idempotency-Key` of 1-128 bytes for the logical request. +2. Submit the generation body. Persist `operationId`, `statusUrl`, and the idempotency key before doing more work. +3. On a lost or uncertain submission response, resend the exact body with the same key. Do not allocate a replacement key. +4. Poll `statusUrl` no faster than `pollAfterMs`: + - `queued` / `running`: display `phaseLabel`, `phaseDetail`, and `progress`, then continue polling. + - `completed`: consume `result`; if full canvas/library state is needed, reload the normal project or library read endpoint. + - `failed`: display the safe `error` and stop polling. +5. A client-side polling timeout leaves the operation pending. Keep `operationId` for later queries; it does not authorize a new generation request. + +`result` contains compact stable artifact references such as `objectKey`, resource/asset IDs, dimensions, media type, task ID, and warning data. It does not contain a complete project/canvas snapshot, Data URL, Blob URL, expiring signed URL, worker lease, or internal queue/provider diagnostics. Resolve preview/download access from a stable `objectKey` through `/assets/read-url`. + ## 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. @@ -113,14 +135,14 @@ Icon spritesheet generation with pixel-art snapping: } ``` -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"`. +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. Character animation may complete without an `asset`; after `status=completed`, create an asset from the first returned frame only when the compact result still lacks one. The bundled helper performs this fallback. -## HTTP 2xx Warning Handling +## Completed 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. +The initial HTTP `202` only acknowledges durable submission. Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may later reach `completed` with warning data; completion does not imply that all requested post-processed derivatives exist. The query-level `warning` is display-ready text, while compact `result.warning` / `result.sliceWarning` preserve structured artifact semantics when present. -- 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. +- Use compact references to reload the authoritative project/library state. Character results use `resource` / `asset`, while icon spritesheet and UI extraction use `spritesheetResource` / `spritesheetAsset`. `result.warning.code: "postprocess-failed-source-preserved"` means the saved 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 the reason; do not construct missing assets or retry generation from scratch. +- `result.sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet; do not claim individual slices. - `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 @@ -225,7 +247,7 @@ Required: } ``` -`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally. +`dialogId` is optional. A completed external result is compact; use returned resource/asset IDs and stable object keys, then reload the project or library endpoint instead of reconstructing canvas/resource/library state locally. ## Upload Flow 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/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/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index d2dc1f727..74d514b2e 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,11 @@ use super::*; +const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); +const EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS: u64 = 2_000; +const EXTERNAL_GENERATION_MAX_POLL_AFTER_MS: u64 = 5_000; +const EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS: usize = 3; +const EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS: u64 = 250; + pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { read_manifest_for_project(root) .map(|manifest| { @@ -270,6 +276,132 @@ 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) + .min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS) +} + +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!( + "平台图片生成任务仍在执行,已停止本地等待;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!("{error};operationId={operation_id}")), + }; + let generation = external_editor_response_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("completed") => { + return generation + .get("result") + .filter(|result| !result.is_null()) + .cloned() + .ok_or_else(|| { + format!( + "平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" + ) + }); + } + 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!( + "平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )); + } + None => { + return Err(format!( + "平台图片生成任务状态响应缺少 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: &serde_json::Value, +) -> Result { + let mut last_error = None; + for attempt in 1..=EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS { + match client + .post(format!("{api_base_url}{endpoint}")) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .json(request_body) + .send() + .await + { + Ok(response) => return Ok(response), + Err(error) => { + last_error = Some(error); + if attempt < EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS * attempt as u64, + )) + .await; + } + } + } + } + Err(format!( + "请求平台图片生成失败:{}", + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "未知传输错误".to_string()) + )) +} + async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, @@ -519,7 +651,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( 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 client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?; 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); @@ -582,22 +717,28 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( }), ) }; - let response = client - .post(format!("{api_base_url}{endpoint}")) - .bearer_auth(&api_key) - .json(&request_body) - .send() - .await - .map_err(|error| format!("请求平台图片生成失败:{error}"))?; + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let response = submit_external_generation_request( + &client, + &api_base_url, + endpoint, + &api_key, + &idempotency_key, + &request_body, + ) + .await?; let status = response.status(); if !status.is_success() { return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); } - let payload = response + let submission_payload = response .json::() .await - .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; - let generated = payload.get("data").unwrap_or(&payload); + .map_err(|error| format!("解析平台图片生成提交响应失败:{error}"))?; + let generated = + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission_payload) + .await?; + let generated = &generated; if let Some(error) = platform_art_generation_postprocess_failure(generated) { return Err(error); } @@ -643,7 +784,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(generation_prompt.clone())); let model = json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); let provider = json_string_field(generated, "provider") @@ -1023,6 +1165,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 +1219,76 @@ mod canvas_generation_tests { } } + #[tokio::test] + async fn generation_submit_transport_retry_reuses_body_and_idempotency_key() { + 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(); + std::thread::spawn(move || { + for attempt in 0..2 { + let (mut stream, _) = listener.accept().expect("accept submit request"); + let request = read_test_http_request(&mut stream); + sender.send(request).expect("capture submit request"); + if attempt == 0 { + continue; + } + let body = serde_json::json!({ + "data": { + "operationId": "task-retry-1", + "status": "queued", + "pollAfterMs": 1 + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 202 Accepted\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 response"); + } + }); + + 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 response = submit_external_generation_request( + &client, + &base_url, + "/generation", + "test-key", + &idempotency_key, + &body, + ) + .await + .expect("transport retry should succeed"); + assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED); + + let first = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first request"); + let second = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("retried request"); + assert_eq!( + test_request_header(&first, "idempotency-key"), + &idempotency_key + ); + assert_eq!( + test_request_header(&second, "idempotency-key"), + &idempotency_key + ); + assert_eq!( + first.split_once("\r\n\r\n").map(|(_, body)| body), + second.split_once("\r\n\r\n").map(|(_, body)| body), + ); + } + #[test] fn canonical_art_spritesheet_requires_real_transparent_pixels() { assert!(platform_art_spritesheet_has_transparent_pixels( 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..e85374e95 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 @@ -2651,6 +2651,17 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let generation_accepted_body = serde_json::json!({ + "data": { + "operationId": "task-external-fixture-1", + "kind": "editor_image_generation", + "status": "queued", + "statusUrl": "/api/external/v1/generations/task-external-fixture-1", + "pollAfterMs": 1, + "updatedAtMicros": 1 + } + }) + .to_string(); let read_body = serde_json::json!({ "read": { "provider": "aliyun-oss", @@ -2677,6 +2688,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( .to_string(); std::thread::spawn(move || { let mut generation_response_gate = generation_response_gate; + let mut pending_generation_result: Option = None; + let mut generation_poll_index = 0_u8; for _ in 0..expected_requests { let (mut stream, _) = listener.accept().expect("mock canvas api accept"); let mut request_buffer = [0_u8; 8192]; @@ -2686,61 +2699,119 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( let _ = sender.send(request.to_string()); } let normalized_request = request.to_ascii_lowercase(); - let (content_type, body) = if request + let (status, content_type, body) = if request .starts_with("GET /api/external/v1/editor/projects ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", projects_body.as_bytes().to_vec()) + ("200 OK", "application/json", projects_body.as_bytes().to_vec()) } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", library_body.as_bytes().to_vec()) + ("200 OK", "application/json", library_body.as_bytes().to_vec()) } else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ") { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", project_body.as_bytes().to_vec()) + ("200 OK", "application/json", project_body.as_bytes().to_vec()) } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { assert!(normalized_request.contains("authorization: bearer ")); - if let Some(gate) = generation_response_gate.take() { - gate.recv_timeout(Duration::from_secs(5)) - .expect("release mock canvas generation response"); - } - ("application/json", generation_body.as_bytes().to_vec()) + let idempotency_key = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("idempotency-key") + .then_some(value.trim()) + }) + .expect("generation request idempotency key"); + uuid::Uuid::parse_str(idempotency_key.trim()) + .expect("generation idempotency key must be UUID"); + pending_generation_result = Some(generation_body.clone()); + generation_poll_index = 0; + ( + "202 Accepted", + "application/json", + generation_accepted_body.as_bytes().to_vec(), + ) } else if request .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { + assert!(normalized_request.contains("authorization: bearer ")); + let idempotency_key = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("idempotency-key") + .then_some(value.trim()) + }) + .expect("generation request idempotency key"); + uuid::Uuid::parse_str(idempotency_key.trim()) + .expect("generation idempotency key must be UUID"); + pending_generation_result = Some(icon_spritesheet_body.clone()); + generation_poll_index = 0; + ( + "202 Accepted", + "application/json", + generation_accepted_body.as_bytes().to_vec(), + ) + } else if request.starts_with( + "GET /api/external/v1/generations/task-external-fixture-1 ", + ) { assert!(normalized_request.contains("authorization: bearer ")); if let Some(gate) = generation_response_gate.take() { gate.recv_timeout(Duration::from_secs(5)) .expect("release mock canvas generation response"); } + let status = match generation_poll_index { + 0 => "queued", + 1 => "running", + _ => "completed", + }; + generation_poll_index = generation_poll_index.saturating_add(1); + let result = (status == "completed").then(|| { + serde_json::from_str::( + pending_generation_result + .as_deref() + .expect("generation query follows one submission"), + ) + .expect("fixture generation result JSON") + }); ( + "200 OK", "application/json", - icon_spritesheet_body.as_bytes().to_vec(), + serde_json::json!({ + "data": { + "operationId": "task-external-fixture-1", + "kind": "editor_image_generation", + "status": status, + "phaseLabel": "图片画布生成图片", + "phaseDetail": if status == "completed" { "生成已完成。" } else { "正在生成。" }, + "progress": if status == "completed" { 100 } else { 35 }, + "result": result, + "pollAfterMs": 1, + "updatedAtMicros": 2 + } + }) + .to_string() + .into_bytes(), ) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); - ("application/json", read_body.as_bytes().to_vec()) + ("200 OK", "application/json", read_body.as_bytes().to_vec()) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ( + "200 OK", "application/json", spritesheet_read_body.as_bytes().to_vec(), ) } else if request.starts_with("GET /signed/hero.png ") { - ("image/png", valid_test_png_bytes()) + ("200 OK", "image/png", valid_test_png_bytes()) } else if request.starts_with("GET /signed/spritesheet.png ") { - ("image/png", transparent_test_png_bytes()) + ("200 OK", "image/png", transparent_test_png_bytes()) } else { - ("text/plain", b"not found".to_vec()) - }; - let status = if content_type == "text/plain" { - "404 Not Found" - } else { - "200 OK" + ("404 Not Found", "text/plain", b"not found".to_vec()) }; let response = format!( "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -2762,7 +2833,7 @@ fn spawn_mock_external_canvas_api_server() -> String { fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { - spawn_mock_external_canvas_api_server_with_capture(5, request_sender) + spawn_mock_external_canvas_api_server_with_capture(8, request_sender) } fn spawn_mock_external_canvas_generation_api_server_with_gate( @@ -2770,7 +2841,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate( generation_response_gate: mpsc::Receiver<()>, ) -> String { spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( - 5, + 8, Some(request_sender), Some(generation_response_gate), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 550350adc..849eee264 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 @@ -780,7 +780,7 @@ 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) + let canvas_requests = (0..8) .map(|_| { canvas_receiver .recv_timeout(Duration::from_secs(2)) @@ -793,6 +793,22 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") }) .expect("canvas generation request"); + assert_eq!( + canvas_requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/")) + .count(), + 1, + "queued/running polling must not submit generation again" + ); + assert_eq!( + canvas_requests + .iter() + .filter(|request| request.starts_with("GET /api/external/v1/generations/")) + .count(), + 3, + "fixture should exercise queued, running, and completed states" + ); for expected in [ r#""referenceImageSrc":"resource-icon-spec""#, r#""iconDescriptions":"#, @@ -839,7 +855,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/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..c5419d682 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,121 @@ } } }, + "/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。", + "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": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/external/v1/assets/direct-upload-tickets": { "post": { "tags": [ @@ -881,6 +1000,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -892,12 +1016,12 @@ } }, "responses": { - "200": { - "description": "生成结果与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorImageGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -930,6 +1054,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -941,12 +1070,12 @@ } }, "responses": { - "200": { - "description": "重绘结果与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorImageGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -978,6 +1107,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -989,12 +1123,12 @@ } }, "responses": { - "200": { - "description": "图标 spritesheet、实际切片结果、可选非阻断告警与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1026,6 +1160,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1037,12 +1176,12 @@ } }, "responses": { - "200": { - "description": "UI 设计图素材 spritesheet、实际切片结果、可选非阻断告警与落库资源", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorIconSpritesheetGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1074,6 +1213,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1085,12 +1229,12 @@ } }, "responses": { - "200": { - "description": "角色动画视频预览与抽帧结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorCharacterAnimationGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1122,6 +1266,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1133,12 +1282,12 @@ } }, "responses": { - "200": { - "description": "视频生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorVideoGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1170,6 +1319,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1181,12 +1335,12 @@ } }, "responses": { - "200": { - "description": "音效生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorAudioGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1218,6 +1372,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], "requestBody": { "required": true, "content": { @@ -1229,12 +1388,12 @@ } }, "responses": { - "200": { - "description": "背景音乐生成结果", + "202": { + "description": "生成任务已持久化入队", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EditorAudioGenerationResponse" + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" } } } @@ -1253,6 +1412,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 +1495,18 @@ "schema": { "type": "string" } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "本次逻辑生成请求的稳定幂等键;网络结果不确定时必须复用原值,不得换键重提。", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!-~]+$" + } } }, "responses": { @@ -4037,6 +4257,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 d4a959f06..791b98e17 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5886,3 +5886,12 @@ - 决策:预览的业务失败与浏览器基础设施失败分流。基础设施失败按稳定 kind 持久化并立即失败结束当前 run;preview readiness/playtest 的 manifest 完成分别绑定当前 revision smoke 和根合同 browser receipt,read-only 文本交付不能绕过。 - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 + +## 2026-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,不得换键重提。 +- 查询与结果:新增 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 控制字段。 +- 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 入口。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 +- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、API 选择参考、stdlib Python helper 和 `agents/openai.yaml`,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。不支持 MCP 或需要本地文件上传编排的 Agent 使用该 Skill 包。 +- 兼容边界:这是基于「截至 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/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index fe9792898..dd0e4e4ac 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3998,3 +3998,19 @@ - 处理:改外部 v1 响应前先确认 `external_api_key` 是否已有非内部账号的活跃密钥。仍无调用方时可按现行豁免直接改,但必须同步更新接入方案的「版本与兼容策略」;已有调用方时按该节规则择一处理(兼容值 / 弃用期 / 升 v2),只改 JSON 不构成合规变更。 - 验证:`external_editor_api.rs` 的 openapi 断言只校验 schema 形状,不校验兼容性,通过不等于契约安全;判定 breaking 与否以「删字段、移出 required、收窄类型、改语义、新增必填」为准。 - 关联:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/openapi/genarrative-external-v1.openapi.json`、`server-rs/crates/api-server/src/external_editor_api.rs`、`server-rs/crates/api-server/src/modules/external_api.rs`。 + +## 异步生成结果未知时不能换幂等键重提(2026-07-31) + +- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 +- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 +- 处理:一次逻辑生成只分配一个稳定幂等键;传输重试必须使用完全相同的请求体和原键。收到 `operationId` 后只查询 `/api/external/v1/generations/{operationId}`,调用方轮询超时不改变服务端任务状态。结果未知且尚未拿到 operationId 时也只用原键重试提交。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 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`。 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/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index d5839a983..d81274588 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -4,18 +4,18 @@ > 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 -更新时间:`2026-07-21` +更新时间:`2026-07-31` ## 背景 -当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。 +VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的八类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。 ## 目标 - 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。 - 外部生成副作用由独立 `external-generation-worker` 角色执行。 - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 -- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 +- 本地或小流量站内同步排查可显式启用 `inline` 模式,由站内 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。External v1 不继承此例外,始终异步入队。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 - 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。 - 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。 @@ -37,6 +37,8 @@ - `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 - `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。 +External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。 + 不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 @@ -112,6 +114,8 @@ pending/running -> cancelled (预留) - `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。 - `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。 +External v1 八类生成不读取上述模式分支:即使进程配置为 `inline`,External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。 + 同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换: - `api`:只启动 HTTP server。 @@ -207,7 +211,19 @@ controller 配置: 透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。 -inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 +inline 完成结果与 External v1 completed compact result 继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 + +### External v1 异步提交与查询 + +External v1 复用上述九类 editor job kind 中除手动去背景外的八类生成 kind。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数: + +1. 每个生成 POST 必须携带 `Idempotency-Key`。服务端把 owner、job kind、稳定键和规范请求纳入 dedupe;未知结果重试必须复用原键。 +2. 成功入队返回 HTTP `202`、`operationId`、`statusUrl`、`pollAfterMs`,并设置 `Location` / `Retry-After`;不返回 project、asset 或媒体结果。 +3. `GET /api/external/v1/generations/{operationId}` 通过 owner-safe facade 读取摘要。`queued/running` 返回 phase/progress;`failed` 返回脱敏错误;`completed` 再读取同一 owner 的生成 artifacts 并返回 `result_payload_json.result`。 +4. 跨 owner operationId 按不存在处理。查询路径不开放 claim、renew、complete、fail、retry、acknowledge 或 controller 控制面。 +5. completed 查询返回 compact artifact 引用。调用方需要完整画布时重新读取项目,需要媒体临时 URL 时再对稳定 objectKey 换签。 + +托管 MCP 的生成 tools 也走同一 External REST router:MCP 参数中的 `idempotencyKey` 映射到 HTTP `Idempotency-Key`,`get_external_editor_generation_job` 映射统一查询。MCP 不直接调用 SpacetimeDB procedure,不形成平行队列或结果账本。 ## 验收 @@ -226,6 +242,8 @@ cargo check -p api-server --manifest-path server-rs/Cargo.toml cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml +cargo test -p api-server external_editor_generation --manifest-path server-rs/Cargo.toml +cargo test -p api-server external_mcp --manifest-path server-rs/Cargo.toml npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible" npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft" ``` @@ -239,7 +257,7 @@ curl -f http://127.0.0.1:/healthz 本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。 -生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8891958e4..11f443740 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -240,7 +240,7 @@ 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 收紧:后台任务工具箱提供 `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`。每次生成提交必须携带稳定 `Idempotency-Key`,持久保留返回的 `operationId`,按 `pollAfterMs` 查询 `/api/external/v1/generations/{operationId}`;只有 `completed` 才消费 compact result 并换签下载,客户端超时或结果未知时不得换键重提。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。completed result 含 `warning.code=postprocess-failed-source-preserved` 或对应媒体不含任意 `alpha < 255` 时不得登记为正式透明图集;仅有 `sliceWarning` 时可保留完整透明图,但不宣称已有独立切片。本地 manifest 持久生成 route、kind、operationId 与精确参考 resourceId;登记失败时删除本轮刚写入的新文件。API Key 和幂等键不进入 observation、manifest、agent.db 或日志。 - 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 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -327,7 +327,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` 调用 `/api/external/v1/editor/images/generations`,请求携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`。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 或日志;operationId 只作为该生成动作的可恢复身份保存。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 - 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。 - `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端资源行。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 3c41aae27..66c41f2b8 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,75 @@ 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 三类资源,不开放内部 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 入口正文与完整 Skill 包下载地址。 + +MCP 必须始终复用 `require_external_api_key`,owner 从 `ExternalApiPrincipal` 获取,不接受请求参数伪造 owner。禁止透传内部 `external_generation_job` procedure、worker controller、SpacetimeDB MCP 或 lease/fencing 控制面。 + +## Agent 集成发现与完整 Skill 包 + +`agent-integration.json` 是机器可读的统一发现入口。支持远程 MCP 的 Agent 读取其中 `mcp.transport/url/authentication`;不支持 MCP 的 Agent 下载 `skill.archive`,核对 `archiveSha256`,解压后从 `genarrative-external-editor-api/SKILL.md` 进入。 + +Skill archive 必须至少包含: + +- `SKILL.md` +- `references/api-selection.md` +- `scripts/genarrative_external_api.py` +- `agents/openai.yaml` + +包由 api-server 直接从仓库同源文件构建,不能只返回光秃秃的 OpenAPI JSON,也不能把个人 API Key、环境配置或本机路径写入包。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 +129,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 +166,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 +195,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 +230,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 包含四个声明文件且不含凭据。 +- MCP 在无 Bearer 时返回 `401`,合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。 - 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。 - 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。 - 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 7c01f7be6..d3424f54c 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -231,6 +231,7 @@ dependencies = [ "platform-wechat", "reqwest", "ring", + "rmcp", "serde", "serde_json", "sha1", @@ -827,6 +828,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1932,6 +1944,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -3679,6 +3692,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -4516,6 +4535,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4554,6 +4584,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4720,6 +4756,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "schemars 1.2.1", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + [[package]] name = "rmp" version = "0.8.15" @@ -4931,12 +4996,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5027,6 +5106,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -5670,6 +5760,19 @@ dependencies = [ "log", ] +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 44de73be6..dbf37e43e 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false } pingora-proxy = { version = "0.8.1", default-features = false } rand_core = "0.6" reqwest = { version = "0.12", default-features = false } +rmcp = { version = "=2.2.0", default-features = false } ring = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 27b3bc76c..ff0357094 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -16,6 +16,7 @@ hex = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] } webp = { workspace = true } module-ai = { workspace = true } module-assets = { workspace = true, features = ["server-service"] } @@ -48,6 +49,7 @@ tokio-stream = { workspace = true } futures-util = { workspace = true } time = { workspace = true, features = ["formatting"] } tower-http = { workspace = true, features = ["trace"] } +tower = { workspace = true, features = ["util"] } tracing = { workspace = true } opentelemetry = { workspace = true } url = { workspace = true } @@ -62,4 +64,3 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ base64 = { workspace = true } http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } -tower = { workspace = true, features = ["util"] } diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b44a66496..5a47c01a9 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -48,7 +48,7 @@ use shared_contracts::assets::{ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, }; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ExternalGenerationJobRecord, SpacetimeClientError}; use crate::{ api_response::json_success_body, @@ -61,7 +61,7 @@ use crate::{ editor_generation_queue::{ EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, EditorGenerationQueuedResponse, editor_generation_queue_state, - editor_generation_source_entity_id, enqueue_editor_generation_job, + editor_generation_source_entity_id, enqueue_editor_generation_job_for_caller, }, editor_green_screen::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, @@ -578,38 +578,14 @@ pub async fn generate_editor_character_animation( )); } if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - character_animation_error_response( - &request_context, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })), - ) - })?; - // 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。 - let normalized = normalize_editor_character_animation_request_with_pricing( - payload.clone(), - &pricing, - crate::editor_green_screen::default_editor_screen_background_color(), - ) - .map_err(|error| character_animation_error_response(&request_context, error))?; - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - payload.source_layer_id.as_str(), - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_character_animation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成角色动作", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -627,6 +603,56 @@ pub async fn generate_editor_character_animation( .await } +pub(crate) async fn enqueue_editor_character_animation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: EditorCharacterAnimationGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + if matches_inline_media_source(payload.source_image_src.as_str()) { + return Err(character_animation_error_response( + request_context, + editor_character_animation_bad_request( + "sourceImageSrc 必须先上传 OSS,并使用 objectKey 或画板资源引用。", + ), + )); + } + let pricing = state.editor_generation_pricing().await.map_err(|error| { + character_animation_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + // 队列路径只取定价,背景色决策留到实际执行时再做,这里用默认色占位。 + let normalized = normalize_editor_character_animation_request_with_pricing( + payload.clone(), + &pricing, + crate::editor_green_screen::default_editor_screen_background_color(), + ) + .map_err(|error| character_animation_error_response(request_context, error))?; + let source_entity_id = editor_generation_source_entity_id( + payload.project_id.as_deref(), + payload.source_layer_id.as_str(), + ); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成角色动作", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_character_animation_for_owner( state: AppState, request_context: RequestContext, @@ -970,31 +996,14 @@ pub async fn generate_editor_video( })?; let owner_user_id = authenticated.claims().user_id().to_string(); if !state.config.external_generation_mode.is_inline() { - let pricing = state.editor_generation_pricing().await.map_err(|error| { - editor_video_error_response( - &request_context, - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "editor-generation-pricing", - "message": error.to_string(), - })), - ) - })?; - let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing) - .map_err(|error| editor_video_error_response(&request_context, error))?; - let source_entity_id = - editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video"); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_video_generation_for_owner( &state, &request_context, owner_user_id.as_str(), - EDITOR_VIDEO_GENERATION_JOB_KIND, - source_entity_id, - "图片画布生成视频", - u64::from(normalized.price_mud_points), - &payload, + payload, + None, ) - .await - .map_err(|error| error.into_response_with_context(Some(&request_context)))?; + .await?; return Ok(json_success_body( Some(&request_context), EditorGenerationQueuedResponse { @@ -1005,6 +1014,41 @@ pub async fn generate_editor_video( generate_editor_video_for_owner(state, request_context, owner_user_id, Ok(Json(payload))).await } +pub(crate) async fn enqueue_editor_video_generation_for_owner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + payload: EditorVideoGenerateRequest, + external_idempotency_key: Option<&str>, +) -> Result { + let pricing = state.editor_generation_pricing().await.map_err(|error| { + editor_video_error_response( + request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-generation-pricing", + "message": error.to_string(), + })), + ) + })?; + let normalized = normalize_editor_video_request_with_pricing(payload.clone(), &pricing) + .map_err(|error| editor_video_error_response(request_context, error))?; + let source_entity_id = + editor_generation_source_entity_id(payload.project_id.as_deref(), "editor-video"); + enqueue_editor_generation_job_for_caller( + state, + request_context, + owner_user_id, + EDITOR_VIDEO_GENERATION_JOB_KIND, + source_entity_id, + "图片画布生成视频", + u64::from(normalized.price_mud_points), + &payload, + external_idempotency_key, + ) + .await + .map_err(|error| error.into_response_with_context(Some(request_context))) +} + pub(crate) async fn generate_editor_video_for_owner( state: AppState, request_context: RequestContext, diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 640aa6dc9..248adf54a 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -1,6 +1,7 @@ use axum::http::StatusCode; use serde::Serialize; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use shared_contracts::external_generation::{ ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, }; @@ -26,6 +27,7 @@ pub(crate) const EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND: &str = pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas"; const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; +const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -77,6 +79,132 @@ where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; + enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json, + job_id, + dedupe_key, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_external_api_editor_generation_job( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + idempotency_key: &str, +) -> Result +where + T: Serialize, +{ + let request_payload_json = serialize_editor_generation_job_payload(payload)?; + let mut hasher = Sha256::new(); + hasher.update(owner_user_id.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(job_kind.trim().as_bytes()); + hasher.update(b"\0"); + hasher.update(idempotency_key.as_bytes()); + let dedupe_key = format!( + "{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}", + hasher.finalize() + ); + let requested_job_id = build_prefixed_uuid_id("task-"); + let job = enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json.clone(), + requested_job_id.clone(), + dedupe_key, + ) + .await?; + + if job.job_kind != job_kind + || job.owner_user_id != owner_user_id + || job.request_payload_json != request_payload_json + { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": EDITOR_GENERATION_QUEUE_PROVIDER, + "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + })), + ); + } + Ok(job) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_editor_generation_job_for_caller( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + external_idempotency_key: Option<&str>, +) -> Result +where + T: Serialize, +{ + let source_entity_id = source_entity_id.into(); + let request_label = request_label.into(); + match external_idempotency_key { + Some(idempotency_key) => { + enqueue_external_api_editor_generation_job( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + idempotency_key, + ) + .await + } + None => { + enqueue_editor_generation_job( + state, + request_context, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + ) + .await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn enqueue_serialized_editor_generation_job_with_identity( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + request_payload_json: String, + job_id: String, + dedupe_key: String, +) -> Result { let now_micros = current_utc_micros(); state .spacetime_client() @@ -164,12 +292,21 @@ fn is_inline_media_reference(value: &str) -> bool { pub(crate) fn editor_generation_queue_state( job: ExternalGenerationJobRecord, ) -> ExternalGenerationJobStatusRecord { + let (status, phase_detail, progress) = match job.status.as_str() { + "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" if job.phase.as_deref() == Some("processing") => { + (ExternalGenerationJobStatus::Running, "正在处理。", 70) + } + "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), + "failed" | "cancelled" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), + _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), + }; ExternalGenerationJobStatusRecord { operation_id: job.job_id, - status: ExternalGenerationJobStatus::Queued, + status, phase_label: job.request_label, - phase_detail: "排队中。".to_string(), - progress: 8, + phase_detail: phase_detail.to_string(), + progress, error: job.last_error_message, updated_at_micros: job.updated_at_micros, } @@ -194,6 +331,38 @@ fn current_utc_micros() -> i64 { mod tests { use super::*; + fn queue_job_fixture(status: &str, phase: Option<&str>) -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "task-queue-test".to_string(), + dedupe_key: "editor-canvas:test:task-queue-test".to_string(), + job_kind: EDITOR_IMAGE_GENERATION_JOB_KIND.to_string(), + owner_user_id: "user-1".to_string(), + source_module: EDITOR_GENERATION_QUEUE_SOURCE_MODULE.to_string(), + source_entity_id: "project-1".to_string(), + request_label: "图片画布生成图片".to_string(), + request_payload_json: "{}".to_string(), + status: status.to_string(), + attempt: 0, + max_attempts: 1, + last_error_message: None, + worker_id: None, + lease_expires_at: None, + available_at: "2026-07-31T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-07-31T00:00:00Z".to_string(), + started_at: None, + completed_at: None, + updated_at: "2026-07-31T00:00:00Z".to_string(), + updated_at_micros: 1_785_456_000_000_000, + lease_token: None, + price_mud_points: 2, + refund_ledger_id: None, + notification_acknowledged_at: None, + notification_acknowledged_at_micros: None, + phase: phase.map(ToOwned::to_owned), + } + } + #[test] fn serialize_payload_accepts_persistable_media_references() { let payload = json!({ @@ -253,4 +422,72 @@ mod tests { assert_eq!(error.status_code().as_u16(), 413); assert!(error.body_text().contains("超过持久化上限")); } + + #[test] + fn queue_state_maps_idempotent_replays_to_the_persisted_status() { + let cases = [ + ( + "pending", + None, + ExternalGenerationJobStatus::Queued, + "排队中。", + 8, + ), + ( + "running", + Some("generating"), + ExternalGenerationJobStatus::Running, + "正在生成。", + 35, + ), + ( + "running", + Some("processing"), + ExternalGenerationJobStatus::Running, + "正在处理。", + 70, + ), + ( + "completed", + None, + ExternalGenerationJobStatus::Completed, + "生成已完成。", + 100, + ), + ( + "failed", + None, + ExternalGenerationJobStatus::Failed, + "生成失败。", + 0, + ), + ]; + + for (persisted_status, phase, expected_status, expected_detail, expected_progress) in cases + { + let state = editor_generation_queue_state(queue_job_fixture(persisted_status, phase)); + + assert_eq!(state.operation_id, "task-queue-test"); + assert_eq!(state.status, expected_status, "status={persisted_status}"); + assert_eq!( + state.phase_detail, expected_detail, + "status={persisted_status}" + ); + assert_eq!( + state.progress, expected_progress, + "status={persisted_status}" + ); + } + } + + #[test] + fn queue_state_keeps_failed_error_for_idempotent_replay() { + let mut job = queue_job_fixture("failed", None); + job.last_error_message = Some("生成失败摘要".to_string()); + + let state = editor_generation_queue_state(job); + + assert_eq!(state.status, ExternalGenerationJobStatus::Failed); + assert_eq!(state.error.as_deref(), Some("生成失败摘要")); + } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9457a2004..1bbc80437 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, @@ -5866,50 +5914,16 @@ pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, - Json(mut payload): Json, + Json(payload): Json, ) -> Result, AppError> { - payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { - ensure_editor_reference_image_source_is_stable( - payload.source_image_src.as_str(), - "editor-ui-design-asset-extraction", - "sourceImageSrc", - "UI设计图", - )?; - ensure_editor_reference_image_sources_are_stable( - payload.reference_image_srcs.as_deref(), - "editor-ui-design-asset-extraction", - "referenceImageSrcs", - "UI素材参考图", - )?; - let generation_options = normalize_editor_ui_design_asset_extraction_options( - payload.model.as_deref(), - payload.aspect_ratio.as_str(), - payload.image_size.as_str(), - )?; - let price_mud_points = u64::from( - resolve_editor_ui_design_asset_extraction_price( - &state, - Some(generation_options.model), - Some(generation_options.image_size), - ) - .await?, - ); - let source_entity_id = editor_generation_source_entity_id( - payload.project_id.as_deref(), - "editor-ui-design-asset-extraction", - ); - let queue_job = enqueue_editor_generation_job( + let queue_job = enqueue_editor_ui_design_asset_extraction_for_owner( &state, &request_context, - caller.owner_user_id.as_str(), - EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, - source_entity_id, - "图片画布提取UI设计图素材", - price_mud_points, - &payload, + &caller, + payload, + None, ) .await?; return Ok(json_success_body( @@ -5922,6 +5936,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_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..712d41b70 --- /dev/null +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -0,0 +1,789 @@ +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 USAGE_URI: &str = "genarrative://external-editor/usage"; +const OPENAPI_URI: &str = "genarrative://external-editor/openapi"; +const SKILL_URI: &str = "genarrative://external-editor/skill"; +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 下载信息见 resources/list。"#; + +#[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(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("不支持 MCP 的 Agent 可下载完整 Skill 包") + .with_mime_type("text/markdown"), + ])) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: McpRequestContext, + ) -> Result { + let (text, mime_type) = match request.uri.as_str() { + USAGE_URI => (MCP_INSTRUCTIONS.to_string(), "text/markdown"), + OPENAPI_URI => (OPENAPI_JSON.to_string(), "application/json"), + SKILL_URI => ( + format!( + "{SKILL_MD}\n\n完整 Skill 包:\n原始入口:" + ), + "text/markdown", + ), + _ => return Err(ErrorData::resource_not_found("资源不存在", None)), + }; + Ok(ReadResourceResult::new(vec![ + ResourceContents::text(text, request.uri).with_mime_type(mime_type), + ])) + } +} + +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; + use axum::http::{ + StatusCode, + header::{ACCEPT, HOST}, + }; + + #[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 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}" + ); + } + + 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": 3, + "method": "resources/read", + "params": {"uri": OPENAPI_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("陶泥儿外部编辑器 OpenAPI")) + ); + } + + #[tokio::test] + async fn mounted_mcp_route_requires_external_api_key() { + let state = AppState::new(AppConfig::default()).expect("test state should build"); + 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 = modules::external_api::router(state.clone()) + .with_state(state) + .oneshot(request) + .await + .expect("external router should be infallible"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } +} 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..d5c03af14 --- /dev/null +++ b/server-rs/crates/api-server/src/external_skill_api.rs @@ -0,0 +1,130 @@ +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); 4] = [ + ( + "SKILL.md", + include_str!("../../../../.codex/skills/genarrative-external-editor-api/SKILL.md"), + ), + ( + "references/api-selection.md", + include_str!( + "../../../../.codex/skills/genarrative-external-editor-api/references/api-selection.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" + }, + "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"); + 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); + } + } +} 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..4c1b5757f 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}, @@ -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_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 {