合并外部API MCP与异步生成能力
基于最新master接入托管式Streamable HTTP MCP并复用External API Key鉴权 统一External v1生成任务的幂等异步提交与状态查询 同步完整Skill资源、OpenAPI契约、AI游戏客户端与Worker适配 保留game-chat生命周期与首轮调度约定并完成重基验证
This commit is contained in:
@@ -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 controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`.
|
||||
|
||||
For Python callers, prefer:
|
||||
For a canvas-backed generation:
|
||||
|
||||
```python
|
||||
from genarrative_external_api import GenarrativeExternalClient
|
||||
|
||||
client = GenarrativeExternalClient()
|
||||
session = client.prepare_canvas_session("新画板")
|
||||
art_spec = {
|
||||
"assetType": "background",
|
||||
"subject": "幻想森林主视觉",
|
||||
"style": "手绘游戏概念图",
|
||||
"palette": "翡翠绿、金色光斑,避免低饱和灰",
|
||||
"composition": "16:9 横版,中心留出角色站位",
|
||||
"format": "16:9, 1K",
|
||||
"constraints": "无文字、无 UI 按钮",
|
||||
"references": [],
|
||||
}
|
||||
client.generate_image(
|
||||
"生成幻想森林背景",
|
||||
"生成一张 16:9 幻想森林游戏背景",
|
||||
canvasSession=session,
|
||||
assetLabel="森林背景",
|
||||
aspectRatio="16:9",
|
||||
imageSize="1K",
|
||||
artSpec=art_spec,
|
||||
artSpec={
|
||||
"assetType": "background",
|
||||
"subject": "幻想森林主视觉",
|
||||
"style": "手绘游戏概念图",
|
||||
"palette": "翡翠绿与金色光斑",
|
||||
"composition": "横版,中心留出角色站位",
|
||||
"format": "16:9, 1K",
|
||||
"constraints": "无文字、无 UI 按钮",
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
|
||||
|
||||
```python
|
||||
client.generate_icon_spritesheet(
|
||||
"editor-resource-current-art-spec",
|
||||
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
|
||||
canvasSession=session,
|
||||
assetLabel="贪吃蛇透明图集",
|
||||
screenColor="auto",
|
||||
)
|
||||
```
|
||||
|
||||
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
|
||||
|
||||
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
|
||||
|
||||
Use this shared base:
|
||||
|
||||
```bash
|
||||
api="https://www.genarrative.world"
|
||||
credentials_file="$HOME/.config/genarrative/external-editor-api.json"
|
||||
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")"
|
||||
auth=(-H "Authorization: Bearer $api_key")
|
||||
json=(-H "Content-Type: application/json")
|
||||
```
|
||||
|
||||
Create a project:
|
||||
|
||||
```bash
|
||||
curl -fsS "$api/api/external/v1/editor/projects" \
|
||||
"${auth[@]}" "${json[@]}" \
|
||||
-d '{"title":"新画板"}'
|
||||
```
|
||||
|
||||
Generate an image and save it into both the canvas and the asset-library folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "一张横版幻想森林背景,适合游戏主视觉",
|
||||
"kind": "spec",
|
||||
"aspectRatio": "16:9",
|
||||
"imageSize": "1K",
|
||||
"projectId": "<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,239 +0,0 @@
|
||||
# External Editor API Routing
|
||||
|
||||
Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
|
||||
|
||||
## Base
|
||||
|
||||
- Fixed base URL: `https://www.genarrative.world/`.
|
||||
- Public contract: `GET /api/external/v1/openapi.json`.
|
||||
- Authenticated calls: `Authorization: Bearer <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 deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting.
|
||||
|
||||
- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning.
|
||||
- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation.
|
||||
- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`.
|
||||
- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum.
|
||||
|
||||
Image or character generation with pixel-art snapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "生成一个正面站立的像素风冒险者角色",
|
||||
"kind": "character",
|
||||
"style": "pixelArt"
|
||||
}
|
||||
```
|
||||
|
||||
Icon spritesheet generation with pixel-art snapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png",
|
||||
"iconDescriptions": ["木剑", "圆盾", "红色药水"],
|
||||
"style": "pixelArt"
|
||||
}
|
||||
```
|
||||
|
||||
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
|
||||
|
||||
## HTTP 2xx Warning Handling
|
||||
|
||||
Character image generation (including character redraw through `kind: "character"`), icon spritesheet generation, and UI asset extraction may return HTTP 2xx while carrying a structured `warning`; completion does not imply that all post-processed derivatives exist.
|
||||
|
||||
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
|
||||
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
|
||||
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
|
||||
|
||||
## Reference Image Upload
|
||||
|
||||
If the user provides a local file as a reference image, run upload before the generation request:
|
||||
|
||||
1. `POST /api/external/v1/assets/direct-upload-tickets`.
|
||||
Use `legacyPrefix: "generated-character-drafts"`, `pathSegments: ["editor", "external-editor-references"]`, original `fileName`, detected image `contentType`, and `access: "private"`.
|
||||
2. Upload the file to the returned OSS form endpoint with all returned `formFields`.
|
||||
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey`, detected `contentType`, `contentLength` if known, `assetKind: "editor_reference_image"`, and `accessPolicy: "private"`.
|
||||
4. Use the returned `objectKey` in the actual editor request.
|
||||
|
||||
OSS form upload uses `upload.host` and every non-null `upload.formFields` entry, then the file part named `file`. Default responses expose `upload`; envelope responses expose `data.upload`. Save the upload ticket response as `ticket.json`:
|
||||
|
||||
```bash
|
||||
node - <<'NODE' ticket.json /path/to/reference.png
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const ticket = body.upload || body.data?.upload;
|
||||
if (!ticket) throw new Error('Upload ticket response missing upload payload');
|
||||
const filePath = process.argv[3];
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(ticket.formFields)) {
|
||||
if (value != null) form.append(key, value);
|
||||
}
|
||||
const bytes = fs.readFileSync(filePath);
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
|
||||
path.basename(filePath),
|
||||
);
|
||||
const response = await fetch(ticket.host, { method: 'POST', body: form });
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
```
|
||||
|
||||
Field mapping after upload:
|
||||
|
||||
| Target API | Put uploaded `objectKey` in |
|
||||
| --- | --- |
|
||||
| Image generation | `referenceImageSrcs` |
|
||||
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
|
||||
| Icon spritesheet | `referenceImageSrc`; additional style refs in `referenceImageSrcs` |
|
||||
| UI design extraction | `sourceImageSrc`; additional refs in `referenceImageSrcs` |
|
||||
| Character animation | `sourceImageSrc` |
|
||||
| Video generation with image references | `referenceImageSrcs` |
|
||||
|
||||
Do not put the signed read URL into generation fields. Signed URLs are for user-visible preview/download; generation fields should use the stable `objectKey` for uploaded private references.
|
||||
|
||||
## Common Enums
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
||||
- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`.
|
||||
- Video `resolution`: `480p`, `720p`, `1080p`.
|
||||
- Video `mode`: always `std`.
|
||||
- Video `sound`: `on`, `off`.
|
||||
- Character animation `model`: always `seedance2.0-fast`.
|
||||
- Character animation `resolution`: `480p`, `720p`; `frameCount`: `32`, `40`, `48`; `durationSeconds`: `4`, `5`, `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, `3:4`.
|
||||
|
||||
## Local Reference Media Details
|
||||
|
||||
- `contentLength` in object confirm is a JSON number from local byte size, not a string.
|
||||
- For character animation, use an existing project layer ID as `sourceLayerId` when available.
|
||||
- If the source is only an uploaded local image, derive `sourceLayerId` from the file name, such as `external-reference-hero`, and keep it stable across retries.
|
||||
- Read `sourceWidth` and `sourceHeight` from the local image. If dimensions cannot be read, ask instead of inventing dimensions.
|
||||
- UI design extraction uses fixed `aspectRatio: "1:1"`; choose `imageSize: "1K"` for normal/small extractions and `2K` for dense designs.
|
||||
- Video image/video/audio references are supported only by the Seedance 2.0 family; default referenced-media video requests to `model: "seedance2.0-fast"`, `mode: "std"`, and explicit `sound`.
|
||||
- Image edit/redraw can pass `targetLayerId` with `projectId` to replace an existing canvas layer when no explicit `canvasCompletion` is supplied.
|
||||
- Image, edit, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record.
|
||||
- Icon spritesheet and UI extraction can pass `assetFolderId`; UI extraction can also pass `spritesheetLabel`.
|
||||
|
||||
## Canvas Completion
|
||||
|
||||
Use `canvasCompletion` for generation in this skill so the generated result is written back into the project canvas by the backend.
|
||||
|
||||
Required:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "素材名称",
|
||||
"placeholder": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"originalWidth": 512,
|
||||
"originalHeight": 512
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally.
|
||||
|
||||
## Upload Flow
|
||||
|
||||
For a local file that should become a project resource or library asset:
|
||||
|
||||
1. `POST /api/external/v1/assets/direct-upload-tickets` with `legacyPrefix`, `fileName`, and optional `contentType`, `access`, `maxSizeBytes`.
|
||||
2. Submit the file to the returned OSS form endpoint with returned `formFields`.
|
||||
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey` and an `assetKind`.
|
||||
4. Create a project resource or library asset with the confirmed `assetObjectId`/`objectKey`.
|
||||
|
||||
For reading private/generated assets, call `GET /api/external/v1/assets/read-url?objectKey=...` and use the returned `signedUrl`.
|
||||
+146
@@ -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.
|
||||
+146
-33
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||
- 除 CI/CD 脚本内部受控用法外,人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`。
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -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!(
|
||||
"平台图片生成任务已完成但响应缺少 result;operationId={operation_id}"
|
||||
)
|
||||
});
|
||||
}
|
||||
Some("failed") => {
|
||||
let error = json_string_field(generation, "error")
|
||||
.or_else(|| json_string_field(generation, "phaseDetail"))
|
||||
.unwrap_or_else(|| "生成任务失败".to_string());
|
||||
return Err(format!(
|
||||
"平台图片生成任务失败:{error};operationId={operation_id}"
|
||||
));
|
||||
}
|
||||
Some("queued" | "running") => {
|
||||
poll_after_ms = external_generation_poll_after_ms(&payload);
|
||||
}
|
||||
Some(status) => {
|
||||
return Err(format!(
|
||||
"平台图片生成任务返回未知状态 {status};operationId={operation_id}"
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(format!(
|
||||
"平台图片生成任务状态响应缺少 status;operationId={operation_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_external_generation_request(
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
endpoint: &str,
|
||||
api_key: &str,
|
||||
idempotency_key: &str,
|
||||
request_body: &serde_json::Value,
|
||||
) -> Result<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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5908,3 +5908,11 @@
|
||||
- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。
|
||||
- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。
|
||||
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
|
||||
|
||||
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
|
||||
- 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。
|
||||
- MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。
|
||||
- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
|
||||
@@ -4014,3 +4014,18 @@
|
||||
- 处理:从当前 root source 的 seed lane 动态解析全部零依赖首波任务,只对这些 child 容忍 hydration `Pending`,后续 code prototype / preview 仍严格要求 Running/Completed。`streaming / ready` 仍要求当前 revision,`committed` 回复改为依据 finalization 的稳定身份查询,不随后续项目 revision 失效。
|
||||
- 验证:覆盖 `design-director / art-director / code-director` 三个 Pending 首波 child 均可投影 Completed、`code-prototype` Pending 仍被拒绝;非流式专业 Agent 在 finalization 前无 stream,提交后形成 committed stream,再推进项目 revision 后仍可查询且正文不变。
|
||||
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs`。
|
||||
## 异步生成结果未知时不能换幂等键重提(2026-07-31)
|
||||
|
||||
- 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。
|
||||
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。
|
||||
- 处理:一次逻辑生成只分配一个稳定幂等键;传输重试必须使用完全相同的请求体和原键。收到 `operationId` 后只查询 `/api/external/v1/generations/{operationId}`,调用方轮询超时不改变服务端任务状态。结果未知且尚未拿到 operationId 时也只用原键重试提交。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。
|
||||
- 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。
|
||||
- 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。
|
||||
|
||||
## api-server 嵌入仓库外资源时必须同步容器构建上下文(2026-07-31)
|
||||
|
||||
- 现象:本地 `cargo test` 可以编译 MCP 与 Skill 下载模块,但 api-server 镜像在 Rust 编译阶段报 `include_str!` 找不到 OpenAPI 或 Skill 文件。
|
||||
- 原因:本地工作树包含完整仓库,而容器 Rust builder 原先只复制 `server-rs/` 和 `public/`;crate 中向上引用的 `docs/openapi/`、`.codex/skills/` 不会自动进入镜像构建文件系统。
|
||||
- 处理:凡 api-server 通过 `include_str!` 使用仓库根目录资源,都要在 `deploy/container/api-server.Dockerfile` 的 builder 阶段显式复制对应权威目录;不要再复制一份内容到 crate 内形成平行事实源。
|
||||
- 验证:除本地 Cargo 测试外,检查 Dockerfile 构建上下文覆盖所有 `include_str!` 相对路径;新增或移动嵌入资源时同步更新容器 COPY 和接入文档。
|
||||
- 关联:`deploy/container/api-server.Dockerfile`、`server-rs/crates/api-server/src/external_mcp.rs`、`server-rs/crates/api-server/src/external_skill_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
- 涉及中文文本时注意 UTF-8 编码和乱码排查。
|
||||
- 涉及后端时遵循 DDD 分层,不把业务真相下沉到前端或临时兼容层。
|
||||
- `packages/shared` 用于前后端 DTO、公开契约及跨页面复用的无业务真相 UI 组件和纯工具;不得把领域规则、后端副作用或正式状态放入其中。
|
||||
- 修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义时,必须同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 和对应契约测试;Rust 实现与 OpenAPI 未对齐时不得完成、提交或发布。
|
||||
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求均视为历史残留,禁止新增、运行或引用;API smoke 统一使用 `npm run dev:api-server` 与 `/healthz`。
|
||||
- 涉及 SpacetimeDB 表结构、发布或迁移时,先看 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 和 `SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
- 涉及生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
|
||||
> 2026-07-21 已实施、待生产压测专题:BgFilter 作为受限内部资源,仍遵守“单用户动作一个外部生成 job”;用户可见层与调度层都只有父 `external_generation_job`。父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。
|
||||
|
||||
更新时间:`2026-07-21`
|
||||
更新时间:`2026-07-31`
|
||||
|
||||
## 背景
|
||||
|
||||
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
|
||||
VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部调用方的 HTTP 请求长期等待上游、OSS 持久化和 SpacetimeDB 回写。站内保留受控 `inline` 排障模式;External v1 的八类生成则固定使用持久队列和统一查询接口,避免调用方超时后重复提交、重复扣费或丢失已完成结果。
|
||||
|
||||
## 目标
|
||||
|
||||
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
||||
- 本地或小流量站内同步排查可显式启用 `inline` 模式,由站内 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。External v1 不继承此例外,始终异步入队。
|
||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`,跳一跳、拼消消和敲木鱼的外部图片生成动作,以及图片画布编辑器的图片、改图、手动去背景、图标 spritesheet、UI 素材提取、角色动作、视频、音效和背景音乐生成。后续玩法和编辑器生成入口继续复用同一队列 Module,不再为每个入口发明独立队列。
|
||||
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。用户可见执行阶段通过现有任务行及摘要投影的轻量 `phase` 保存,不作为队列调度单位,也不写回大 payload。
|
||||
@@ -37,6 +37,8 @@
|
||||
- `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
|
||||
- `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。
|
||||
|
||||
External API job 复用同一个 `result_payload_json` 列,但只额外保存 `result` compact 引用:允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、大布局、Data URL、Blob URL、临时 signed URL、provider 原始响应和 lease/fencing 控制字段。普通站内 job 继续保持原 payload 语义,不能为了 External 查询把所有队列结果扩成第二套资产 read model。
|
||||
|
||||
不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。
|
||||
|
||||
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
|
||||
@@ -112,6 +114,8 @@ pending/running -> cancelled (预留)
|
||||
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
||||
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
||||
|
||||
External v1 八类生成不读取上述模式分支:即使进程配置为 `inline`,External handler 仍只做校验、幂等入队并返回 HTTP `202`。调用方按 `/api/external/v1/generations/{operationId}` 查询;这条外部契约不能因部署环境不同而从异步退化为同步响应。
|
||||
|
||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||
|
||||
- `api`:只启动 HTTP server。
|
||||
@@ -207,7 +211,19 @@ controller 配置:
|
||||
|
||||
透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。
|
||||
|
||||
inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。
|
||||
inline 完成结果与 External v1 completed compact result 继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:只有一条时原样保留完整 `reason`;两条并存时按“通用在前、拆分在后”拼接,`code` 收敛为 `multiple-generation-warnings`(两条 `code` 相同则沿用原 `code`),任何一条都不得被丢弃。`sliceWarning.reason` 无论是否与通用告警并存都由 worker 添加“图集已生成,但自动拆分未完成:”前缀,拼接结果最后统一做长度上界收敛。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。
|
||||
|
||||
### External v1 异步提交与查询
|
||||
|
||||
External v1 复用上述九类 editor job kind 中除手动去背景外的八类生成 kind。外部 POST handler 只负责 API Key scope、owner、请求校验和入队,不调用 `*_for_owner` 同步执行函数:
|
||||
|
||||
1. 每个生成 POST 必须携带 `Idempotency-Key`。服务端把 owner、job kind、稳定键和规范请求纳入 dedupe;未知结果重试必须复用原键。
|
||||
2. 成功入队返回 HTTP `202`、`operationId`、`statusUrl`、`pollAfterMs`,并设置 `Location` / `Retry-After`;不返回 project、asset 或媒体结果。
|
||||
3. `GET /api/external/v1/generations/{operationId}` 通过 owner-safe facade 读取摘要。`queued/running` 返回 phase/progress;`failed` 返回脱敏错误;`completed` 再读取同一 owner 的生成 artifacts 并返回 `result_payload_json.result`。
|
||||
4. 跨 owner operationId 按不存在处理。查询路径不开放 claim、renew、complete、fail、retry、acknowledge 或 controller 控制面。
|
||||
5. completed 查询返回 compact artifact 引用。调用方需要完整画布时重新读取项目,需要媒体临时 URL 时再对稳定 objectKey 换签。
|
||||
|
||||
托管 MCP 的生成 tools 也走同一 External REST router:MCP 参数中的 `idempotencyKey` 映射到 HTTP `Idempotency-Key`,`get_external_editor_generation_job` 映射统一查询。MCP 不直接调用 SpacetimeDB procedure,不形成平行队列或结果账本。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -226,6 +242,8 @@ cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server external_editor_generation --manifest-path server-rs/Cargo.toml
|
||||
cargo test -p api-server external_mcp --manifest-path server-rs/Cargo.toml
|
||||
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
|
||||
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
|
||||
```
|
||||
@@ -239,7 +257,7 @@ curl -f http://127.0.0.1:<api-port>/healthz
|
||||
|
||||
本地 `npm run dev` 与 `npm run dev:api-server` 默认注入 `GENARRATIVE_PROCESS_ROLE=all`,同一 Rust 进程同时监听 HTTP 并消费外部生成队列;显式设置 `GENARRATIVE_PROCESS_ROLE` 时保留显式值。需要验证生产式拆分角色、lease 重领或扩缩容时,再分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`,也可以使用隔离容器 smoke。
|
||||
|
||||
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
|
||||
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。`genarrative-api.service` 还通过 systemd `Wants=genarrative-external-generation-controller.service` 弱依赖覆盖只启动 API 的现场兜底;controller 仍是独立进程,不由 HTTP 进程内执行 `systemctl`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认任务列表 BFF 可读、未确认终态任务会弹出提示、提示展示后后台 acknowledge 且刷新后不再弹出,单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。External smoke 还必须证明生成 POST 返回 `202`、同幂等键不重复创建任务、统一查询能读到 compact completed result、跨 owner 返回 `404`,并通过托管 MCP 调用同一提交/查询工具链。
|
||||
|
||||
systemd 生产 controller 与手动兜底示例:
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ Agent Runtime 负责:
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。
|
||||
- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。
|
||||
- 2026-07-10 补充,2026-07-28 收紧:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、平台素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。图集响应含 `warning.code=postprocess-failed-source-preserved` 或不含任意 `alpha < 255` 时不得登记为正式透明图集;仅有 `sliceWarning` 时可保留完整透明图,但不宣称已有独立切片。本地 manifest 持久生成 route、kind 与精确参考 resourceId;登记失败时删除本轮刚写入的新文件。API Key 不进入 observation、manifest、agent.db 或日志。
|
||||
- 2026-07-10 补充,2026-07-31 收紧:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、平台素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。每次生成提交必须携带稳定 `Idempotency-Key`,持久保留返回的 `operationId`,按 `pollAfterMs` 查询 `/api/external/v1/generations/{operationId}`;只有 `completed` 才消费 compact result 并换签下载,客户端超时或结果未知时不得换键重提。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。completed result 含 `warning.code=postprocess-failed-source-preserved` 或对应媒体不含任意 `alpha < 255` 时不得登记为正式透明图集;仅有 `sliceWarning` 时可保留完整透明图,但不宣称已有独立切片。本地 manifest 持久生成 route、kind、operationId 与精确参考 resourceId;登记失败时删除本轮刚写入的新文件。API Key 和幂等键不进入 observation、manifest、agent.db 或日志。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `task.update`。Agent 可在 loop 中把 manifest 种子任务状态更新为 `pending / running / waiting-for-confirmation / completed / failed`,用于表达长期后台任务的当前进度;Runtime 复用 `task.update` 策略和项目写锁,实际只修改 `.agent/manifest.json` 中已有 taskId 的 `status`,并写入 `agent.runtime.task.update` 审计记录。策略要求确认或拒绝时不会修改 manifest,也不会创建新任务。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `file.list`。Agent 可在 loop 中自行列出项目文件摘要或某个相对目录下的文件摘要,再决定是否继续读取具体文件;Runtime 复用 `file.list` 项目权限策略,observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。
|
||||
@@ -346,7 +346,7 @@ game-project/
|
||||
- `canvas.project_open` 只打开本机 Genarrative 编辑器的 `/editor/canvas?projectid=...`,默认地址为 `http://127.0.0.1:3000`,开发者可在开发窗口改成本机端口;不允许打开远程站点或任意 URL。
|
||||
- 画板资源回流到本地项目 `assets/`,并在 manifest 中记录画板项目、资源 ID、assetObjectId、prompt、model、taskId 和 assetKind;当前最小落地提供 `asset.register` 登记项目内已有资产,并提供 `canvas.export_import` 读取现有画板素材导出 ZIP。
|
||||
- `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。
|
||||
- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再调用 `/api/external/v1/editor/images/generations`,携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`,随后通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件、agent.db、trace、manifest 或日志;未配置 Key 或生成失败时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。
|
||||
- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用 `/api/external/v1/editor/images/generations`,请求携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`。Runtime 必须保存返回的 `operationId` 并按 `pollAfterMs` 查询统一状态端点;completed 后从 compact result 取得稳定 objectKey/resourceId,再通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 和幂等键不写入项目文件、agent.db、trace、manifest 或日志;operationId 只作为该生成动作的可恢复身份保存。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。
|
||||
- `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。
|
||||
- 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
|
||||
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
外部调用方需要通过稳定 HTTP 契约使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。该能力必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态或生成结果放到前端临时状态中。
|
||||
外部调用方需要通过稳定 HTTP 契约或托管式远程 MCP 使用图片画布编辑器内的素材生成、编辑和管理能力,并能创建项目、保存画板布局和管理账号级素材库。不支持 MCP 的 Agent 还需要可发现、可校验、可完整下载的 Skill 包,而不是只有一份 OpenAPI JSON。全部入口必须走 `server-rs + Axum + SpacetimeDB` 正式链路,不能把 API Key、画板状态、素材状态、生成任务或生成结果放到前端临时状态中。
|
||||
|
||||
## v1 范围
|
||||
|
||||
@@ -32,24 +32,84 @@ v1 只开放以下能力:
|
||||
- `POST /api/external/v1/editor/assets`:创建素材记录。
|
||||
- `PATCH /api/external/v1/editor/assets/{assetId}`:更新素材名称或所在文件夹。
|
||||
- `DELETE /api/external/v1/editor/assets/{assetId}`:删除素材记录。
|
||||
- `POST /api/external/v1/editor/images/generations`:调用编辑器图片素材生成能力;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。可选传入 `projectId` 和 `assetFolderId`,生成后按站内编辑器规则写入 `editor_project_resource` 和账号级 `editor_asset`。
|
||||
- `POST /api/external/v1/editor/images/edits`:重绘 / 调整已有图片,结果可写入项目资源和素材库。
|
||||
- `POST /api/external/v1/editor/icon-spritesheets/generations`:按规范图生成图标 spritesheet,并拆分为独立图标素材。
|
||||
- `POST /api/external/v1/editor/ui-designs/assets/extractions`:从 UI 设计图中提取 / 拆分素材。
|
||||
- `POST /api/external/v1/editor/character-animations/generations`:基于角色图片生成角色动画预览和帧序列。
|
||||
- `POST /api/external/v1/editor/videos/generations`:生成编辑器视频素材,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。
|
||||
- `POST /api/external/v1/editor/audios/sound-effects/generations`:生成编辑器音效素材。
|
||||
- `POST /api/external/v1/editor/audios/background-music/generations`:生成编辑器背景音乐素材。
|
||||
- `POST /api/external/v1/editor/images/generations`:异步提交编辑器图片素材生成;通过 `kind` 支持普通图、规范图 `spec`、角色图 `character`、快速编辑参考图 `quick-edit`、UI 设计图 `ui-design` 和宣发素材 `publication-material`。
|
||||
- `POST /api/external/v1/editor/images/edits`:异步提交已有图片重绘 / 调整。
|
||||
- `POST /api/external/v1/editor/icon-spritesheets/generations`:异步提交规范图驱动的图标 spritesheet 生成和拆分。
|
||||
- `POST /api/external/v1/editor/ui-designs/assets/extractions`:异步提交 UI 设计图素材提取 / 拆分。
|
||||
- `POST /api/external/v1/editor/character-animations/generations`:异步提交角色动画预览和帧序列生成。
|
||||
- `POST /api/external/v1/editor/videos/generations`:异步提交编辑器视频生成,支持现有 Seedance / Kling / Veo 模型参数和参考媒体限制。
|
||||
- `POST /api/external/v1/editor/audios/sound-effects/generations`:异步提交编辑器音效生成。
|
||||
- `POST /api/external/v1/editor/audios/background-music/generations`:异步提交编辑器背景音乐生成。
|
||||
- `GET /api/external/v1/generations/{operationId}`:按 API Key owner 查询异步生成状态;`completed` 时返回 compact 稳定结果引用,跨 owner 按不存在处理。
|
||||
- `GET /api/external/v1/openapi.json`:导出本版本 OpenAPI 3.1 JSON。
|
||||
- `GET /api/external/v1/agent-integration.json`:公开导出 Agent 集成发现 manifest,声明 MCP、OpenAPI、Skill 入口、完整 Skill archive、archive SHA-256 和包内文件清单。
|
||||
- `GET /api/external/v1/skill/SKILL.md`:公开读取 Skill 原始入口。
|
||||
- `GET /api/external/v1/skill.zip`:公开下载完整 Skill 包。
|
||||
- `POST /api/external/v1/mcp`:使用相同 Bearer API Key 的托管式 Streamable HTTP MCP;对外暴露本节 OpenAPI operation tools 以及使用说明、OpenAPI、Skill 入口 `SKILL.md` 和逐个 Skill reference 文档,不开放内部 SpacetimeDB MCP。
|
||||
|
||||
图片生成、图标 spritesheet 和 UI 素材提取的 2xx 成功响应可携带可选结构化 `warning { code, reason }`。外部 OpenAPI 当前公开四个稳定 `code`:
|
||||
八类生成 POST 全部要求 `Idempotency-Key`,成功只返回 HTTP `202 Accepted`、`operationId`、`kind`、`status`、`statusUrl`、`pollAfterMs` 和 `updatedAtMicros`。调用方不得把 `202` 当作媒体生成完成,也不得在网络结果不确定时换一个幂等键重新提交。
|
||||
|
||||
图片生成、图标 spritesheet 和 UI 素材提取的 completed compact `result` 可携带可选结构化 `warning { code, reason }`;任务查询顶层 `warning` 是可直接展示的有界摘要。外部 OpenAPI 当前公开四个稳定 `code`:
|
||||
|
||||
- `postprocess-failed-source-preserved`:生成成功,但透明处理、像素规整等后处理未完成,接口保留仍可使用的原图或进入该步骤前的结果。
|
||||
- `dimension-restore-fallback`:provider 回图无法安全收口到目标交付尺寸,接口保留实际回图尺寸。
|
||||
- `unsupported-image-style`:请求的图片后处理风格未知或不适用于当前生成类型,接口按无风格继续生成。
|
||||
- `multiple-generation-warnings`:同一成功响应合并了不同 `code` 的多条非阻断告警,具体原因按顺序拼接在 `reason`。
|
||||
|
||||
provider 原图已保存但透明背景处理最终失败时,接口返回原图,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回,无画布上下文时只返回原图及实际存在的资源 / 素材快照。调用方应展示 `warning.reason`,但不得把任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍返回错误。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥(该情况不会进入拆分);风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,调用方必须同时展示两者,不得只取其一。
|
||||
provider 原图已保存但透明背景处理最终失败时,worker 保留原图稳定引用,不返回不存在的透明处理图,图标和 UI 也不继续拆分;有 `projectId + canvasCompletion` 时由原图完成画布写回。调用方应展示告警,但不得把 completed 任务改判为失败。该降级只覆盖透明背景处理的最终失败,phase 上报、原图或透明处理图持久化、画布写回失败仍使任务失败。图标 / UI 已成功生成透明图、只有自动拆分失败时继续使用既有 `sliceWarning`。通用 `warning` 与 `sliceWarning` 只在「透明背景最终失败」这一条上互斥;风格归一化或像素规整产生的通用 `warning` 可以与 `sliceWarning` 并存,compact result 不得丢弃任一条。
|
||||
|
||||
## 异步提交、查询与幂等
|
||||
|
||||
外部生成不受 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响:无论站内本地排障模式如何配置,External v1 都只持久化入队并返回 `202`,不在 API 请求中同步执行 provider。正式状态源是既有 `external_generation_job`;生成核心、计费、OSS、画布写回、lease 续租和 fencing 继续由现役 worker 链路负责。
|
||||
|
||||
调用规则:
|
||||
|
||||
1. 调用方为一次逻辑生成分配 `1-128` 字节、无空格的可打印 ASCII `Idempotency-Key`。
|
||||
2. 服务端以 owner、job kind、幂等键和规范请求建立稳定去重身份;同一请求的传输重试必须复用原键。
|
||||
3. `202` 响应通过 `Location` / `statusUrl` 指向 `/api/external/v1/generations/{operationId}`,并提供 `Retry-After` / `pollAfterMs`。
|
||||
4. `queued/running` 返回 phase、进度与下一次建议轮询间隔;`completed` 返回 `result`;`failed` 返回脱敏 `error`。调用方自己的轮询超时不改变任务状态。
|
||||
5. `result` 只保留稳定 `objectKey`、`resourceId`、`assetId`、`assetObjectId`、尺寸、媒体类型、taskId、warning 等轻量引用;禁止持久化完整 project/canvas、大型布局快照、Data URL、Blob URL、过期 signed URL、worker lease/fencing 字段和内部 provider 诊断。
|
||||
6. 需要完整项目或素材库状态时,调用方在 completed 后重新读取项目或素材库;需要下载媒体时,用稳定 `objectKey` 调 `/assets/read-url` 获取短期签名 URL。
|
||||
|
||||
结果查询使用 API Key owner 过滤。任务不存在、已删除或属于其他 owner 时统一返回 `404`,不能通过差异错误枚举他人 operationId。
|
||||
|
||||
## 托管远程 MCP
|
||||
|
||||
`/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。
|
||||
|
||||
MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。
|
||||
|
||||
MCP 暴露下列稳定文本资源:
|
||||
|
||||
- `genarrative://external-editor/usage`:关键工作流和异步轮询规则。
|
||||
- `genarrative://external-editor/openapi`:完整 External v1 OpenAPI。
|
||||
- `genarrative://external-editor/skill`:Skill 入口原文;保留首版已声明的稳定 URI。
|
||||
- `genarrative://external-editor/skill/references/capability-routing.md`:能力选路与场景边界。
|
||||
- `genarrative://external-editor/skill/references/api-operations.md`:公开 API 操作、必填字段与调用顺序。
|
||||
- `genarrative://external-editor/skill/references/authentication-and-safety.md`:API Key 鉴权、幂等与安全边界。
|
||||
- `genarrative://external-editor/skill/references/requests-and-outputs.md`:异步提交、状态轮询与 compact 结果语义。
|
||||
|
||||
Skill 日后新增 `references/` 文档时,MCP 必须按包内相对路径逐个增加 `genarrative://external-editor/skill/references/<name>` resource,不得只暴露 `SKILL.md` 而让 Agent 无法读取其引用。当前稳定 reference 精确为上述四篇,不得声明不存在的 reference。MCP Agent 直接调用托管 tools,不下载或安装 Python CLI;`scripts/`、`tests/` 和 `.github/workflows/` 不作为 MCP resources。
|
||||
|
||||
MCP 必须始终复用 `require_external_api_key`,owner 从 `ExternalApiPrincipal` 获取,不接受请求参数伪造 owner。禁止透传内部 `external_generation_job` procedure、worker controller、SpacetimeDB MCP 或 lease/fencing 控制面。
|
||||
|
||||
## Agent 集成发现与完整 Skill 包
|
||||
|
||||
`agent-integration.json` 是机器可读的统一发现入口。支持远程 MCP 的 Agent 读取其中 `mcp.transport/url/authentication`,通过 MCP resources 读取 Skill 入口和所需 references,直接调用 MCP tools,不安装 CLI。仅不支持 MCP,或需要在 Agent 所在机器上编排本地文件上传的调用方下载 `skill.archive`,核对 `archiveSha256`,解压后从 `genarrative-external-editor-api/SKILL.md` 进入。
|
||||
|
||||
Skill archive 必须至少包含:
|
||||
|
||||
- `SKILL.md`
|
||||
- `references/capability-routing.md`
|
||||
- `references/api-operations.md`
|
||||
- `references/authentication-and-safety.md`
|
||||
- `references/requests-and-outputs.md`
|
||||
- `scripts/genarrative_external_api.py`
|
||||
- `agents/openai.yaml`
|
||||
|
||||
包由 api-server 直接从仓库同源文件构建,不能只返回光秃秃的 OpenAPI JSON,也不能把个人 API Key、环境配置或本机路径写入包。完整 `skill.zip` 只服务不支持 MCP 或需要本地文件编排的 Agent,不是 MCP resource catalog 的压缩包镜像。Python helper 对上层保持便利的同步函数外观,但内部必须执行“异步提交 → 保存 operationId → 按 pollAfterMs 查询 → completed 返回 result”,查询超时应保留 operationId 供后续继续,不得换键重提。
|
||||
|
||||
api-server 使用 `include_str!` 嵌入 OpenAPI 与 Skill 源文件;容器构建阶段必须同时复制 `docs/openapi/` 和 `.codex/skills/genarrative-external-editor-api/`,不能只复制 `server-rs/`,否则本地 Cargo 验证虽可通过,隔离镜像构建会在编译期找不到同源资源。
|
||||
|
||||
管理 API Key 的登录态接口保留在站内个人中心链路,但不写入外部 OpenAPI JSON:
|
||||
|
||||
@@ -78,6 +138,8 @@ DELETE /api/profile/api-keys/{keyId}
|
||||
|
||||
这是 breaking change,不是文档同步:严格反序列化的调用方(OpenAPI Generator 生成的 Java / Kotlin / C#、pydantic、serde 非 `Option` 字段)在 `required` 字段缺失时直接失败,且失败发生在服务端上线瞬间,不需要调用方做任何动作。脱敏目标本身成立,接受不升版本、不设弃用期的唯一依据是当前无存量调用方。
|
||||
|
||||
2026-07-31 同一豁免还覆盖了「八类生成从同步成功响应切换为 `202 + operationId`,新增统一查询接口」这一 breaking change。旧调用方若仍把生成 POST 响应当作媒体结果会立即失败;接受原地修改 v1 的唯一依据同样是上线前已确认没有外部第三方存量调用方。托管 MCP、集成 manifest 与 Skill archive 均为新增入口,不产生既有客户端兼容债务。
|
||||
|
||||
### 豁免的失效条件
|
||||
|
||||
API Key 由用户在个人中心自助发放,因此「无外部调用方」不是受控状态,可能在无人决策的情况下变为假。本节豁免在下列任一条件出现后立即失效:
|
||||
@@ -113,7 +175,7 @@ Authorization: Bearer tnr_sk_xxx
|
||||
- 明文 Key 只在创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`。
|
||||
- API Key 被撤销后立即不可再用于外部接口。
|
||||
- 外部 API 鉴权不复用登录态 JWT,不检查 refresh session;它是独立开发者凭据。
|
||||
- OpenAPI JSON 公共可读,不需要鉴权。
|
||||
- OpenAPI JSON、Agent 集成 manifest、原始 Skill 入口和完整 Skill archive 公共可读,不需要鉴权;MCP 与全部业务操作需要鉴权。
|
||||
|
||||
## 数据模型
|
||||
|
||||
@@ -142,19 +204,19 @@ SpacetimeDB procedure:
|
||||
|
||||
## 素材生成与落库
|
||||
|
||||
外部生成接口复用站内编辑器已有 handler 和 DTO,不维护第二套生成语义:
|
||||
外部生成接口复用站内编辑器已有 DTO、入队器和 worker executor,不维护第二套生成语义:
|
||||
|
||||
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则。
|
||||
- 图片生成 / 重绘 / 规范图 / 宣发图 / UI 设计图复用 `/api/editor/images/generations` 与 `/api/editor/images/edits` 的校验、模型归一、计费和持久化规则,但 External handler 固定只入队。
|
||||
- 图标 spritesheet 和 UI 设计图素材提取复用站内拆分逻辑,生成图集后按连通域切片,并把图集与切片都按请求写入项目资源和素材库。
|
||||
- 角色动画、视频、音效和背景音乐复用站内编辑器生成链路;请求携带 `assetFolderId` 时按站内规则写入素材库,音频类外部调用使用 API Key 所属账号作为 asset owner。
|
||||
- API Key 管理接口仍只属于登录态个人中心,不进入外部 OpenAPI JSON。
|
||||
|
||||
素材外部生成成功后,后端拿到素材后:
|
||||
素材外部生成由 worker 成功后:
|
||||
|
||||
1. 通过 OSS / asset object adapter 持久化媒体文件。
|
||||
2. 写入 `editor_asset`,让生成素材进入账号级素材库。
|
||||
3. 如果请求带 `projectId`,写入 `editor_project_resource`。
|
||||
4. 返回图片读取地址、素材 ID、资源 ID、尺寸、prompt、model 和 taskId;普通 External API 响应、项目资源与素材 read model 不返回生成 provider 或内部抠图审计字段。同源画布前端自动提交默认 `segModel` 属于站内 BFF 请求契约,不因此向 External OpenAPI 开放该字段。
|
||||
4. 在 `result_payload_json` 保存 compact 稳定引用,由查询接口返回素材 ID、资源 ID、objectKey、尺寸、媒体类型、model、taskId 和 warning;普通 External API 响应、项目资源与素材 read model 不返回生成 provider、原始 prompt 或内部抠图审计字段。同源画布前端自动提交默认 `segModel` 属于站内 BFF 请求契约,不因此向 External OpenAPI 开放该字段。
|
||||
|
||||
如果请求未带 `projectId`,只生成并写入素材库;调用方可随后创建项目或自行保存画板布局。
|
||||
|
||||
@@ -177,17 +239,21 @@ OpenAPI 3.1 JSON 固定落在:
|
||||
docs/openapi/genarrative-external-v1.openapi.json
|
||||
```
|
||||
|
||||
服务端 `GET /api/external/v1/openapi.json` 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。
|
||||
服务端 `GET /api/external/v1/openapi.json` 和 MCP OpenAPI resource 使用同一份 JSON,通过 `include_str!` 导出,避免运行时生成结果与仓库文档漂移。MCP tool catalog 同样以这份 OpenAPI 的 path、method、operationId、参数和 request body 是否存在为来源;字段级精确约束继续以 OpenAPI resource 为准。
|
||||
|
||||
## 验收
|
||||
|
||||
- API Key 创建只返回一次明文,列表不返回明文。
|
||||
- 撤销后的 API Key 调用外部接口返回 `401`。
|
||||
- 外部图片生成、重绘、图标拆分、UI 素材拆分、视频、音效和音乐生成成功后,生成结果按请求同时出现在画布资源和账号级素材库。
|
||||
- 角色图、图标 spritesheet 和 UI 素材提取的 2xx 成功响应允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。
|
||||
- 八类外部生成 POST 缺少或携带非法 `Idempotency-Key` 时返回 `400`;同一 owner、请求和 key 重试只得到同一 operation。
|
||||
- 八类外部生成 POST 固定返回 `202`,查询能从 `queued/running` 收敛到 `completed/failed`;调用方超时后使用原 operationId 继续查询。
|
||||
- 外部图片生成、重绘、图标拆分、UI 素材拆分、角色动画、视频、音效和音乐 completed 后,生成结果按请求同时出现在画布资源和账号级素材库。
|
||||
- 角色图、图标 spritesheet 和 UI 素材提取的 completed result 允许携带 `EditorGenerationWarning`;provider 原图保留降级与自动拆分降级必须保持成功状态,并分别使用通用 `warning` 与兼容 `sliceWarning` 表达。
|
||||
- 外部视频、角色动画、音效和音乐接口使用站内编辑器相同的请求校验、模型限制和价格校验。
|
||||
- OpenAPI JSON 能被 `serde_json` 解析,且 security scheme 为 Bearer API Key。
|
||||
- OpenAPI JSON 不包含 `/api/profile/api-keys`、`UserAccessToken` 或 API Key 管理 schema。
|
||||
- `agent-integration.json` 能发现 MCP、OpenAPI、Skill entry/archive;下载 archive 的 SHA-256 与 manifest 一致,ZIP 包含 `SKILL.md`、四篇 references、Python helper 和 `agents/openai.yaml` 七个声明文件且不含凭据。
|
||||
- MCP 在无 Bearer 时返回 `401`,合法 Key 可完成 initialize、tools/list、resources/list/read 和生成提交/查询;resource catalog 必须包含 usage、OpenAPI、`skill` 主入口和当前全部 Skill references,当前精确为 `skill/references/capability-routing.md`、`skill/references/api-operations.md`、`skill/references/authentication-and-safety.md` 与 `skill/references/requests-and-outputs.md`,且不包含 CLI 脚本、测试或 workflow;多实例不依赖 sticky session,不暴露内部 SpacetimeDB MCP 或 worker 控制面。
|
||||
- 外部素材库接口覆盖当前已有素材操作:直传凭证、素材对象确认、签名读取、读取素材库、创建 / 更新 / 删除文件夹、创建 / 更新 / 删除素材、创建项目画布资源。
|
||||
- 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。
|
||||
- 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。
|
||||
|
||||
Generated
+103
@@ -231,6 +231,7 @@ dependencies = [
|
||||
"platform-wechat",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -827,6 +828,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -1932,6 +1944,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -3679,6 +3692,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -4516,6 +4535,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -4554,6 +4584,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4720,6 +4756,35 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
@@ -4931,12 +4996,26 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -5027,6 +5106,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
@@ -5670,6 +5760,19 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -112,6 +112,7 @@ pingora-http = { version = "0.8.1", default-features = false }
|
||||
pingora-proxy = { version = "0.8.1", default-features = false }
|
||||
rand_core = "0.6"
|
||||
reqwest = { version = "0.12", default-features = false }
|
||||
rmcp = { version = "=2.2.0", default-features = false }
|
||||
ring = "0.17"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user