Merge remote-tracking branch 'web/master' into feat/pixel_art2
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Failing after 2m45s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Failing after 21s

# Conflicts:
#	.codex/skills/genarrative-external-editor-api/SKILL.md
#	.codex/skills/genarrative-external-editor-api/references/api-selection.md
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-08-03 07:47:17 +00:00
65 changed files with 7399 additions and 1287 deletions
@@ -1,93 +1,70 @@
---
name: genarrative-external-editor-api
description: Guide use of Genarrative's external editor/canvas OpenAPI. Use when a user describes a canvas/editor integration need and Codex must infer the right `/api/external/v1` API automatically, prepare canvas and asset-library context, abstract reusable art specs before generating assets, upload references, draft curl/HTTP/SDK requests, or set up and safely handle a Genarrative developer API Key.
description: Guide use of Genarrative's hosted external editor/canvas MCP or asynchronous `/api/external/v1` OpenAPI. Use when an Agent needs to discover the hosted integration, choose a canvas or asset operation, upload local reference media, create or update projects and asset-library records, submit and poll image/video/audio generation, interpret generated artifacts and warnings, draft HTTP/Python calls, or securely handle a Genarrative developer API Key.
---
# Genarrative External Editor API
Use the live OpenAPI contract as the source of truth: `GET https://www.genarrative.world/api/external/v1/openapi.json`. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`. If exact fields or enums matter, read the contract before emitting final code.
Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
Prefer the bundled Python helper for runnable examples: `scripts/genarrative_external_api.py`. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload/confirm/generation routes.
Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
## Workflow
1. At the start of a new conversation, ask the user for the canvas name before the first generation call unless an existing session is already provided. Create or use a project with that name and an asset-library folder with the same name. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
2. Before any art asset generation, abstract the user's request into a reusable art spec. Ask only for missing spec fields required by the selected asset type. If a current spec already exists and the user does not request a new style/spec, reuse it automatically.
3. Classify the user's natural-language intent. Do not ask the user to choose an API:
- "生成/生图/做一张图" -> image generation
- "重绘/修改这张图" -> image edit
- "用这张参考图/基于本地图生成" -> upload local reference image, then generation or edit
- "上传本地素材" -> upload ticket, OSS form upload, object confirm
- "保存画板/更新布局" -> canvas save
- "读取私有素材" -> signed read URL
4. Ask only for missing inputs that affect the request body or an actually ambiguous route:
- credentials JSON path only if the user cannot use the default local path
- canvas name when no current canvas session exists; existing `projectId`, folder/resource IDs only when resuming a known project
- media type, prompt, references, dimensions, model, ratio, duration, and resolution
- whether referenced media is already uploaded as `objectKey` or still local
5. Every external generation must write to both the canvas and the asset library. Include `projectId`, `assetFolderId`, a display label, and `canvasCompletion` whenever the target endpoint supports them. For character animation, use the helper's two-step fallback: generate with `projectId` + `canvasCompletion`, then create a library asset from the first returned frame in the session folder.
6. If the user lacks an API Key, guide setup before request design.
7. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference.
8. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or a chain that should execute with fewer hand-written curl steps.
9. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs.
1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to generation; never substitute a temporary signed URL.
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
## Core Routes
## Essential Invariants
| Intent | Method and path | Required fields |
| --- | --- | --- |
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
| Image edit/redraw | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` |
| Icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` |
| UI asset extraction | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize`; use `assetFolderId` for library folder |
| Character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` |
| Video generation | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` |
| Sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` |
| Background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` |
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Keep generated artifacts in the canvas and asset library together. Character animation may need a post-completion library fallback from the first returned frame when no direct asset is present; the helper implements it.
## Art Spec Interface
## Documentation Navigation
Maintain one current art spec per conversation. A compact spec is enough:
Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
```json
{
"assetType": "character | background | prop | ui | icon | animation | video | audio",
"subject": "要生成的主体",
"style": "画风/材质/时代/参考风格",
"palette": "主色与禁用色",
"composition": "构图、镜头、姿态或布局",
"format": "比例、尺寸、分辨率、帧数、时长",
"constraints": "必须保留/禁止出现/透明或绿幕要求",
"references": ["objectKey 或本地路径说明"]
}
```
- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
For a first spec, infer fields from the user's words and ask only for missing fields that block the selected API. Examples: character animation needs source image/layer, dimensions, motion, ratio, frame count, and duration; UI extraction needs source design image plus target density; icon spritesheet needs reference image and icon descriptions. After a spec exists, reuse it for later assets unless the user changes style, subject family, palette, format, or constraints.
The hosted MCP exposes the same documents through:
## API Key
- `genarrative://external-editor/skill`
- `genarrative://external-editor/skill/references/capability-routing.md`
- `genarrative://external-editor/skill/references/api-operations.md`
- `genarrative://external-editor/skill/references/authentication-and-safety.md`
- `genarrative://external-editor/skill/references/requests-and-outputs.md`
- `genarrative://external-editor/openapi`
The external OpenAPI uses:
## Hosted Integration Discovery
```text
Authorization: Bearer <tnr_sk_...>
```
- Manifest: `GET /api/external/v1/agent-integration.json`.
- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
- OpenAPI: `GET /api/external/v1/openapi.json`.
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key.
The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
Use this fixed production base URL:
## Python Helper
```text
https://www.genarrative.world/
```
Guide the user to create a key from the logged-in product UI under `开发者 API Key`. The raw key is shown only once; never ask the user to paste it into chat. Tell them to store it in this local private JSON file, outside the repository:
```text
~/.config/genarrative/external-editor-api.json
```
Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
```json
{
@@ -95,274 +72,45 @@ Guide the user to create a key from the logged-in product UI under `开发者 AP
}
```
Set the file readable only by the current user where possible: `chmod 600 ~/.config/genarrative/external-editor-api.json`. Do not use environment variables for this API.
Smoke test by reading the JSON file, without printing the key:
```bash
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")"
curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \
-H "Authorization: Bearer $api_key"
```
For generated client code, read `apiKey` from the JSON file, fail with a clear missing-config error, and redact keys in logs.
Python smoke without printing the key:
Set restrictive permissions where possible, then smoke-test without printing the key:
```bash
chmod 600 ~/.config/genarrative/external-editor-api.json
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
## Request Patterns
For image and icon generation, the request-body top-level `style` field both appends a server-side pixel-art clause to the prompt sent to the provider and enables deterministic post-processing. It is distinct from `generationInputs.artSpec.style`, which is stored metadata describing the requested visual language. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable both on supported generation types; use `"none"` or omit the field otherwise, in which case the submitted prompt is identical to what the same request would send with the field omitted. That is not the same as passing your text through untouched — see `references/api-selection.md`, which also covers compatibility and fallback semantics.
For Python callers, prefer:
For a canvas-backed generation:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
art_spec = {
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿、金色光斑,避免低饱和灰",
"composition": "16:9 横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": [],
}
client.generate_image(
"生成幻想森林背景",
"生成一张 16:9 幻想森林游戏背景",
canvasSession=session,
assetLabel="森林背景",
aspectRatio="16:9",
imageSize="1K",
artSpec=art_spec,
artSpec={
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿与金色光斑",
"composition": "横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": [],
},
)
```
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
```python
client.generate_icon_spritesheet(
"editor-resource-current-art-spec",
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
screenColor="auto",
)
```
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
Use this shared base:
```bash
api="https://www.genarrative.world"
credentials_file="$HOME/.config/genarrative/external-editor-api.json"
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")"
auth=(-H "Authorization: Bearer $api_key")
json=(-H "Content-Type: application/json")
```
Create a project:
```bash
curl -fsS "$api/api/external/v1/editor/projects" \
"${auth[@]}" "${json[@]}" \
-d '{"title":"新画板"}'
```
Generate an image and save it into both the canvas and the asset-library folder:
```json
{
"prompt": "一张横版幻想森林背景,适合游戏主视觉",
"kind": "spec",
"aspectRatio": "16:9",
"imageSize": "1K",
"projectId": "<projectId>",
"assetFolderId": "<assetFolderId>",
"assetLabel": "森林背景",
"generationInputs": {
"artSpec": {
"assetType": "background",
"style": "手绘游戏概念图"
}
},
"canvasCompletion": {
"title": "森林背景",
"placeholder": {
"x": 0,
"y": 0,
"width": 1024,
"height": 576,
"originalWidth": 1024,
"originalHeight": 576
}
}
}
```
Then call `POST /api/external/v1/editor/images/generations`.
For direct HTTP/curl, create or find the folder first with `GET /api/external/v1/editor/assets/library` and `POST /api/external/v1/editor/assets/folders`. The folder label should match the canvas name.
## Reference Images
When the user provides a local reference image path/file, upload it first; do not ask the user to convert it to base64.
Python helper path:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
ref = client.upload_reference_image("/path/to/reference.png")
client.generate_image(
"基于参考图生成一张 16:9 游戏背景",
canvasSession=session,
assetLabel="参考图背景",
aspectRatio="16:9",
imageSize="1K",
referenceImageSrcs=[ref["objectKey"]],
)
```
Use the normal upload flow with:
```json
{
"legacyPrefix": "generated-character-drafts",
"pathSegments": ["editor", "external-editor-references"],
"fileName": "<original-file-name>",
"contentType": "image/png",
"access": "private"
}
```
After OSS form upload, confirm the object with `assetKind: "editor_reference_image"`. Put the returned `objectKey` into the generation request:
- image generation: `referenceImageSrcs`
- image edit/redraw: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- icon spritesheet: `referenceImageSrc`
- UI asset extraction: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- character animation: `sourceImageSrc`
- video generation image references: `referenceImageSrcs`
Use `signedUrl` only for display/download. For generation requests, use `objectKey`, project resource ID, asset ID, public URL, or Data URL as the endpoint allows; prefer uploaded `objectKey` for local/private reference images.
OSS form upload shape, using the ticket response saved as `ticket.json`. The default response has `upload`; if the caller explicitly requested the API response envelope, use `data.upload`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Then confirm with `contentLength`:
```json
{
"objectKey": "<ticket upload.objectKey>",
"contentType": "image/png",
"contentLength": 12345,
"assetKind": "editor_reference_image",
"accessPolicy": "private"
}
```
`contentLength` is a JSON number from the local file byte size, not a quoted string.
For character animation from an uploaded local image, set:
```json
{
"sourceLayerId": "external-reference-hero",
"sourceImageSrc": "<uploaded objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
"promptText": "让角色自然呼吸并轻微转身",
"resolution": "720p",
"ratio": "9:16",
"frameCount": 40,
"durationSeconds": 5,
"model": "seedance2.0-fast"
}
```
Use an existing canvas layer ID when the image came from a project layer. If it came only from a local upload, derive a stable synthetic `sourceLayerId` from the file name, for example `external-reference-hero`. Read `sourceWidth` and `sourceHeight` from the actual image before upload; ask the user only if the dimensions cannot be determined.
The helper uses a 420 second timeout for generation calls, including character animation and video. Direct HTTP clients should not use a 70 second request timeout for animation.
Character animation currently returns canvas completion data but not a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; the helper creates a library asset from the first returned frame in the session folder after the animation call succeeds.
For video generation, always include `mode: "std"`. When using image/video/audio references, default to `model: "seedance2.0-fast"` unless the user asks for another listed model, because reference media support is limited to the Seedance 2.0 family.
For image edit/redraw that should replace an existing canvas layer, pass `projectId` and `targetLayerId`. If the user instead gives an explicit `canvasCompletion`, let that placement win.
For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation.
## Successful Responses with Warnings
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction can return HTTP 2xx with an optional structured `warning`. A 2xx response means the task completed, but it does not guarantee that every requested post-processed derivative exists.
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
## AI Game Creator Canonical Visual DAG
The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request:
1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image.
2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`.
3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`.
Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG.
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
- Do not omit canvas/library context for generation. New generated assets should enter both the canvas and the asset-library folder named after the canvas.
- Do not put API Keys in repository files, generated project files, command history snippets with literal secrets, logs, docs, commits, or screenshots. The only default storage is the user's local private JSON credentials file.
- Do not use account JWT endpoints as the default external integration path. The profile API can create/revoke keys for logged-in product users, but it is not part of the external editor OpenAPI.
- When an endpoint returns `project`, `resource`, or `asset`, treat those as the authoritative updated project/resource/asset snapshots.
## Resources
- `references/api-selection.md`: intent routing and required-field cheat sheet.
- `scripts/genarrative_external_api.py`: stdlib Python helper for OpenAPI fetch, API Key loading, local reference upload, object confirm, project/canvas calls, and generation requests.
- Do not change the fixed production base URL in generated examples.
- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
@@ -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
@@ -0,0 +1,99 @@
# API Operations
Use this reference after selecting a capability. Treat `GET /api/external/v1/openapi.json` as authoritative for exact request/response schemas, required fields, constraints, and operation IDs.
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
## Project and Canvas Operations
| Operation | Method and path | Minimum input |
| --- | --- | --- |
| List projects | `GET /api/external/v1/editor/projects` | Authentication |
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
| Load recent project | `GET /api/external/v1/editor/projects/recent` | Authentication |
| Get project | `GET /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Delete project | `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
Canvas save uses optimistic revision control. Pass the last authoritative `expectedRevision`; on conflict, reload instead of replaying a stale full layout.
## Asset and Upload Operations
| Operation | Method and path | Minimum input |
| --- | --- | --- |
| Create direct-upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Read asset library | `GET /api/external/v1/editor/assets/library` | Authentication |
| Create folder | `POST /api/external/v1/editor/assets/folders` | `label` |
| Update folder | `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `label` or `collapsed` |
| Delete folder | `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `folderId` |
| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` |
| Update asset record | `PATCH /api/external/v1/editor/assets/{assetId}` | `label` or `folderId` |
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
## Generation Operations
Every generation row requires a stable `Idempotency-Key` header and returns HTTP `202` with an asynchronous submission, not the generated media.
| Capability | POST path | Required body fields | Common optional body fields |
| --- | --- | --- | --- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all eight through:
```text
GET /api/external/v1/generations/{operationId}
```
Supply the `operationId` returned by submission. Poll no faster than `pollAfterMs` and retain the ID after a caller-side timeout.
## Canvas and Library Field Rules
- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
- Character animation does not accept the same library fields. If its completed compact result lacks a direct `asset`, create a library record from the first returned frame; do not duplicate one when an asset already exists.
- Reload project/library state after completion when full current state is required.
## Reference Field Mapping
After confirming a local upload, pass its stable `objectKey` into:
| Target capability | Field |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Icon spritesheet | `referenceImageSrc`; additional style references in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video with image references | `referenceImageSrcs` |
Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference.
## Common Values
Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`.
- Video `resolution`: `480p`, `720p`, `1080p`; `mode`: `std`; `sound`: `on` or `off`.
- Character animation uses `model: "seedance2.0-fast"`; `resolution`: `480p` or `720p`; `frameCount`: `32`, `40`, or `48`; `durationSeconds`: `4`, `5`, or `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, or `3:4`.
- UI extraction uses `aspectRatio: "1:1"`; use `imageSize: "1K"` for normal/small extraction and `2K` for dense designs.
Do not hard-code this list as a replacement client schema. In particular, the top-level image `style` field is intentionally extensible; see `requests-and-outputs.md` for its fallback behavior.
@@ -1,240 +0,0 @@
# External Editor API Routing
Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
## Base
- Fixed base URL: `https://www.genarrative.world/`.
- Public contract: `GET /api/external/v1/openapi.json`.
- Authenticated calls: `Authorization: Bearer <tnr_sk_...>`.
- Default credentials file: `~/.config/genarrative/external-editor-api.json` with an `apiKey` string.
- Generation clients should allow long-running responses. Use at least 420 seconds for character animation and video; 70 seconds is too short for animation.
## Canvas Session and Art Spec
At the start of a new conversation, ask for a canvas name before the first generation call unless the user already supplied `projectId` and `assetFolderId`. Create or reuse:
1. `POST /api/external/v1/editor/projects` with `title` = canvas name.
2. `GET /api/external/v1/editor/assets/library`; if no folder has the same label, `POST /api/external/v1/editor/assets/folders` with `label` = canvas name.
3. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful.
For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain:
1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`.
2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`.
Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
| User says | Route |
| --- | --- |
| "生成图片", "生图", "做一张背景/角色/宣发图" | `POST /api/external/v1/editor/images/generations` |
| "重绘", "调整这张图", "基于这张图修改" | `POST /api/external/v1/editor/images/edits` |
| "用这张参考图", "参考本地图片生成", "基于本地图做图" | Upload local image first, then pass returned `objectKey` into the generation/edit reference field |
| "按规范图生成图标", "拆图标" | `POST /api/external/v1/editor/icon-spritesheets/generations` |
| "从 UI 设计图提取素材" | `POST /api/external/v1/editor/ui-designs/assets/extractions` |
| "让角色动起来", "生成角色动画帧" | `POST /api/external/v1/editor/character-animations/generations` |
| "生成视频" | `POST /api/external/v1/editor/videos/generations` |
| "生成音效" | `POST /api/external/v1/editor/audios/sound-effects/generations` |
| "生成背景音乐/BGM" | `POST /api/external/v1/editor/audios/background-music/generations` |
| "上传本地素材/图片/音频/视频" | Upload flow: direct upload ticket -> OSS form upload -> object confirm |
| "保存画板布局" | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` |
| "创建/读取/删除画板项目" | Project endpoints |
| "素材库/文件夹/素材记录" | Asset library endpoints |
| "读取私有素材/拿可访问链接" | `GET /api/external/v1/assets/read-url` |
Ask a follow-up only when two routes could both be correct and produce different artifacts, for example "处理这张图" without saying edit, extract UI assets, or use it as a reference for new generation.
## Endpoint Map
| User intent | Endpoint | Minimum request |
| --- | --- | --- |
| Read contract | `GET /api/external/v1/openapi.json` | No auth required |
| List projects | `GET /api/external/v1/editor/projects` | API Key |
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Read asset library | `GET /api/external/v1/editor/assets/library` | API Key |
| Create/update/delete folder | `POST /api/external/v1/editor/assets/folders`, `PATCH`/`DELETE /api/external/v1/editor/assets/folders/{folderId}` | create: `label`; update: `label` or `collapsed` |
| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` |
| Update/delete asset | `PATCH`/`DELETE /api/external/v1/editor/assets/{assetId}` | update: `label` or `folderId` |
## Generation Endpoints
| User intent | Endpoint | Required fields | Common optional fields |
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
## Image Post-processing Style
The request-body top-level `style` field controls both the prompt actually submitted to the provider and deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which is stored metadata describing the requested visual language.
- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning, and leave the submitted prompt identical to what the same request would send with the field omitted.
- That guarantee is about this field only, not about passthrough of your text. The server always builds the submitted prompt: ordinary image prompts are trimmed, `kind: "character"` prompts are wrapped in a server-side template that pins the flat key-colour background matting depends on, and icon spritesheet generation has no `prompt` field at all — its prompt is assembled from the trimmed `iconDescriptions`. `style` does not participate in any of that.
- `"pixelArt"` appends one server-side clause to the end of the submitted prompt and enables deterministic pixel-art snapping, for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation. The clause is scoped per path — `画面为像素风格` for ordinary images, `角色主体为像素风格` for characters, and `每个图标素材均为像素风格` for icon spritesheets. Character and icon spritesheet generation are matted against a flat key-colour background afterwards, so their clauses deliberately never demand a whole-canvas pixelation that would fight the flat-background requirement already in those prompts.
- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`.
- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum.
Image or character generation with pixel-art snapping:
```json
{
"prompt": "生成一个正面站立的像素风冒险者角色",
"kind": "character",
"style": "pixelArt"
}
```
Icon spritesheet generation with pixel-art snapping:
```json
{
"referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png",
"iconDescriptions": ["木剑", "圆盾", "红色药水"],
"style": "pixelArt"
}
```
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
## HTTP 2xx Warning Handling
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist.
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
## Reference Image Upload
If the user provides a local file as a reference image, run upload before the generation request:
1. `POST /api/external/v1/assets/direct-upload-tickets`.
Use `legacyPrefix: "generated-character-drafts"`, `pathSegments: ["editor", "external-editor-references"]`, original `fileName`, detected image `contentType`, and `access: "private"`.
2. Upload the file to the returned OSS form endpoint with all returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey`, detected `contentType`, `contentLength` if known, `assetKind: "editor_reference_image"`, and `accessPolicy: "private"`.
4. Use the returned `objectKey` in the actual editor request.
OSS form upload uses `upload.host` and every non-null `upload.formFields` entry, then the file part named `file`. Default responses expose `upload`; envelope responses expose `data.upload`. Save the upload ticket response as `ticket.json`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Field mapping after upload:
| Target API | Put uploaded `objectKey` in |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Icon spritesheet | `referenceImageSrc`; additional style refs in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional refs in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video generation with image references | `referenceImageSrcs` |
Do not put the signed read URL into generation fields. Signed URLs are for user-visible preview/download; generation fields should use the stable `objectKey` for uploaded private references.
## Common Enums
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`.
- Video `resolution`: `480p`, `720p`, `1080p`.
- Video `mode`: always `std`.
- Video `sound`: `on`, `off`.
- Character animation `model`: always `seedance2.0-fast`.
- Character animation `resolution`: `480p`, `720p`; `frameCount`: `32`, `40`, `48`; `durationSeconds`: `4`, `5`, `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, `3:4`.
## Local Reference Media Details
- `contentLength` in object confirm is a JSON number from local byte size, not a string.
- For character animation, use an existing project layer ID as `sourceLayerId` when available.
- If the source is only an uploaded local image, derive `sourceLayerId` from the file name, such as `external-reference-hero`, and keep it stable across retries.
- Read `sourceWidth` and `sourceHeight` from the local image. If dimensions cannot be read, ask instead of inventing dimensions.
- UI design extraction uses fixed `aspectRatio: "1:1"`; choose `imageSize: "1K"` for normal/small extractions and `2K` for dense designs.
- Video image/video/audio references are supported only by the Seedance 2.0 family; default referenced-media video requests to `model: "seedance2.0-fast"`, `mode: "std"`, and explicit `sound`.
- Image edit/redraw can pass `targetLayerId` with `projectId` to replace an existing canvas layer when no explicit `canvasCompletion` is supplied.
- Image, edit, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record.
- Icon spritesheet and UI extraction can pass `assetFolderId`; UI extraction can also pass `spritesheetLabel`.
## Canvas Completion
Use `canvasCompletion` for generation in this skill so the generated result is written back into the project canvas by the backend.
Required:
```json
{
"title": "素材名称",
"placeholder": {
"x": 0,
"y": 0,
"width": 512,
"height": 512,
"originalWidth": 512,
"originalHeight": 512
}
}
```
`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally.
## Upload Flow
For a local file that should become a project resource or library asset:
1. `POST /api/external/v1/assets/direct-upload-tickets` with `legacyPrefix`, `fileName`, and optional `contentType`, `access`, `maxSizeBytes`.
2. Submit the file to the returned OSS form endpoint with returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey` and an `assetKind`.
4. Create a project resource or library asset with the confirmed `assetObjectId`/`objectKey`.
For reading private/generated assets, call `GET /api/external/v1/assets/read-url?objectKey=...` and use the returned `signedUrl`.
@@ -0,0 +1,146 @@
# Authentication and Safety
Read this reference before handling credentials, local files, private objects, uploads, retries, or logs.
## Contents
- [API Key Setup](#api-key-setup)
- [Idempotency and Unknown Outcomes](#idempotency-and-unknown-outcomes)
- [Local Reference Upload](#local-reference-upload)
- [Stable and Temporary Media References](#stable-and-temporary-media-references)
- [Logging and Command Safety](#logging-and-command-safety)
- [Scope and Retry Guardrails](#scope-and-retry-guardrails)
## API Key Setup
Authenticated calls use:
```text
Authorization: Bearer <tnr_sk_...>
```
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
Store it outside repositories in the user's private JSON file:
```text
~/.config/genarrative/external-editor-api.json
```
```json
{
"apiKey": "tnr_sk_..."
}
```
Set the file readable only by the current user where supported:
```bash
chmod 600 ~/.config/genarrative/external-editor-api.json
```
Use this fixed production base URL:
```text
https://www.genarrative.world/
```
Do not use environment variables as the default API Key storage for this integration. Generated clients must load the JSON file, fail clearly when it is absent or malformed, and redact credentials from errors and logs.
Smoke-test without printing the key:
```bash
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")"
curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \
-H "Authorization: Bearer $api_key"
```
The OpenAPI document, integration manifest, raw Skill entry, and Skill archive are public. Hosted MCP and all project, asset, upload, generation, and generation-query operations require the Bearer API Key.
## Idempotency and Unknown Outcomes
For each logical generation:
1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
2. Persist the key with the exact request body and returned `operationId`.
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
4. Never allocate a new key merely because the outcome is unknown.
5. On a polling timeout, retain `operationId` and query later. Do not submit another generation.
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
## Local Reference Upload
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
5. Pass the confirmed stable `objectKey` to the selected editor operation.
For a private reference image, use a ticket body shaped like:
```json
{
"legacyPrefix": "generated-character-drafts",
"pathSegments": ["editor", "external-editor-references"],
"fileName": "<original-file-name>",
"contentType": "image/png",
"access": "private"
}
```
The default response exposes `upload`; an explicitly enveloped response exposes `data.upload`. Treat the returned host and form fields as opaque. Do not log the entire ticket or persist it longer than needed.
Confirm with the actual file metadata:
```json
{
"objectKey": "<upload.objectKey>",
"contentType": "image/png",
"contentLength": 12345,
"assetKind": "editor_reference_image",
"accessPolicy": "private"
}
```
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
reference = client.upload_reference_image("/path/to/reference.png")
print(reference["objectKey"])
```
Do not print the complete confirmation response if it may contain temporary access data. Prefer passing the returned `objectKey` directly to the next call.
## Stable and Temporary Media References
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
- Never use a Blob URL outside the browser process that created it.
- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
## Logging and Command Safety
- Never place an API Key in repository files, generated projects, command arguments containing a literal key, docs, commits, screenshots, stack traces, test fixtures, or telemetry.
- Redact `Authorization`, API Key values, upload signatures, cookies, signed URL query strings, and private absolute paths from logs and user-visible errors.
- Do not print credentials while diagnosing JSON configuration. Report only presence/absence and safe validation errors.
- Do not commit the credentials file or copy it into the Skill archive.
- Do not expose provider diagnostics, worker leases, queue internals, or server filesystem paths returned by an unexpected error.
## Scope and Retry Guardrails
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
- Do not bypass upload confirmation or invent an object key.
- Do not retry post-processing locally by fabricating assets. Respect completed warning semantics from `requests-and-outputs.md`.
- Use bounded polling. A local wait budget ending does not cancel or fail the server operation.
@@ -0,0 +1,87 @@
# Capability Routing
Use this reference to translate user intent into a hosted MCP tool or its corresponding External v1 REST operation. Use `genarrative://external-editor/openapi` or `GET /api/external/v1/openapi.json` for exact schemas.
## Integration Surface
- Fixed production base URL: `https://www.genarrative.world/`.
- Discovery manifest: `GET /api/external/v1/agent-integration.json`.
- Hosted MCP: `/api/external/v1/mcp`, Streamable HTTP, authenticated with the same Bearer API Key as REST.
- Public contract: `GET /api/external/v1/openapi.json`.
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
## Canvas Session
Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
1. List or create a project. When creating one, use the canvas name as `title`.
2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation may return no direct library asset; after completion, create one from the first returned frame only when the compact result still lacks an asset.
## Art Spec Routing
Before art generation, normalize the user's request into:
```json
{
"assetType": "character | background | prop | ui | icon | animation | video | audio",
"subject": "要生成的主体",
"style": "画风、材质、时代或参考风格",
"palette": "主色与禁用色",
"composition": "构图、镜头、姿态或布局",
"format": "比例、尺寸、分辨率、帧数或时长",
"constraints": "必须保留、禁止出现、透明或绿幕要求",
"references": ["objectKey、资源 ID 或本地文件说明"]
}
```
Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Store structured context under `generationInputs.artSpec` where supported and summarize it in the prompt when useful.
## Intent Map
| User intent | MCP/REST capability |
| --- | --- |
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
| Redraw, retouch, or replace an existing image | Image edit |
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
| Extract marked assets from an existing UI design | UI design asset extraction |
| Animate a character into frames | Character animation generation |
| Generate video | Video generation |
| Generate a sound effect | Sound-effect generation |
| Generate background music/BGM | Background-music generation |
| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm |
| Save viewport/layers | Canvas save |
| Create, load, rename, or delete a canvas | Project operations |
| Organize folders and asset records | Asset-library operations |
| Obtain temporary access to private media | Signed read URL |
| Check generation progress or retrieve its result | Generation query |
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
## Route-Specific Decisions
- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
## AI Game Creator Canonical Visual DAG
Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceImageSrc` plus concrete `iconDescriptions`.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
## Scope Boundary
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
@@ -0,0 +1,236 @@
# Requests and Outputs
Use this reference to build generation payloads, carry canvas/library context, poll asynchronous jobs, and interpret compact completed results. Verify exact schemas against `GET /api/external/v1/openapi.json`.
## Contents
- [Asynchronous Submission](#asynchronous-submission)
- [Polling State Machine](#polling-state-machine)
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
- [Art Spec and Image Request](#art-spec-and-image-request)
- [Local Reference Requests](#local-reference-requests)
- [Compact Completed Result](#compact-completed-result)
- [Warning Semantics](#warning-semantics)
- [Output Handling Checklist](#output-handling-checklist)
## Asynchronous Submission
All eight generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
```json
{
"operationId": "task-...",
"kind": "editor_image_generation",
"status": "queued",
"statusUrl": "/api/external/v1/generations/task-...",
"pollAfterMs": 1500,
"updatedAtMicros": 1785456000000000
}
```
The response acknowledges durable submission only. It is never the completed media response.
Submit with one stable key per logical request:
```bash
api="https://www.genarrative.world"
credentials_file="$HOME/.config/genarrative/external-editor-api.json"
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")"
idempotency_key="$(node -e 'process.stdout.write(require("node:crypto").randomUUID())')"
submission="$(curl -fsS "$api/api/external/v1/editor/images/generations" \
-H "Authorization: Bearer $api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $idempotency_key" \
-d @request.json)"
operation_id="$(node -e 'const v=JSON.parse(process.argv[1]); process.stdout.write(v.operationId || v.data?.operationId || "")' "$submission")"
```
Persist the key, exact request body, and `operationId`. If submission outcome is uncertain, reuse the same body and key; do not submit a replacement key.
## Polling State Machine
Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
- `completed`: consume the compact `result` and all warning fields, then stop polling.
- `failed`: surface the safe `error`, stop polling, and do not infer provider or worker internals.
A caller-side timeout leaves the operation pending. Persist the ID for later query. Do not keep the original POST connection open and do not infer failure from a local wait budget.
The helper's convenience generation methods block only in the local process while sending short submit and status requests. Its default overall wait budget is 1800 seconds. For explicit orchestration:
```python
submission = client.submit_generation(
"/api/external/v1/editor/images/generations",
request_body,
idempotency_key=stable_key,
)
operation_id = submission["operationId"]
status = client.get_generation(operation_id)
completed = client.wait_for_generation(operation_id)
```
## Canvas and Asset-Library Completion
For endpoints that support these fields, include:
- `projectId`: target canvas project.
- `assetFolderId`: folder whose label matches the canvas name.
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
- `canvasCompletion`: backend canvas placement instructions.
A minimal `canvasCompletion` is:
```json
{
"title": "素材名称",
"placeholder": {
"x": 0,
"y": 0,
"width": 1024,
"height": 576,
"originalWidth": 1024,
"originalHeight": 576
}
}
```
`dialogId` is optional. Do not reconstruct canvas state from completion results. Reload the project and asset library when complete authoritative snapshots are needed.
Character animation may complete without a direct `asset` field. To preserve the canvas/library invariant, create a library asset from the first returned frame only if the compact result lacks one. Prefer `client.animate_character(..., canvasSession=session, canvasTitle="...")`, which implements this fallback.
## Art Spec and Image Request
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
```json
{
"prompt": "一张横版幻想森林背景,适合游戏主视觉,无文字",
"aspectRatio": "16:9",
"imageSize": "1K",
"projectId": "<projectId>",
"assetFolderId": "<assetFolderId>",
"assetLabel": "森林背景",
"generationInputs": {
"artSpec": {
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿与金色光斑",
"composition": "横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": []
}
},
"canvasCompletion": {
"title": "森林背景",
"placeholder": {
"x": 0,
"y": 0,
"width": 1024,
"height": 576,
"originalWidth": 1024,
"originalHeight": 576
}
}
}
```
The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing:
- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning.
- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation.
- Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`.
- Non-string JSON values: malformed request, HTTP `400`.
Keep this field extensible. Do not impose a closed client enum beyond the server contract.
## Local Reference Requests
Upload and confirm a local file before generation, then use the stable `objectKey`:
```python
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
reference = client.upload_reference_image("/path/to/reference.png")
client.generate_image(
"基于参考图生成一张 16:9 游戏背景",
canvasSession=session,
assetLabel="参考图背景",
aspectRatio="16:9",
imageSize="1K",
referenceImageSrcs=[reference["objectKey"]],
)
```
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
```json
{
"sourceLayerId": "external-reference-hero",
"sourceImageSrc": "<confirmed objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
"promptText": "让角色自然呼吸并轻微转身",
"resolution": "720p",
"ratio": "9:16",
"frameCount": 40,
"durationSeconds": 5,
"model": "seedance2.0-fast"
}
```
Do not guess dimensions or pass a temporary signed read URL. See `authentication-and-safety.md` for upload and credential rules.
## Compact Completed Result
The completed `result` may contain stable artifact fields such as:
- `objectKey`, media type, dimensions, or task ID.
- `resource`, `resourceId`, or equivalent canvas reference.
- `asset`, `assetId`, or equivalent library reference.
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
- `warning` and `sliceWarning` structures.
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
## Warning Semantics
Interpret warnings only after the query reaches `status=completed`. The query-level `warning` is display-ready text. Compact `result.warning` and `result.sliceWarning` preserve structured artifact semantics.
### Source-preserved post-processing failure
When `result.warning.code` is `postprocess-failed-source-preserved`:
- Treat the saved provider source as the authoritative main result.
- For character output, do not claim a transparent derivative.
- For icon spritesheet or UI extraction, do not claim a transparent spritesheet or individual slices.
- Display the safe reason.
- Do not fabricate derivatives or restart generation automatically.
Use `resource` / `asset` for character results and `spritesheetResource` / `spritesheetAsset` for icon/UI results, then reload authoritative project/library state.
### Slice failure after transparent-sheet success
`result.sliceWarning` means transparent spritesheet post-processing succeeded but automatic splitting failed:
- Continue using the complete transparent spritesheet.
- Do not claim individual slices.
- Display the slice reason.
### Coexisting warnings
`warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that path never reaches slicing. A general warning from unsupported style normalization or pixel-art snapping can coexist with `sliceWarning`. Render both reasons.
Before registering a requested transparent deliverable, verify the full sheet actually contains transparency. If source-preserved warning is present, do not register the opaque provider source as the requested transparent atlas. If only `sliceWarning` is present, the transparent full sheet remains valid.
## Output Handling Checklist
1. Require terminal `completed` before consuming artifacts.
2. Preserve stable IDs and `objectKey` values.
3. Surface all warning channels without downgrading completion to failure.
4. Avoid claiming absent transparent derivatives or slices.
5. Obtain temporary preview/download URLs only through `/assets/read-url`.
6. Reload authoritative project and library state when downstream logic needs complete records.
@@ -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")
+1
View File
@@ -45,6 +45,7 @@
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facadeHTTP/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`
@@ -1485,8 +1485,15 @@ for (const snippet of [
'assertSafeGameCreatorConfigDestination',
'readGameCreatorWizardConfigState',
'$security.SetAccessRuleProtection($true, $false)',
'$targetItem = Get-Item -LiteralPath $target -Force',
'$targetItem.SetAccessControl($security)',
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"runChildCapture('powershell.exe'",
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
'await secureWindowsPath(temporaryPath, { isDirectory: false })',
'await temporaryFile.writeFile',
]) {
@@ -1496,6 +1503,11 @@ for (const snippet of [
);
}
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
);
}
await runConfigWizardRegressionChecks();
await runHiddenInputRegressionChecks();
@@ -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,
},
});
}
@@ -51,9 +51,10 @@ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
[System.Security.AccessControl.AccessControlType]::Allow
)
$security.AddAccessRule($rule) | Out-Null
(Get-Item -LiteralPath $target -Force).SetAccessControl($security)
$targetItem = Get-Item -LiteralPath $target -Force
$targetItem.SetAccessControl($security)
$verified = Get-Acl -LiteralPath $target
$verified = $targetItem.GetAccessControl()
$owner = $verified.GetOwner([System.Security.Principal.SecurityIdentifier])
$rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))
if (-not $owner.Equals($currentSid) -or -not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) {
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
@@ -300,16 +300,69 @@ function stopChild(child, signal = 'SIGTERM') {
}
}
function isProcessGroupAlive(processGroupId, killImpl = process.kill) {
function readLinuxProcessGroupAlive(
processGroupId,
{ readdirImpl = readdirSync, readFileImpl = readFileSync } = {},
) {
let processIds;
try {
processIds = readdirImpl('/proc');
} catch {
return null;
}
for (const processId of processIds) {
if (!/^\d+$/.test(processId)) {
continue;
}
let stat;
try {
stat = readFileImpl(`/proc/${processId}/stat`, 'utf8');
} catch {
continue;
}
const commandEnd = stat.lastIndexOf(') ');
if (commandEnd < 0) {
continue;
}
const [state, , processGroup] = stat
.slice(commandEnd + 2)
.trim()
.split(/\s+/);
if (
Number(processGroup) === processGroupId &&
state !== 'Z' &&
state !== 'X'
) {
return true;
}
}
return false;
}
function isProcessGroupAlive(
processGroupId,
{
platform = process.platform,
killImpl = process.kill,
readLinuxGroupAlive = readLinuxProcessGroupAlive,
} = {},
) {
if (!Number.isInteger(processGroupId)) {
return false;
}
try {
killImpl(-processGroupId, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
if (platform === 'linux') {
const linuxGroupAlive = readLinuxGroupAlive(processGroupId);
if (typeof linuxGroupAlive === 'boolean') {
return linuxGroupAlive;
}
}
return true;
}
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
@@ -415,7 +468,7 @@ async function terminateChildTree(
stopChild(child, 'SIGTERM');
if (
await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
gracefulTimeoutMs,
)
) {
@@ -430,7 +483,7 @@ async function terminateChildTree(
}
}
const stopped = await waitUntil(
() => !isProcessGroupAlive(processGroupId, killImpl),
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
forceTimeoutMs,
);
return { stopped, forced: true };
@@ -600,8 +653,10 @@ export {
ensureBackend,
formatChildFailure,
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readChildFailure,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
+1
View File
@@ -1507,6 +1507,7 @@ dependencies = [
"tokio",
"unicode-normalization",
"url",
"uuid",
"windows-sys 0.61.2",
"zip",
]
@@ -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"
@@ -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<String> {
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<serde_json::Value, String> {
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::<String>();
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!(
"平台图片生成任务已完成但响应缺少 resultoperationId={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!(
"平台图片生成任务状态响应缺少 statusoperationId={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<reqwest::Response, String> {
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::<serde_json::Value>()
.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::<usize>().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(
@@ -811,7 +811,101 @@ fn plan_update_schema() -> Value {
})
}
fn action_function_parameters(input_schema: Value) -> Value {
fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) {
let Value::Object(object) = value else {
return;
};
// `$id` 会建立独立 schema resource;其内部 fragment 应继续相对该 resource
// 解析,不能按外层 function parameters 根重定位。
let has_local_resource_id = has_local_resource_id || object.contains_key("$id");
let reference = object
.get("$ref")
.and_then(Value::as_str)
.map(ToString::to_string);
if let Some(reference) = reference {
// 只有空 fragment 和 JSON Pointer fragment 相对当前 document 根。
// `#Mode` 是命名 anchor,外部 URI 也有自己的解析范围,必须保持原样。
if !has_local_resource_id && (reference == "#" || reference.starts_with("#/")) {
let rebased = if reference == "#" {
"#/properties/input".to_string()
} else {
format!("#/properties/input{}", &reference[1..])
};
object.insert("$ref".to_string(), Value::String(rebased));
}
}
// 只进入 JSON Schema 明确定义为 subschema 的位置。default、const、examples、
// enum 等关键词承载普通 JSON 数据,其中即使出现 `$ref` 也不能改写。
for keyword in [
"additionalProperties",
"unevaluatedProperties",
"propertyNames",
"additionalItems",
"unevaluatedItems",
"contains",
"not",
"if",
"then",
"else",
"contentSchema",
] {
if let Some(child) = object.get_mut(keyword) {
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
}
}
for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] {
if let Some(Value::Array(children)) = object.get_mut(keyword) {
for child in children {
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
}
}
}
// draft-07 的 tuple validation 允许 items 为 schema 数组;新版本则为单 schema。
if let Some(items) = object.get_mut("items") {
match items {
Value::Array(children) => {
for child in children {
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
}
}
child => rebase_action_input_schema_refs_in_scope(child, has_local_resource_id),
}
}
for keyword in [
"$defs",
"definitions",
"properties",
"patternProperties",
"dependentSchemas",
] {
if let Some(Value::Object(children)) = object.get_mut(keyword) {
for child in children.values_mut() {
rebase_action_input_schema_refs_in_scope(child, has_local_resource_id);
}
}
}
// draft-07 dependencies 的 value 可能是 subschema,也可能是属性名数组。
if let Some(Value::Object(dependencies)) = object.get_mut("dependencies") {
for dependency in dependencies.values_mut().filter(|value| value.is_object()) {
rebase_action_input_schema_refs_in_scope(dependency, has_local_resource_id);
}
}
}
fn rebase_action_input_schema_refs(value: &mut Value) {
rebase_action_input_schema_refs_in_scope(value, false);
}
fn action_function_parameters(mut input_schema: Value) -> Value {
// MCP 的 input schema 会被包进 action.input。局部 JSON Pointer 仍从整个
// function parameters 根解析,因此必须同步重定位;否则 #/$defs/... 会悬空。
rebase_action_input_schema_refs(&mut input_schema);
json!({
"type": "object",
"required": ["reason", "input"],
@@ -1369,6 +1463,108 @@ mod tests {
assert!(issues.is_empty(), "{}", issues.join("\n"));
}
#[test]
fn action_function_parameters_rebases_local_schema_refs_after_wrapping() {
let parameters = action_function_parameters(json!({
"type": "object",
"$defs": {
"Mode": {"type": "string", "enum": ["fast", "safe"]},
"Options": {
"type": "object",
"properties": {"mode": {"$ref": "#/$defs/Mode"}},
"required": ["mode"],
"additionalProperties": false
}
},
"properties": {
"options": {"$ref": "#/$defs/Options"},
"recursive": {"$ref": "#"},
"anchor": {"$ref": "#Mode"},
"scoped": {
"$id": "nested.json",
"$defs": {"Value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/Value"}}
},
"external": {"$ref": "https://schemas.example/tool.json"}
},
"required": ["options"],
"additionalProperties": false
}));
let input = &parameters["properties"]["input"];
assert_eq!(
input["properties"]["options"]["$ref"],
"#/properties/input/$defs/Options"
);
assert_eq!(
input["$defs"]["Options"]["properties"]["mode"]["$ref"],
"#/properties/input/$defs/Mode"
);
assert_eq!(
input["properties"]["recursive"]["$ref"],
"#/properties/input"
);
assert_eq!(input["properties"]["anchor"]["$ref"], "#Mode");
assert_eq!(
input["properties"]["scoped"]["properties"]["value"]["$ref"],
"#/$defs/Value"
);
assert_eq!(
input["properties"]["external"]["$ref"],
"https://schemas.example/tool.json"
);
for reference in [
input["properties"]["options"]["$ref"]
.as_str()
.expect("options ref"),
input["$defs"]["Options"]["properties"]["mode"]["$ref"]
.as_str()
.expect("mode ref"),
input["properties"]["recursive"]["$ref"]
.as_str()
.expect("recursive ref"),
] {
assert!(
parameters
.pointer(reference.trim_start_matches('#'))
.is_some(),
"rebased ref must resolve: {reference}"
);
}
}
#[test]
fn action_function_parameters_preserves_refs_inside_schema_data_keywords() {
let parameters = action_function_parameters(json!({
"type": "object",
"$defs": {
"Value": {"type": "string"}
},
"properties": {
"value": {
"$ref": "#/$defs/Value",
"default": {"$ref": "#/literal-default"},
"const": {
"nested": [{"$ref": "#/literal-const"}]
},
"examples": [
{"$ref": "#/literal-example"},
[{"$ref": "#/nested-literal-example"}]
]
}
},
"required": ["value"],
"additionalProperties": false
}));
let value = &parameters["properties"]["input"]["properties"]["value"];
assert_eq!(value["$ref"], "#/properties/input/$defs/Value");
assert_eq!(value["default"]["$ref"], "#/literal-default");
assert_eq!(value["const"]["nested"][0]["$ref"], "#/literal-const");
assert_eq!(value["examples"][0]["$ref"], "#/literal-example");
assert_eq!(value["examples"][1][0]["$ref"], "#/nested-literal-example");
}
#[test]
fn native_project_patchset_normalizes_nullable_strict_shape() {
let arguments = json!({
@@ -53,7 +53,7 @@ fn build_game_creator_platform_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmConfig, String> {
validate_game_creator_llm_web_search_config(llm, config_path)?;
let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?;
let api_key =
trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?;
let base_url =
@@ -61,6 +61,8 @@ fn build_game_creator_platform_llm_config(
let model =
trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?;
validate_game_creator_llm_timing_config(llm, config_path)?;
let anthropic_strict_tool_support =
game_creator_supports_anthropic_strict_tools(api_kind, &base_url, &model);
LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
@@ -70,9 +72,54 @@ fn build_game_creator_platform_llm_config(
llm.max_retries,
llm.retry_backoff_ms,
)
.map(|config| config.with_anthropic_strict_tool_support(anthropic_strict_tool_support))
.map_err(|error| format!("LLM 配置无效:{error}"))
}
fn game_creator_supports_anthropic_strict_tools(
api_kind: LlmApiKind,
base_url: &str,
model: &str,
) -> bool {
if api_kind != LlmApiKind::Anthropic {
return false;
}
// 兼容网关即使复用了 Anthropic messages 协议,也不能据此推断 structured
// outputs 能力。只对无凭据、无自定义端口/路径的官方 HTTPS endpoint 开启。
let Ok(endpoint) = url::Url::parse(base_url) else {
return false;
};
if endpoint.scheme() != "https"
|| endpoint.host_str() != Some("api.anthropic.com")
|| endpoint.port().is_some()
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.path() != "/"
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
{
return false;
}
// Claude API 的 structured outputs 从 Claude 4.5 起可用。仅识别官方 Claude
// family 的版本化 model id;不凭 `latest`、第三方别名或未知产品名猜能力。
let normalized = model.trim().to_ascii_lowercase();
let mut parts = normalized.split('-');
if parts.next() != Some("claude") || !matches!(parts.next(), Some("opus" | "sonnet" | "haiku"))
{
return false;
}
let Some(major) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return false;
};
let minor = parts
.next()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(0);
major > 4 || (major == 4 && minor >= 5)
}
pub(crate) fn build_game_creator_llm_client_from_config() -> Result<LlmClient, String> {
let app_config = load_game_creator_app_config()?;
build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm")
@@ -174,7 +221,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
error: Some(error),
agents: Vec::new(),
}
};
}
};
let global_route_shape_error =
@@ -1586,3 +1633,54 @@ pub(crate) fn game_creator_config_file_label(file_name: &str) -> String {
.map(|directory| directory.join(file_name).display().to_string())
.unwrap_or_else(|| file_name.to_string())
}
#[cfg(test)]
mod anthropic_strict_capability_tests {
use super::*;
fn anthropic_config(base_url: &str, model: &str) -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
api_key: "test-key".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
api_kind: "anthropic".to_string(),
web_search_enabled: false,
..GameCreatorLlmConfig::default()
}
}
#[test]
fn official_supported_claude_model_opts_in_to_anthropic_strict_tools() {
for model in [
"claude-sonnet-4-5-20250929",
"claude-opus-4-6",
"claude-haiku-5",
] {
let config = build_game_creator_platform_llm_config(
&anthropic_config("https://api.anthropic.com", model),
"llm",
)
.expect("supported official Anthropic config");
assert!(config.anthropic_strict_tool_support(), "model={model}");
}
}
#[test]
fn old_models_and_compatible_or_lookalike_endpoints_keep_strict_disabled() {
for (base_url, model) in [
("https://api.anthropic.com", "claude-3-5-sonnet-latest"),
("https://api.anthropic.com", "claude-sonnet-latest"),
("https://minimax.example.com", "claude-sonnet-4-5"),
("https://api.anthropic.com.example.com", "claude-sonnet-4-5"),
("http://api.anthropic.com", "claude-sonnet-4-5"),
] {
let config =
build_game_creator_platform_llm_config(&anthropic_config(base_url, model), "llm")
.expect("non-capable Anthropic config remains usable without strict");
assert!(
!config.anthropic_strict_tool_support(),
"base_url={base_url}, model={model}"
);
}
}
}
+166 -49
View File
@@ -899,13 +899,47 @@ fn normalize_game_creator_mcp_catalog_tool(
})
}
fn normalize_game_creator_mcp_server_tools(
server_id: &str,
config: &GameCreatorMcpServerConfig,
listed_tools: Vec<Tool>,
) -> Result<Vec<GameCreatorMcpCatalogTool>, String> {
if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER {
return Err(format!(
"MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}",
listed_tools.len()
));
}
let mut server_tools = Vec::new();
let mut server_tool_names = BTreeSet::new();
for tool in listed_tools {
if !game_creator_mcp_tool_is_enabled(config, tool.name.as_ref()) {
continue;
}
if tool.task_support() == TaskSupport::Required {
return Err(format!(
"MCP tool {server_id}/{} 要求 task-mode,当前切片未支持",
tool.name
));
}
if !server_tool_names.insert(tool.name.to_string()) {
return Err(format!(
"MCP server {server_id} 返回重复 tool identity{}",
tool.name
));
}
server_tools.push(normalize_game_creator_mcp_catalog_tool(
server_id, config, tool,
)?);
}
server_tools.sort_by(|left, right| left.name.cmp(&right.name));
Ok(server_tools)
}
pub(crate) async fn read_game_creator_mcp_catalog_at(
root: &Path,
) -> Result<GameCreatorMcpCatalog, String> {
let config = load_game_creator_app_config()?;
let mut servers = Vec::new();
let mut tools = Vec::new();
let mut catalog_identity = Vec::new();
let server_reads = config
.mcp_servers
.into_iter()
@@ -999,37 +1033,33 @@ pub(crate) async fn read_game_creator_mcp_catalog_at(
));
}
};
if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER {
return Err(format!(
"MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}",
listed_tools.len()
));
}
let mut server_tools = Vec::new();
let mut server_tool_names = BTreeSet::new();
for tool in listed_tools {
if !game_creator_mcp_tool_is_enabled(&server_config, tool.name.as_ref()) {
continue;
}
if tool.task_support() == TaskSupport::Required {
return Err(format!(
"MCP tool {server_id}/{} 要求 task-mode,当前切片未支持",
tool.name
let server_tools = match normalize_game_creator_mcp_server_tools(
&server_id,
&server_config,
listed_tools,
) {
Ok(server_tools) => server_tools,
Err(error) if server_config.required => return Err(error),
Err(error) => {
return Ok((
GameCreatorMcpServerStatus {
server_id,
enabled: true,
required: false,
transport: server_config.transport,
connected: false,
server_name,
server_version,
instructions: instructions.clone(),
instructions_chars: instructions.chars().count(),
tool_count: 0,
error: Some(sanitize_game_creator_mcp_error(root, &error, 240)),
},
Vec::new(),
None,
));
}
if !server_tool_names.insert(tool.name.to_string()) {
return Err(format!(
"MCP server {server_id} 返回重复 tool identity{}",
tool.name
));
}
server_tools.push(normalize_game_creator_mcp_catalog_tool(
&server_id,
&server_config,
tool,
)?);
}
server_tools.sort_by(|left, right| left.name.cmp(&right.name));
};
let catalog_identity = serde_json::json!({
"serverId": server_id,
"configFingerprint": entry.config_fingerprint,
@@ -1054,25 +1084,117 @@ pub(crate) async fn read_game_creator_mcp_catalog_at(
drop(entry);
Ok((status, server_tools, Some(catalog_identity)))
});
let mut server_results = Vec::new();
for server_read in futures::future::join_all(server_reads).await {
let (server, server_tools, identity) = server_read?;
servers.push(server);
tools.extend(server_tools);
if let Some(identity) = identity {
catalog_identity.push(identity);
server_results.push(server_read?);
}
let collect_catalog_parts =
|results: &[(
GameCreatorMcpServerStatus,
Vec<GameCreatorMcpCatalogTool>,
Option<serde_json::Value>,
)],
included_optional_servers: &BTreeSet<String>| {
let mut candidate_servers = Vec::with_capacity(results.len());
let mut candidate_tools = Vec::new();
let mut candidate_identity = Vec::new();
for (status, server_tools, identity) in results {
let included = status.required
|| included_optional_servers.contains(status.server_id.as_str());
let mut candidate_status = status.clone();
if !included && candidate_status.connected {
candidate_status.connected = false;
candidate_status.tool_count = 0;
}
candidate_servers.push(candidate_status);
if included && status.connected {
candidate_tools.extend(server_tools.iter().cloned());
if let Some(identity) = identity {
candidate_identity.push(identity.clone());
}
}
}
(candidate_servers, candidate_tools, candidate_identity)
};
let catalog_prompt_bytes = |candidate_servers: Vec<GameCreatorMcpServerStatus>,
candidate_tools: Vec<GameCreatorMcpCatalogTool>,
candidate_identity: &[serde_json::Value]|
-> Result<usize, String> {
let candidate = GameCreatorMcpCatalog {
fingerprint: game_creator_mcp_sha256(candidate_identity)?,
servers: candidate_servers,
tools: candidate_tools,
};
Ok(render_game_creator_mcp_catalog_for_prompt(&candidate)?
.into_bytes()
.len())
};
let mut included_optional_servers = BTreeSet::new();
let (required_servers, required_tools, required_identity) =
collect_catalog_parts(&server_results, &included_optional_servers);
if required_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS {
return Err(format!(
"required MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}",
required_tools.len()
));
}
let required_catalog_bytes =
catalog_prompt_bytes(required_servers, required_tools, &required_identity)?;
if required_catalog_bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES {
return Err(format!(
"required MCP catalog 为 {required_catalog_bytes} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}"
));
}
for index in 0..server_results.len() {
let status = &server_results[index].0;
if status.required || !status.connected {
continue;
}
let server_id = status.server_id.clone();
included_optional_servers.insert(server_id.clone());
let (candidate_servers, candidate_tools, candidate_identity) =
collect_catalog_parts(&server_results, &included_optional_servers);
let candidate_tool_count = candidate_tools.len();
let candidate_bytes = if candidate_tool_count <= GAME_CREATOR_MCP_MAX_TOOLS {
Some(catalog_prompt_bytes(
candidate_servers,
candidate_tools,
&candidate_identity,
)?)
} else {
None
};
let capacity_error = if candidate_tool_count > GAME_CREATOR_MCP_MAX_TOOLS {
Some(format!(
"MCP server {server_id} 使目录工具总数达到 {candidate_tool_count},超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}"
))
} else if candidate_bytes.is_some_and(|bytes| bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES) {
Some(format!(
"MCP server {server_id} 使目录超过 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES} bytes 上限"
))
} else {
None
};
if let Some(error) = capacity_error {
included_optional_servers.remove(server_id.as_str());
let status = &mut server_results[index].0;
status.connected = false;
status.tool_count = 0;
status.error = Some(sanitize_game_creator_mcp_error(root, &error, 240));
}
}
let (servers, mut tools, catalog_identity) =
collect_catalog_parts(&server_results, &included_optional_servers);
tools.sort_by(|left, right| {
left.server_id
.cmp(&right.server_id)
.then_with(|| left.name.cmp(&right.name))
});
if tools.len() > GAME_CREATOR_MCP_MAX_TOOLS {
return Err(format!(
"MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}",
tools.len()
));
}
let fingerprint = game_creator_mcp_sha256(&catalog_identity)?;
let catalog = GameCreatorMcpCatalog {
fingerprint,
@@ -1080,12 +1202,7 @@ pub(crate) async fn read_game_creator_mcp_catalog_at(
tools,
};
let catalog_bytes = render_game_creator_mcp_catalog_for_prompt(&catalog)?.into_bytes();
if catalog_bytes.len() > GAME_CREATOR_MCP_MAX_CATALOG_BYTES {
return Err(format!(
"MCP catalog 为 {} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}",
catalog_bytes.len()
));
}
debug_assert!(catalog_bytes.len() <= GAME_CREATOR_MCP_MAX_CATALOG_BYTES);
Ok(catalog)
}
@@ -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<String> = 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::<serde_json::Value>(
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<mpsc::Sender<String>>,
) -> 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),
)
@@ -791,7 +791,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))
@@ -804,6 +804,22 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
})
.expect("canvas generation request");
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("POST /api/external/v1/editor/"))
.count(),
1,
"queued/running polling must not submit generation again"
);
assert_eq!(
canvas_requests
.iter()
.filter(|request| request.starts_with("GET /api/external/v1/generations/"))
.count(),
3,
"fixture should exercise queued, running, and completed states"
);
for expected in [
r#""referenceImageSrc":"resource-icon-spec""#,
r#""iconDescriptions":"#,
@@ -850,7 +866,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))
@@ -222,6 +222,120 @@ async fn mcp_optional_tools_list_failure_is_bounded_but_required_fails() {
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn mcp_optional_invalid_tool_catalog_is_isolated_but_required_fails() {
let root = unique_project_path();
init_local_game_project_at(&root, "mcp-invalid-catalog", "MCP 非法目录项目")
.expect("initialize invalid MCP catalog project");
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create invalid MCP catalog config dir");
let config_guard = use_test_runtime_config_dir(config_dir.clone());
for (fixture_arg, expected_error) in [
("--oversized-input-schema", "input schema 超过上限"),
("--duplicate-tool", "重复 tool identity"),
("--require-task-mode", "要求 task-mode"),
] {
let fixture = serde_json::json!({
"required": false,
"transport": "stdio",
"command": "node",
"args": [mcp_fixture_script_path(), "stdio", fixture_arg]
});
write_mcp_transport_test_config(&config_dir, "optional-invalid-fixture", fixture.clone());
let catalog = read_game_creator_mcp_catalog_at(&root)
.await
.expect("optional invalid tool catalog must stay in server status");
assert!(catalog.tools.is_empty());
let server = catalog.servers.first().expect("optional invalid status");
assert!(!server.connected);
assert!(
server
.error
.as_deref()
.is_some_and(|error| { error.contains(expected_error) }),
"fixture={fixture_arg} status={server:?}"
);
shutdown_game_creator_mcp_clients_for_tests().await;
let mut required_fixture = fixture;
required_fixture["required"] = serde_json::Value::Bool(true);
write_mcp_transport_test_config(&config_dir, "required-invalid-fixture", required_fixture);
let error = read_game_creator_mcp_catalog_at(&root)
.await
.expect_err("required invalid tool catalog must fail the catalog");
assert!(
error.contains(expected_error),
"fixture={fixture_arg} error={error}"
);
shutdown_game_creator_mcp_clients_for_tests().await;
}
drop(config_guard);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn mcp_optional_server_is_isolated_when_aggregate_catalog_exceeds_tool_limit() {
let root = unique_project_path();
init_local_game_project_at(&root, "mcp-aggregate-limit", "MCP 聚合上限项目")
.expect("initialize MCP aggregate limit project");
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create MCP aggregate limit config dir");
let config_guard = use_test_runtime_config_dir(config_dir.clone());
let fixture = mcp_fixture_script_path();
let config = serde_json::json!({
"mcpServers": {
"required-alpha": {
"required": true,
"transport": "stdio",
"command": "node",
"args": [fixture, "stdio", "--tool-count=64"]
},
"required-beta": {
"required": true,
"transport": "stdio",
"command": "node",
"args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"]
},
"optional-gamma": {
"required": false,
"transport": "stdio",
"command": "node",
"args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"]
}
}
});
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::to_vec_pretty(&config).expect("serialize MCP aggregate limit config"),
)
.expect("write MCP aggregate limit config");
let catalog = read_game_creator_mcp_catalog_at(&root)
.await
.expect("optional aggregate overflow must stay in server status");
assert_eq!(catalog.tools.len(), 128);
let optional = catalog
.servers
.iter()
.find(|server| server.server_id == "optional-gamma")
.expect("optional aggregate overflow status");
assert!(!optional.connected);
assert_eq!(optional.tool_count, 0);
assert!(optional
.error
.as_deref()
.is_some_and(|error| error.contains("工具总数") && error.contains("超过上限")));
shutdown_game_creator_mcp_clients_for_tests().await;
drop(config_guard);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[tokio::test]
async fn mcp_catalog_refreshes_independent_servers_in_parallel() {
let root = unique_project_path();
@@ -6,6 +6,13 @@ const args = process.argv.slice(2);
const mode = args[0] ?? 'stdio';
const failList = args.includes('--fail-list');
const includeUnannotated = args.includes('--include-unannotated');
const duplicateTool = args.includes('--duplicate-tool');
const oversizedInputSchema = args.includes('--oversized-input-schema');
const requireTaskMode = args.includes('--require-task-mode');
const toolCountArgument = args.find((value) => value.startsWith('--tool-count='));
const toolCount = toolCountArgument
? Number(toolCountArgument.slice('--tool-count='.length))
: null;
const listDelayArgument = args.find((value) =>
value.startsWith('--list-delay-ms='),
);
@@ -89,6 +96,28 @@ const tools = [
},
];
if (duplicateTool) {
tools.push({ ...tools[0] });
}
if (oversizedInputSchema) {
tools[0].inputSchema.properties.query.description = 'x'.repeat(70 * 1024);
}
if (requireTaskMode) {
tools[0].execution = { taskSupport: 'required' };
}
if (Number.isInteger(toolCount) && toolCount > tools.length) {
for (let index = tools.length; index < toolCount; index += 1) {
tools.push({
...tools[0],
name: `lookup-${index}`,
title: `Lookup ${index}`,
});
}
}
if (includeUnannotated) {
tools.push({
name: 'mutate-unannotated',
@@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest';
import {
ensureBackend,
isProcessGroupAlive,
preflightExistingVite,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
@@ -91,6 +93,36 @@ describe('AI 游戏创作配套后端复用门禁', () => {
describe('AI 游戏创作启动子进程生命周期', () => {
const posixTest = process.platform === 'win32' ? test.skip : test;
test('Linux 进程组只剩僵尸进程时视为已经停止', () => {
const procStats = new Map([
['/proc/101/stat', '101 (node worker) Z 1 700 700 0'],
['/proc/102/stat', '102 (other worker) S 1 701 701 0'],
]);
const readLinuxGroupAlive = (processGroupId: number) =>
readLinuxProcessGroupAlive(processGroupId, {
readdirImpl: () => ['101', '102', 'not-a-pid'],
readFileImpl: (path: string) => {
const stat = procStats.get(path);
if (!stat) {
throw new Error('missing proc stat fixture');
}
return stat;
},
});
const killImpl = vi.fn();
expect(readLinuxGroupAlive(700)).toBe(false);
expect(readLinuxGroupAlive(701)).toBe(true);
expect(
isProcessGroupAlive(700, {
platform: 'linux',
killImpl,
readLinuxGroupAlive,
}),
).toBe(false);
expect(killImpl).toHaveBeenCalledWith(-700, 0);
});
posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => {
const child = spawnChild('genarrative-command-that-does-not-exist', [], {
cwd: process.cwd(),
+2
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More