合并最新master到画板抠图背景分支
更新本地 master 到 origin/master 后合入当前 feat/BGfilter 分支。 解决决策记录和图片画布 MVP 文档冲突,保留画布 Agent 与 BGFilter 背景色说明。 补齐画布 Agent 调用生图接口时的默认 screenColor=auto 和 segModel=birefnet。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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, draft curl/HTTP/SDK requests, clarify missing generation/upload/project/asset fields, or set up and safely handle a Genarrative developer API Key.
|
||||
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.
|
||||
---
|
||||
|
||||
# Genarrative External Editor API
|
||||
@@ -11,22 +11,25 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Classify the user's natural-language intent first. Do not ask the user to choose an API:
|
||||
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
|
||||
2. Ask only for missing inputs that affect the request body or an actually ambiguous route:
|
||||
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
|
||||
- existing `projectId`, folder/resource IDs, and whether output should update the canvas
|
||||
- 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
|
||||
3. If the user lacks an API Key, guide setup before request design.
|
||||
4. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference.
|
||||
5. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, or a chain that should execute with fewer hand-written curl steps.
|
||||
6. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs.
|
||||
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.
|
||||
|
||||
## Core Routes
|
||||
|
||||
@@ -45,6 +48,25 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
|
||||
| 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` |
|
||||
|
||||
## Art Spec Interface
|
||||
|
||||
Maintain one current art spec per conversation. A compact spec is enough:
|
||||
|
||||
```json
|
||||
{
|
||||
"assetType": "character | background | prop | ui | icon | animation | video | audio",
|
||||
"subject": "要生成的主体",
|
||||
"style": "画风/材质/时代/参考风格",
|
||||
"palette": "主色与禁用色",
|
||||
"composition": "构图、镜头、姿态或布局",
|
||||
"format": "比例、尺寸、分辨率、帧数、时长",
|
||||
"constraints": "必须保留/禁止出现/透明或绿幕要求",
|
||||
"references": ["objectKey 或本地路径说明"]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## API Key
|
||||
|
||||
The external OpenAPI uses:
|
||||
@@ -99,7 +121,25 @@ For Python callers, prefer:
|
||||
from genarrative_external_api import GenarrativeExternalClient
|
||||
|
||||
client = GenarrativeExternalClient()
|
||||
project = client.create_project("新画板")
|
||||
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(
|
||||
"生成幻想森林背景",
|
||||
canvasSession=session,
|
||||
assetLabel="森林背景",
|
||||
aspectRatio="16:9",
|
||||
imageSize="1K",
|
||||
artSpec=art_spec,
|
||||
)
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -122,7 +162,7 @@ curl -fsS "$api/api/external/v1/editor/projects" \
|
||||
-d '{"title":"新画板"}'
|
||||
```
|
||||
|
||||
Generate an image and save it into a project/canvas when the user supplies placement:
|
||||
Generate an image and save it into both the canvas and the asset-library folder:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -131,6 +171,14 @@ Generate an image and save it into a project/canvas when the user supplies place
|
||||
"aspectRatio": "16:9",
|
||||
"imageSize": "1K",
|
||||
"projectId": "<projectId>",
|
||||
"assetFolderId": "<assetFolderId>",
|
||||
"assetLabel": "森林背景",
|
||||
"generationInputs": {
|
||||
"artSpec": {
|
||||
"assetType": "background",
|
||||
"style": "手绘游戏概念图"
|
||||
}
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"title": "森林背景",
|
||||
"placeholder": {
|
||||
@@ -147,6 +195,8 @@ Generate an image and save it into a project/canvas when the user supplies place
|
||||
|
||||
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.
|
||||
@@ -157,9 +207,12 @@ Python helper path:
|
||||
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"]],
|
||||
@@ -255,6 +308,10 @@ For character animation from an uploaded local image, set:
|
||||
|
||||
Use an existing canvas layer ID when the image came from a project layer. If it came only from a local upload, derive a stable synthetic `sourceLayerId` from the file name, for example `external-reference-hero`. Read `sourceWidth` and `sourceHeight` from the actual image before upload; ask the user only if the dimensions cannot be determined.
|
||||
|
||||
The helper uses a 420 second timeout for generation calls, including character animation and video. Direct HTTP clients should not use a 70 second request timeout for animation.
|
||||
|
||||
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.
|
||||
@@ -264,6 +321,7 @@ For sound effects and BGM, `assetFolderId` and `assetLabel` can write the genera
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
interface:
|
||||
display_name: "Genarrative External Editor API"
|
||||
short_description: "Auto-route external canvas API usage"
|
||||
default_prompt: "Use $genarrative-external-editor-api to infer the right external canvas API and draft a request."
|
||||
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."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -8,6 +8,17 @@ Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
|
||||
- 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.
|
||||
|
||||
## Intent Routing
|
||||
|
||||
@@ -60,11 +71,13 @@ Ask a follow-up only when two routes could both be correct and produce different
|
||||
| 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` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
|
||||
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `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` |
|
||||
| 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` |
|
||||
|
||||
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"`.
|
||||
|
||||
## Reference Image Upload
|
||||
|
||||
If the user provides a local file as a reference image, run upload before the generation request:
|
||||
@@ -144,11 +157,12 @@ Do not put the signed read URL into generation fields. Signed URLs are for user-
|
||||
- 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, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record.
|
||||
- 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` only when the generated result should be written back into a project canvas by the backend.
|
||||
Use `canvasCompletion` for generation in this skill so the generated result is written back into the project canvas by the backend.
|
||||
|
||||
Required:
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ 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
|
||||
|
||||
|
||||
class GenarrativeApiError(RuntimeError):
|
||||
@@ -58,6 +60,20 @@ def source_layer_id_from_path(file_path: str | os.PathLike[str]) -> str:
|
||||
return f"external-reference-{slug}"
|
||||
|
||||
|
||||
def normalize_optional_text(value: Any) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def art_spec_prompt(prompt: str, art_spec: dict[str, Any] | None) -> str:
|
||||
if not art_spec:
|
||||
return prompt
|
||||
spec_json = json.dumps(art_spec, ensure_ascii=False, sort_keys=True)
|
||||
return f"{prompt}\n\n美术规范(JSON): {spec_json}"
|
||||
|
||||
|
||||
def image_dimensions(file_path: str | os.PathLike[str]) -> tuple[int, int] | None:
|
||||
path = Path(file_path)
|
||||
with path.open("rb") as fh:
|
||||
@@ -108,7 +124,7 @@ class GenarrativeExternalClient:
|
||||
body: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
auth: bool = True,
|
||||
timeout: int = 60,
|
||||
timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
if query:
|
||||
@@ -140,6 +156,120 @@ class GenarrativeExternalClient:
|
||||
body = {} if title is None else {"title": title}
|
||||
return self.request_json("POST", "/api/external/v1/editor/projects", body)
|
||||
|
||||
def list_asset_library(self) -> Any:
|
||||
return self.request_json("GET", "/api/external/v1/editor/assets/library")
|
||||
|
||||
def create_asset_folder(self, label: str, sort_order: int = 100) -> Any:
|
||||
return self.request_json(
|
||||
"POST",
|
||||
"/api/external/v1/editor/assets/folders",
|
||||
{"label": label, "sortOrder": sort_order},
|
||||
)
|
||||
|
||||
def ensure_asset_folder(self, label: str, sort_order: int = 100) -> dict[str, Any]:
|
||||
normalized_label = normalize_optional_text(label) or "新画板"
|
||||
library = self.list_asset_library()
|
||||
folders = unwrap_envelope(library).get("library", {}).get("folders", [])
|
||||
if isinstance(folders, list):
|
||||
for folder in folders:
|
||||
if isinstance(folder, dict) and normalize_optional_text(folder.get("label")) == normalized_label:
|
||||
return folder
|
||||
created = self.create_asset_folder(normalized_label, sort_order=sort_order)
|
||||
folder = unwrap_envelope(created).get("folder")
|
||||
if not isinstance(folder, dict):
|
||||
raise GenarrativeApiError("Create asset folder response missing folder payload.")
|
||||
return folder
|
||||
|
||||
def create_asset(
|
||||
self,
|
||||
folder_id: str,
|
||||
label: str,
|
||||
image_src: str,
|
||||
width: int,
|
||||
height: int,
|
||||
**fields: Any,
|
||||
) -> Any:
|
||||
body = {
|
||||
"folderId": folder_id,
|
||||
"label": label,
|
||||
"imageSrc": image_src,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"sourceType": fields.pop("sourceType", "generated"),
|
||||
**fields,
|
||||
}
|
||||
return self.request_json("POST", "/api/external/v1/editor/assets", body)
|
||||
|
||||
def prepare_canvas_session(self, canvas_name: str) -> dict[str, Any]:
|
||||
normalized_name = normalize_optional_text(canvas_name) or "新画板"
|
||||
project = unwrap_envelope(self.create_project(normalized_name)).get("project")
|
||||
if not isinstance(project, dict) or not normalize_optional_text(project.get("projectId")):
|
||||
raise GenarrativeApiError("Create project response missing projectId.")
|
||||
folder = self.ensure_asset_folder(normalized_name)
|
||||
folder_id = normalize_optional_text(folder.get("folderId"))
|
||||
if not folder_id:
|
||||
raise GenarrativeApiError("Asset folder payload missing folderId.")
|
||||
return {
|
||||
"canvasName": normalized_name,
|
||||
"projectId": project["projectId"],
|
||||
"assetFolderId": folder_id,
|
||||
"project": project,
|
||||
"folder": folder,
|
||||
}
|
||||
|
||||
def build_canvas_completion(
|
||||
self,
|
||||
title: str,
|
||||
width: int,
|
||||
height: int,
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
dialog_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"title": normalize_optional_text(title) or "生成素材",
|
||||
"placeholder": {
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"originalWidth": width,
|
||||
"originalHeight": height,
|
||||
},
|
||||
}
|
||||
if normalize_optional_text(dialog_id):
|
||||
payload["dialogId"] = dialog_id
|
||||
return payload
|
||||
|
||||
def canvas_generation_fields(
|
||||
self,
|
||||
session: dict[str, Any],
|
||||
asset_label: str,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
dialog_id: str | None = None,
|
||||
asset_label_field: str | None = "assetLabel",
|
||||
) -> dict[str, Any]:
|
||||
fields: dict[str, Any] = {
|
||||
"projectId": session["projectId"],
|
||||
"canvasCompletion": self.build_canvas_completion(
|
||||
asset_label,
|
||||
width=width,
|
||||
height=height,
|
||||
x=x,
|
||||
y=y,
|
||||
dialog_id=dialog_id,
|
||||
),
|
||||
}
|
||||
folder_id = normalize_optional_text(session.get("assetFolderId"))
|
||||
if folder_id:
|
||||
fields["assetFolderId"] = folder_id
|
||||
if asset_label_field:
|
||||
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
|
||||
return fields
|
||||
|
||||
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
|
||||
return self.request_json(
|
||||
"PATCH",
|
||||
@@ -147,6 +277,62 @@ class GenarrativeExternalClient:
|
||||
{"viewport": viewport, "layers": layers},
|
||||
)
|
||||
|
||||
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
|
||||
art_spec = fields.pop("artSpec", None)
|
||||
if art_spec is None:
|
||||
art_spec = fields.pop("art_spec", None)
|
||||
if not isinstance(art_spec, dict):
|
||||
return prompt
|
||||
generation_inputs = fields.get("generationInputs")
|
||||
if not isinstance(generation_inputs, dict):
|
||||
generation_inputs = {}
|
||||
generation_inputs.setdefault("artSpec", art_spec)
|
||||
fields["generationInputs"] = generation_inputs
|
||||
return art_spec_prompt(prompt, art_spec)
|
||||
|
||||
def _apply_canvas_session_fields(
|
||||
self,
|
||||
fields: dict[str, Any],
|
||||
default_label: str,
|
||||
default_width: int,
|
||||
default_height: int,
|
||||
asset_label_field: str | None = "assetLabel",
|
||||
) -> tuple[dict[str, Any] | None, str]:
|
||||
session = fields.pop("canvasSession", None)
|
||||
if session is None:
|
||||
session = fields.pop("canvas_session", None)
|
||||
if isinstance(session, str):
|
||||
session = self.prepare_canvas_session(session)
|
||||
label = (
|
||||
normalize_optional_text(fields.get(asset_label_field)) if asset_label_field else None
|
||||
) or normalize_optional_text(fields.pop("canvasTitle", None)) or normalize_optional_text(default_label) or "生成素材"
|
||||
if asset_label_field and not normalize_optional_text(fields.get(asset_label_field)):
|
||||
fields.pop(asset_label_field, None)
|
||||
if not isinstance(session, dict):
|
||||
return None, label
|
||||
width = int(fields.pop("canvasWidth", default_width))
|
||||
height = int(fields.pop("canvasHeight", default_height))
|
||||
x = float(fields.pop("canvasX", 0))
|
||||
y = float(fields.pop("canvasY", 0))
|
||||
dialog_id = normalize_optional_text(fields.pop("dialogId", None))
|
||||
fields.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in self.canvas_generation_fields(
|
||||
session,
|
||||
label,
|
||||
width=width,
|
||||
height=height,
|
||||
x=x,
|
||||
y=y,
|
||||
dialog_id=dialog_id,
|
||||
asset_label_field=asset_label_field,
|
||||
).items()
|
||||
if key not in fields or fields[key] is None
|
||||
}
|
||||
)
|
||||
return session, label
|
||||
|
||||
def create_upload_ticket(self, file_path: str | os.PathLike[str], access: str = "private") -> Any:
|
||||
path = Path(file_path)
|
||||
return self.request_json(
|
||||
@@ -245,21 +431,33 @@ class GenarrativeExternalClient:
|
||||
return self.request_json("GET", "/api/external/v1/assets/read-url", query={"objectKey": object_key})
|
||||
|
||||
def generate_image(self, prompt: str, **fields: Any) -> Any:
|
||||
return self.request_json("POST", "/api/external/v1/editor/images/generations", {"prompt": prompt, **fields})
|
||||
self._apply_canvas_session_fields(fields, prompt, 1024, 1024)
|
||||
prompt = self._apply_art_spec(fields, prompt)
|
||||
return self.request_json(
|
||||
"POST",
|
||||
"/api/external/v1/editor/images/generations",
|
||||
{"prompt": prompt, **fields},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
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",
|
||||
"/api/external/v1/editor/images/edits",
|
||||
{"prompt": prompt, "sourceImageSrc": source_image_src, **fields},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
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",
|
||||
"/api/external/v1/editor/ui-designs/assets/extractions",
|
||||
{"sourceImageSrc": source_image_src, "imageSize": image_size, **fields, "aspectRatio": "1:1"},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def animate_character(
|
||||
@@ -271,6 +469,16 @@ class GenarrativeExternalClient:
|
||||
source_layer_id: str,
|
||||
**fields: Any,
|
||||
) -> Any:
|
||||
session, asset_label = self._apply_canvas_session_fields(
|
||||
fields,
|
||||
fields.get("canvasTitle", "角色动画"),
|
||||
source_width,
|
||||
source_height,
|
||||
asset_label_field=None,
|
||||
)
|
||||
prompt_text = self._apply_art_spec(fields, prompt_text)
|
||||
fields.pop("assetFolderId", None)
|
||||
fields.pop("assetLabel", None)
|
||||
body = {
|
||||
"sourceLayerId": source_layer_id,
|
||||
"sourceImageSrc": source_image_src,
|
||||
@@ -284,10 +492,40 @@ class GenarrativeExternalClient:
|
||||
**fields,
|
||||
"model": "seedance2.0-fast",
|
||||
}
|
||||
return self.request_json("POST", "/api/external/v1/editor/character-animations/generations", body)
|
||||
result = self.request_json(
|
||||
"POST",
|
||||
"/api/external/v1/editor/character-animations/generations",
|
||||
body,
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if isinstance(session, dict) and isinstance(result, dict) and not result.get("asset"):
|
||||
frames = result.get("frames")
|
||||
first_frame = frames[0] if isinstance(frames, list) and frames else None
|
||||
folder_id = normalize_optional_text(session.get("assetFolderId"))
|
||||
if isinstance(first_frame, dict) and folder_id:
|
||||
asset = self.create_asset(
|
||||
folder_id,
|
||||
asset_label,
|
||||
first_frame["imageSrc"],
|
||||
int(first_frame["width"]),
|
||||
int(first_frame["height"]),
|
||||
prompt=result.get("prompt"),
|
||||
model=result.get("model"),
|
||||
provider="ark",
|
||||
taskId=result.get("taskId"),
|
||||
assetKind="character-animation",
|
||||
generationInputs={
|
||||
"frames": frames,
|
||||
"previewVideoPath": result.get("previewVideoPath"),
|
||||
},
|
||||
)
|
||||
result["asset"] = unwrap_envelope(asset).get("asset")
|
||||
return result
|
||||
|
||||
def generate_video(self, prompt: str, **fields: Any) -> Any:
|
||||
fields.pop("mode", None)
|
||||
self._apply_canvas_session_fields(fields, prompt, 1280, 720)
|
||||
prompt = self._apply_art_spec(fields, prompt)
|
||||
body = {
|
||||
"prompt": prompt,
|
||||
"model": fields.pop("model", "seedance2.0-fast"),
|
||||
@@ -298,20 +536,31 @@ class GenarrativeExternalClient:
|
||||
**fields,
|
||||
"mode": "std",
|
||||
}
|
||||
return self.request_json("POST", "/api/external/v1/editor/videos/generations", body)
|
||||
return self.request_json(
|
||||
"POST",
|
||||
"/api/external/v1/editor/videos/generations",
|
||||
body,
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
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",
|
||||
"/api/external/v1/editor/audios/sound-effects/generations",
|
||||
{"prompt": prompt, "duration": duration, **fields},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
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",
|
||||
"/api/external/v1/editor/audios/background-music/generations",
|
||||
{"gptDescriptionPrompt": description, **fields, "makeInstrumental": True},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@@ -326,6 +575,50 @@ def _self_test() -> None:
|
||||
assert image_dimensions(fh.name) == (2, 3)
|
||||
assert source_layer_id_from_path(fh.name).startswith("external-reference-")
|
||||
assert unwrap_envelope({"ok": True, "data": {"upload": 1}}) == {"upload": 1}
|
||||
client = GenarrativeExternalClient(api_key="test")
|
||||
session = {"projectId": "proj-demo", "assetFolderId": "editor-asset-folder-demo"}
|
||||
fields = client.canvas_generation_fields(session, "英雄角色", width=512, height=768)
|
||||
assert fields["projectId"] == "proj-demo"
|
||||
assert fields["assetFolderId"] == "editor-asset-folder-demo"
|
||||
assert fields["assetLabel"] == "英雄角色"
|
||||
assert fields["canvasCompletion"]["placeholder"]["height"] == 768
|
||||
assert "美术规范" in art_spec_prompt("生成角色", {"style": "水彩"})
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_request_json(
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
auth: bool = True,
|
||||
timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
calls.append({"method": method, "path": path, "body": body, "timeout": timeout})
|
||||
if path == "/api/external/v1/editor/assets":
|
||||
return {"asset": {"assetId": "editor-asset-demo"}}
|
||||
return {
|
||||
"taskId": "task-demo",
|
||||
"model": "seedance2.0-fast",
|
||||
"prompt": "角色呼吸",
|
||||
"previewVideoPath": "/generated/preview.mp4",
|
||||
"frames": [{"frameIndex": 1, "imageSrc": "/generated/frame01.png", "width": 512, "height": 768}],
|
||||
}
|
||||
|
||||
client.request_json = fake_request_json # type: ignore[method-assign]
|
||||
result = client.animate_character(
|
||||
"/generated/source.png",
|
||||
512,
|
||||
768,
|
||||
"角色呼吸",
|
||||
"layer-hero",
|
||||
canvasSession=session,
|
||||
canvasTitle="角色呼吸动画",
|
||||
)
|
||||
assert calls[0]["timeout"] == GENERATION_REQUEST_TIMEOUT_SECONDS
|
||||
assert calls[0]["body"]["projectId"] == "proj-demo"
|
||||
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
|
||||
assert calls[1]["path"] == "/api/external/v1/editor/assets"
|
||||
assert result["asset"]["assetId"] == "editor-asset-demo"
|
||||
print("self-test ok")
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 微信小程序 web-view 登录配置。
|
||||
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
|
||||
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
|
||||
|
||||
WECHAT_MINI_PROGRAM_APP_ID=""
|
||||
WECHAT_MINI_PROGRAM_APP_SECRET=""
|
||||
WECHAT_JS_CODE_SESSION_ENDPOINT=""
|
||||
|
||||
+15
-11
@@ -1,11 +1,12 @@
|
||||
# Server-side OpenAI-compatible LLM endpoint base URL.
|
||||
LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
|
||||
LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
|
||||
# Server-side API key used by the local Vite proxy.
|
||||
# Recommended: set `LLM_API_KEY` or `ARK_API_KEY`.
|
||||
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
|
||||
# through the Rust api-server proxy.
|
||||
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
|
||||
# but it should not be relied on by browser code.
|
||||
LLM_API_KEY="YOUR_API_KEY"
|
||||
LLM_API_KEY=""
|
||||
|
||||
# Optional frontend override for the local proxy path.
|
||||
VITE_LLM_PROXY_BASE_URL="/api/llm"
|
||||
@@ -116,7 +117,11 @@ WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDE
|
||||
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
|
||||
|
||||
# Model name for chat completions.
|
||||
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
|
||||
VITE_LLM_MODEL="gpt-5.4-mini"
|
||||
GENARRATIVE_LLM_PROVIDER="openai-compatible"
|
||||
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
|
||||
GENARRATIVE_LLM_API_KEY=""
|
||||
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
|
||||
|
||||
# Optional: enable upstream web search for RPG story text generation.
|
||||
RPG_LLM_WEB_SEARCH_ENABLED="true"
|
||||
@@ -125,13 +130,8 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
|
||||
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
|
||||
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
|
||||
|
||||
# APIMart Responses config for creative-agent text/multimodal understanding.
|
||||
APIMART_BASE_URL="https://api.apimart.ai/v1"
|
||||
APIMART_API_KEY="YOUR_APIMART_API_KEY"
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# VectorEngine GPT-image-2 / Gemini image generation config.
|
||||
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai"
|
||||
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
|
||||
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
|
||||
VECTOR_ENGINE_API_KEY=""
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
|
||||
|
||||
@@ -199,6 +199,10 @@ VITE_LLM_DEBUG_LOG="false"
|
||||
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
|
||||
VITE_DEBUG_MODE=""
|
||||
|
||||
# Optional: show the image editor right-side Agent entry.
|
||||
# Keep this off by default outside local development.
|
||||
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -42,6 +42,8 @@ LLM_DEBUG_LOG="true"
|
||||
# 注意:不要在客户端启用调试日志,避免敏感数据泄露
|
||||
# VITE_LLM_DEBUG_LOG="false"
|
||||
|
||||
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true
|
||||
|
||||
ALIYUN_OSS_BUCKET="xushi-dev"
|
||||
ALIYUN_OSS_REGION="oss-cn-beijing"
|
||||
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
|
||||
|
||||
+12
@@ -16,6 +16,18 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
|
||||
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
|
||||
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
|
||||
|
||||
**画布Agent对话**:
|
||||
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
|
||||
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
|
||||
|
||||
**画布Agent会话记录**:
|
||||
画布Agent对话的持久化形态:SpacetimeDB 表只存会话元数据(会话 ID、所属工程、属主、标题、软删标记、聊天记录 OSS 对象引用、时间戳),完整消息内容以会话粒度 JSON 对象存 OSS,追加消息即整体重写对象。
|
||||
_Avoid_: api-server 内存会话、消息全文入 SpacetimeDB 表、对话混入工程布局快照、每条消息一个 OSS 对象
|
||||
|
||||
**画布Agent对话附件**:
|
||||
画布Agent对话消息携带的图片参考,统一为画布资源 / 素材库对象引用(resourceId / assetId + 可选 objectKey),单条消息上限 9 张;上传图片若从对话入口进入,必须复用素材库 / 画布资源登记链路,在上传格未落地前只从已有画布资源和账号素材库选择,不存在只属于对话的第三种图。
|
||||
_Avoid_: 对话私有图片副本、内嵌 base64 附件、音视频附件
|
||||
|
||||
**画布资源**:
|
||||
图片画布工程中可被一个或多个图层引用的图片资源记录,保存 OSS 对象引用、上传 / 生成来源、提示词、模型、任务和尺寸等资源元数据;同一资源可以在工程布局中出现多次。
|
||||
_Avoid_: 图层位置、前端 hover / selected 状态、直接内嵌图片二进制
|
||||
|
||||
@@ -11,6 +11,17 @@ import type {
|
||||
AdminDatabaseTableListResponse,
|
||||
AdminDatabaseTableRowsQuery,
|
||||
AdminDatabaseTableRowsResponse,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
|
||||
AdminEditorAssetListQuery,
|
||||
AdminEditorAssetListResponse,
|
||||
AdminDirectUploadTicketPayload,
|
||||
AdminEditorShowcaseAssetResponse,
|
||||
AdminEditorShowcaseCampaignResponse,
|
||||
AdminEditorShowcaseDisplayRequest,
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -19,6 +30,8 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -54,6 +67,23 @@ interface AdminRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface AdminAssetReadUrlQuery {
|
||||
objectKey?: string | null;
|
||||
legacyPublicPath?: string | null;
|
||||
expireSeconds?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminAssetReadUrlResponse {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
signedUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
objectKey?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
@@ -293,6 +323,107 @@ export function updateAdminWorkVisibility(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
|
||||
return request<AdminAssetReadUrlResponse>(
|
||||
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminEditorAssets(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery = {},
|
||||
) {
|
||||
return request<AdminEditorAssetListResponse>(
|
||||
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminEditorShowcaseAssets(
|
||||
token: string,
|
||||
query: AdminEditorShowcaseListQuery = {},
|
||||
) {
|
||||
return request<AdminEditorShowcaseListResponse>(
|
||||
`/admin/api/editor-showcase/assets${buildEditorShowcaseListQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function reviewAdminEditorShowcaseAsset(
|
||||
token: string,
|
||||
payload: AdminEditorShowcaseReviewRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseAssetResponse>(
|
||||
'/admin/api/editor-showcase/assets/review',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminEditorShowcaseDisplay(
|
||||
token: string,
|
||||
payload: AdminEditorShowcaseDisplayRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseAssetResponse>(
|
||||
'/admin/api/editor-showcase/assets/display',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminEditorShowcaseCampaign(token: string) {
|
||||
return request<AdminEditorShowcaseCampaignResponse>(
|
||||
'/admin/api/editor-showcase/campaign',
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertAdminEditorShowcaseCampaign(
|
||||
token: string,
|
||||
payload: AdminUpsertEditorShowcaseCampaignRequest,
|
||||
) {
|
||||
return request<AdminEditorShowcaseCampaignResponse>(
|
||||
'/admin/api/editor-showcase/campaign',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadAdminEditorShowcaseCampaignImage(
|
||||
token: string,
|
||||
file: File,
|
||||
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
|
||||
const contentType = resolveAdminImageContentType(file);
|
||||
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
|
||||
'/admin/api/editor-showcase/campaign/image-upload-ticket',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
fileName: file.name.trim() || 'showcase-campaign.png',
|
||||
contentType,
|
||||
contentLength: file.size,
|
||||
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
},
|
||||
);
|
||||
await postAdminDirectUploadFile(response.upload, file);
|
||||
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
|
||||
return {
|
||||
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
|
||||
imageObjectKey: objectKey,
|
||||
legacyPublicPath: response.upload.legacyPublicPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
@@ -432,6 +563,77 @@ function buildRequestUrl(path: string) {
|
||||
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
|
||||
const params = new URLSearchParams();
|
||||
const objectKey = query.objectKey?.trim().replace(/^\/+/u, '') ?? '';
|
||||
const legacyPublicPath = query.legacyPublicPath?.trim() ?? '';
|
||||
if (objectKey) {
|
||||
params.set('objectKey', objectKey);
|
||||
} else if (legacyPublicPath) {
|
||||
params.set(
|
||||
'legacyPublicPath',
|
||||
`/${legacyPublicPath.replace(/^\/+/u, '')}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof query.expireSeconds === 'number' &&
|
||||
Number.isFinite(query.expireSeconds) &&
|
||||
query.expireSeconds > 0
|
||||
) {
|
||||
params.set('expireSeconds', String(Math.floor(query.expireSeconds)));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function resolveAdminImageContentType(file: File) {
|
||||
const declaredType = file.type.trim();
|
||||
if (declaredType.startsWith('image/')) {
|
||||
return declaredType;
|
||||
}
|
||||
const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1];
|
||||
if (extension === 'jpg' || extension === 'jpeg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (extension === 'png') {
|
||||
return 'image/png';
|
||||
}
|
||||
if (extension === 'webp') {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (extension === 'gif') {
|
||||
return 'image/gif';
|
||||
}
|
||||
return declaredType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function buildAdminDirectUploadFormData(
|
||||
upload: AdminDirectUploadTicketPayload,
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
formData.append('file', file, file.name);
|
||||
return formData;
|
||||
}
|
||||
|
||||
async function postAdminDirectUploadFile(
|
||||
upload: AdminDirectUploadTicketPayload,
|
||||
file: File,
|
||||
) {
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: buildAdminDirectUploadFormData(upload, file),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传活动卡图片失败:HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'eventKey', query.eventKey);
|
||||
@@ -471,6 +673,34 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'cursor', query.cursor);
|
||||
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
|
||||
appendQueryParam(params, 'keyword', query.keyword);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildEditorShowcaseListQuery(query: AdminEditorShowcaseListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'cursor', query.cursor);
|
||||
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
|
||||
appendQueryParam(params, 'reviewStatus', query.reviewStatus);
|
||||
appendQueryParam(params, 'submittedAfter', query.submittedAfter);
|
||||
appendQueryParam(params, 'submittedBefore', query.submittedBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function appendQueryParam(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
|
||||
@@ -346,6 +346,163 @@ export interface AdminUpdateWorkVisibilityResponse {
|
||||
entry: AdminWorkVisibilityEntryPayload;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
cursor?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
keyword?: string | null;
|
||||
createdAfter?: string | null;
|
||||
createdBefore?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetPayload {
|
||||
assetId: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
folderId: string;
|
||||
label: string;
|
||||
assetObjectId?: string | null;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceType: string;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
sourceResourceId?: string | null;
|
||||
thumbnailSrc?: string | null;
|
||||
generationCostMudPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListResponse {
|
||||
entries: AdminEditorAssetPayload[];
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseListQuery {
|
||||
cursor?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
reviewStatus?: string | null;
|
||||
submittedAfter?: string | null;
|
||||
submittedBefore?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseAssetPayload {
|
||||
showcaseId: string;
|
||||
assetId: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: Record<string, unknown> | null;
|
||||
generationCostMudPoints: number;
|
||||
refundMudPoints: number;
|
||||
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
|
||||
displayEnabled: boolean;
|
||||
likeCount: number;
|
||||
assetDeletedWhilePending: boolean;
|
||||
reviewedByAdminUserId?: string | null;
|
||||
reviewNote?: string | null;
|
||||
refundLedgerId?: string | null;
|
||||
refundCompletedAt?: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
approvedAt?: string | null;
|
||||
rejectedAt?: string | null;
|
||||
updatedAt: string;
|
||||
showcaseCategory?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseListResponse {
|
||||
entries: AdminEditorShowcaseAssetPayload[];
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseReviewRequest {
|
||||
showcaseId: string;
|
||||
reviewStatus: 'approved' | 'rejected';
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseDisplayRequest {
|
||||
showcaseId: string;
|
||||
displayEnabled: boolean;
|
||||
showcaseCategory?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseAssetResponse {
|
||||
entry: AdminEditorShowcaseAssetPayload;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseCampaignPayload {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
imageObjectKey?: string | null;
|
||||
prompt: string;
|
||||
author: string;
|
||||
costText: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminEditorShowcaseCampaignResponse {
|
||||
campaign?: AdminEditorShowcaseCampaignPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminUpsertEditorShowcaseCampaignRequest {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
imageObjectKey?: string | null;
|
||||
prompt: string;
|
||||
author: string;
|
||||
costText: string;
|
||||
}
|
||||
|
||||
export interface AdminDirectUploadTicketPayload {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
contentType?: string | null;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest {
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
|
||||
upload: AdminDirectUploadTicketPayload;
|
||||
}
|
||||
|
||||
export interface AdminUploadedEditorShowcaseCampaignImage {
|
||||
imageSrc: string;
|
||||
imageObjectKey: string;
|
||||
legacyPublicPath: string;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
|
||||
@@ -24,6 +24,8 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
@@ -245,6 +247,18 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'editor-assets' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
Images,
|
||||
Star,
|
||||
WalletCards,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
@@ -42,6 +44,8 @@ const routeIcons = {
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'editor-assets': Images,
|
||||
'creation-announcement': Megaphone,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
'work-visibility': Eye,
|
||||
|
||||
@@ -39,3 +39,23 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
|
||||
'#editor-generation-pricing',
|
||||
);
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-assets',
|
||||
label: '素材查询',
|
||||
hash: '#editor-assets',
|
||||
});
|
||||
expect(resolveAdminRoute('#editor-assets')).toBe('editor-assets');
|
||||
expect(routeHash('editor-assets')).toBe('#editor-assets');
|
||||
});
|
||||
|
||||
test('后台精选审核路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-showcase',
|
||||
label: '精选审核',
|
||||
hash: '#editor-showcase',
|
||||
});
|
||||
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
|
||||
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export type AdminRouteId =
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'editor-assets'
|
||||
| 'creation-announcement'
|
||||
| 'creation-entry'
|
||||
| 'work-visibility';
|
||||
@@ -34,6 +36,8 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
|
||||
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
|
||||
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
|
||||
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
|
||||
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
listAdminEditorAssets,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminEditorAssetPayload } from '../api/adminApiTypes';
|
||||
import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAdminAssetReadUrl: vi.fn(),
|
||||
listAdminEditorAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
const generatedAsset: AdminEditorAssetPayload = {
|
||||
assetId: 'asset-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '作者昵称',
|
||||
authorPublicUserCode: 'SY-00000042',
|
||||
folderId: 'folder-1',
|
||||
label: '角色形象 1',
|
||||
assetObjectId: 'asset-object-1',
|
||||
imageSrc: '/generated-character-drafts/editor/spec.png',
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'generated',
|
||||
prompt: '完整提示词内容',
|
||||
actualPrompt: null,
|
||||
model: 'gpt-image-2',
|
||||
provider: 'character',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: { style: 'clay' },
|
||||
sourceResourceId: 'resource-1',
|
||||
thumbnailSrc: null,
|
||||
generationCostMudPoints: 12,
|
||||
createdAt: '2026-07-04T10:00:00Z',
|
||||
updatedAt: '2026-07-04T10:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValue({
|
||||
entries: [generatedAsset],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
signedUrl:
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询展示作者昵称和陶泥号', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
||||
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
||||
expect(screen.queryByText('user-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('img', { name: '素材:角色形象 1' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('用户 ID'), {
|
||||
target: { value: 'user-1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('搜索'), {
|
||||
target: { value: '陶泥角色' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('开始时间'), {
|
||||
target: { value: '2026-07-01' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('结束时间'), {
|
||||
target: { value: '2026-07-04' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', {
|
||||
ownerUserId: 'user-1',
|
||||
keyword: '陶泥角色',
|
||||
createdAfter: '2026-07-01T00:00:00+08:00',
|
||||
createdBefore: '2026-07-04T23:59:59.999+08:00',
|
||||
limit: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询不展示分类筛选和分类列', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('分类')).toBeNull();
|
||||
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'https://signed.example.com/generated-character-drafts/editor/spec.png',
|
||||
);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledWith({
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
expireSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台素材查询音频素材使用统一封面缩略图', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
assetId: 'asset-audio-1',
|
||||
label: '胜利音效',
|
||||
imageSrc: '/generated-editor-audios/sfx.mp3',
|
||||
objectKey: 'generated-editor-audios/sfx.mp3',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '素材:胜利音效' });
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'/creation-home/audio-asset-cover.png',
|
||||
);
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后台素材查询格式化微秒时间文本', async () => {
|
||||
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...generatedAsset,
|
||||
createdAt: '1783231493.573727Z',
|
||||
updatedAt: '1783231493.573727Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
||||
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台素材查询可查看素材详情', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '素材详情' });
|
||||
expect(within(dialog).getByText('asset-1')).toBeTruthy();
|
||||
expect(within(dialog).getByText('1024 x 1024')).toBeTruthy();
|
||||
expect(within(dialog).getByText('12 泥点')).toBeTruthy();
|
||||
expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台素材查询可打开弹窗查看完整提示词', async () => {
|
||||
render(
|
||||
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完整提示词内容' }),
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
|
||||
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '关闭完整提示词' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '完整提示词' })).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,408 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminAssetReadUrl,
|
||||
getAdminEditorShowcaseCampaign,
|
||||
listAdminEditorShowcaseAssets,
|
||||
reviewAdminEditorShowcaseAsset,
|
||||
updateAdminEditorShowcaseDisplay,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminEditorShowcaseCampaign,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes';
|
||||
import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
getAdminAssetReadUrl: vi.fn(),
|
||||
getAdminEditorShowcaseCampaign: vi.fn(),
|
||||
listAdminEditorShowcaseAssets: vi.fn(),
|
||||
reviewAdminEditorShowcaseAsset: vi.fn(),
|
||||
updateAdminEditorShowcaseDisplay: vi.fn(),
|
||||
uploadAdminEditorShowcaseCampaignImage: vi.fn(),
|
||||
upsertAdminEditorShowcaseCampaign: vi.fn(),
|
||||
}));
|
||||
|
||||
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
|
||||
showcaseId: 'showcase-1',
|
||||
assetId: 'asset-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '作者昵称',
|
||||
authorPublicUserCode: 'SY-00000042',
|
||||
label: '角色形象 1',
|
||||
imageSrc: '/generated-character-drafts/editor/spec.png',
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: '完整提示词内容',
|
||||
actualPrompt: null,
|
||||
model: 'gpt-image-2',
|
||||
provider: 'character',
|
||||
taskId: 'task-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: { style: 'clay' },
|
||||
generationCostMudPoints: 12,
|
||||
refundMudPoints: 6,
|
||||
reviewStatus: 'pending',
|
||||
displayEnabled: false,
|
||||
likeCount: 0,
|
||||
assetDeletedWhilePending: false,
|
||||
reviewedByAdminUserId: null,
|
||||
reviewNote: null,
|
||||
refundLedgerId: null,
|
||||
refundCompletedAt: null,
|
||||
submittedAt: '2026-07-04T10:00:00Z',
|
||||
reviewedAt: null,
|
||||
approvedAt: null,
|
||||
rejectedAt: null,
|
||||
updatedAt: '2026-07-04T10:00:00Z',
|
||||
showcaseCategory: null,
|
||||
};
|
||||
|
||||
const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
|
||||
...pendingShowcaseAsset,
|
||||
showcaseId: 'showcase-2',
|
||||
assetId: 'asset-2',
|
||||
label: '角色形象 2',
|
||||
reviewStatus: 'approved',
|
||||
displayEnabled: true,
|
||||
showcaseCategory: 'characters',
|
||||
reviewedAt: '2026-07-04T10:10:00Z',
|
||||
approvedAt: '2026-07-04T10:10:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValue({
|
||||
entries: [pendingShowcaseAsset],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(getAdminEditorShowcaseCampaign).mockResolvedValue({
|
||||
campaign: {
|
||||
enabled: true,
|
||||
title: '活动卡',
|
||||
imageSrc: '/campaign.png',
|
||||
imageObjectKey: null,
|
||||
prompt: '活动提示词',
|
||||
author: '官方',
|
||||
costText: '12 泥点',
|
||||
updatedAt: '2026-07-04T10:00:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
|
||||
read: {
|
||||
objectKey: 'generated-character-drafts/editor/spec.png',
|
||||
signedUrl: 'https://signed.example.com/spec.png',
|
||||
expiresAt: '2026-07-04T11:00:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(reviewAdminEditorShowcaseAsset).mockResolvedValue({
|
||||
entry: {
|
||||
...pendingShowcaseAsset,
|
||||
reviewStatus: 'approved',
|
||||
displayEnabled: false,
|
||||
reviewedAt: '2026-07-04T10:10:00Z',
|
||||
approvedAt: '2026-07-04T10:10:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValue({
|
||||
entry: {
|
||||
...approvedShowcaseAsset,
|
||||
displayEnabled: false,
|
||||
},
|
||||
});
|
||||
vi.mocked(upsertAdminEditorShowcaseCampaign).mockResolvedValue({
|
||||
campaign: {
|
||||
enabled: false,
|
||||
title: '新活动卡',
|
||||
imageSrc: '/new-campaign.png',
|
||||
imageObjectKey: null,
|
||||
prompt: '新活动提示词',
|
||||
author: '官方',
|
||||
costText: '6 泥点',
|
||||
updatedAt: '2026-07-04T10:20:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValue({
|
||||
imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png',
|
||||
imageObjectKey:
|
||||
'generated-character-drafts/editor/showcase-campaign/card.png',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/showcase-campaign/card.png',
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核展示待审核素材和活动卡配置', async () => {
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('作者昵称')).toBeTruthy();
|
||||
expect(screen.getByText('SY-00000042')).toBeTruthy();
|
||||
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('12 泥点')).toBeTruthy();
|
||||
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
|
||||
expect(screen.getByLabelText('状态')).toHaveProperty('value', 'pending');
|
||||
expect(listAdminEditorShowcaseAssets).toHaveBeenCalledWith('admin-token', {
|
||||
ownerUserId: null,
|
||||
reviewStatus: 'pending',
|
||||
submittedAfter: null,
|
||||
submittedBefore: null,
|
||||
limit: 80,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核格式化微秒时间并显示素材名', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...pendingShowcaseAsset,
|
||||
submittedAt: '1783231493.573727Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('角色形象 1')).toBeTruthy();
|
||||
expect(screen.queryByText('showcase-1')).toBeNull();
|
||||
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
|
||||
});
|
||||
|
||||
test('后台精选审核音频素材使用统一封面缩略图', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...pendingShowcaseAsset,
|
||||
showcaseId: 'showcase-audio-1',
|
||||
assetId: 'asset-audio-1',
|
||||
label: '胜利音效',
|
||||
imageSrc: '/generated-editor-audios/sfx.mp3',
|
||||
objectKey: 'generated-editor-audios/sfx.mp3',
|
||||
assetKind: 'sound-effect',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = await screen.findByRole('img', { name: '精选素材:胜利音效' });
|
||||
expect(image.getAttribute('src')).toBe(
|
||||
'/creation-home/audio-asset-cover.png',
|
||||
);
|
||||
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后台精选审核可以通过素材并查看完整提示词', async () => {
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('审核备注:角色形象 1'), {
|
||||
target: { value: '质量通过' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', {
|
||||
showcaseId: 'showcase-1',
|
||||
reviewStatus: 'approved',
|
||||
reviewNote: '质量通过',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '完整提示词内容' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
|
||||
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后台精选审核可以切换展示和保存活动卡', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [approvedShowcaseAsset],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '隐藏' }));
|
||||
await waitFor(() => {
|
||||
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{
|
||||
showcaseId: 'showcase-2',
|
||||
displayEnabled: false,
|
||||
showcaseCategory: 'characters',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('标题'), {
|
||||
target: { value: '新活动卡' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('图片地址'), {
|
||||
target: { value: '/new-campaign.png' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('成本文案'), {
|
||||
target: { value: '6 泥点' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
title: '新活动卡',
|
||||
imageSrc: '/new-campaign.png',
|
||||
imageObjectKey: null,
|
||||
costText: '6 泥点',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选活动卡可以上传图片并保存 objectKey', async () => {
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByDisplayValue('活动卡');
|
||||
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('上传活动卡图片'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
file,
|
||||
);
|
||||
});
|
||||
expect(await screen.findByDisplayValue('/generated-character-drafts/editor/showcase-campaign/card.png')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('generated-character-drafts/editor/showcase-campaign/card.png')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/showcase-campaign/card.png',
|
||||
imageObjectKey:
|
||||
'generated-character-drafts/editor/showcase-campaign/card.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核已通过素材可以设置精选分类', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [
|
||||
{
|
||||
...approvedShowcaseAsset,
|
||||
showcaseCategory: null,
|
||||
displayEnabled: false,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
|
||||
entry: {
|
||||
...approvedShowcaseAsset,
|
||||
showcaseCategory: 'ui',
|
||||
displayEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
|
||||
target: { value: 'ui' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{
|
||||
showcaseId: 'showcase-2',
|
||||
displayEnabled: false,
|
||||
showcaseCategory: 'ui',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('后台精选审核清空精选分类时不会自动隐藏素材', async () => {
|
||||
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
|
||||
entries: [approvedShowcaseAsset],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
|
||||
entry: {
|
||||
...approvedShowcaseAsset,
|
||||
showcaseCategory: null,
|
||||
displayEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{
|
||||
showcaseId: 'showcase-2',
|
||||
displayEnabled: true,
|
||||
showcaseCategory: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,6 +553,49 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row .admin-field {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.admin-asset-query-filter-row .admin-field:last-child {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb {
|
||||
border: 1px solid #eaded2;
|
||||
border-radius: 8px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: #fffdf9;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.admin-asset-query-thumb-placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-query-action-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -905,6 +948,17 @@ button:disabled {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-table textarea {
|
||||
width: min(100%, 220px);
|
||||
min-height: 64px;
|
||||
border: 1px solid #dfc8b7;
|
||||
border-radius: 8px;
|
||||
color: #3d1f10;
|
||||
background: #fffdf9;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-muted-text {
|
||||
color: #a38f80;
|
||||
}
|
||||
@@ -939,6 +993,158 @@ button:disabled {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.admin-asset-query-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(1),
|
||||
.admin-asset-query-table td:nth-child(1) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(2),
|
||||
.admin-asset-query-table td:nth-child(2),
|
||||
.admin-asset-query-table th:nth-child(3),
|
||||
.admin-asset-query-table td:nth-child(3) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(4),
|
||||
.admin-asset-query-table td:nth-child(4) {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(5),
|
||||
.admin-asset-query-table td:nth-child(5) {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(6),
|
||||
.admin-asset-query-table td:nth-child(6) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table th:nth-child(1),
|
||||
.admin-showcase-review-table td:nth-child(1) {
|
||||
width: 8%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table td:nth-child(1) small {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table td:nth-child(4) select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table th:nth-child(2),
|
||||
.admin-showcase-review-table td:nth-child(2),
|
||||
.admin-showcase-review-table th:nth-child(3),
|
||||
.admin-showcase-review-table td:nth-child(3) {
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table th:nth-child(4),
|
||||
.admin-showcase-review-table td:nth-child(4),
|
||||
.admin-showcase-review-table th:nth-child(5),
|
||||
.admin-showcase-review-table td:nth-child(5),
|
||||
.admin-showcase-review-table th:nth-child(7),
|
||||
.admin-showcase-review-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table th:nth-child(6),
|
||||
.admin-showcase-review-table td:nth-child(6) {
|
||||
width: 21%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table th:nth-child(8),
|
||||
.admin-showcase-review-table td:nth-child(8) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-showcase-review-actions input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-showcase-campaign-grid {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-showcase-campaign-grid .admin-field {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.admin-hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-showcase-campaign-image-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-showcase-campaign-image-row input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-showcase-campaign-prompt {
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-dialog,
|
||||
.admin-asset-query-prompt-dialog {
|
||||
width: min(100%, 860px);
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading > div,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-dialog .admin-panel-heading span,
|
||||
.admin-asset-query-detail-dialog .admin-panel-heading span {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-full {
|
||||
max-height: min(68dvh, 520px);
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout > .admin-asset-query-thumb {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.admin-database-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
@@ -40,6 +40,11 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/assets': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/healthz': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -49,8 +49,8 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||
|
||||
Vendored
+2
-6
@@ -57,16 +57,12 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=false
|
||||
GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
|
||||
|
||||
APIMART_BASE_URL=
|
||||
APIMART_API_KEY=
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||
|
||||
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
|
||||
VECTOR_ENGINE_API_KEY=
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Unit]
|
||||
Description=Genarrative Rust API Server
|
||||
After=network-online.target spacetimedb.service
|
||||
Wants=network-online.target
|
||||
Wants=network-online.target genarrative-external-generation-controller.service
|
||||
Requires=spacetimedb.service
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
|
||||
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
|
||||
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。
|
||||
- [架构决策](./adr/):记录已经接受的跨模块、长期有效架构取舍;对应长期摘要仍应同步到 `docs/project-memory/shared-memory/decision-log.md`。
|
||||
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级;创作流程统一总计划见 [【玩法创作】创作流程统一总计划-2026-05-30.md](./planning/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E6%B5%81%E7%A8%8B%E7%BB%9F%E4%B8%80%E6%80%BB%E8%AE%A1%E5%88%92-2026-05-30.md)。
|
||||
- [参考目录](./reference/README.md):脚本/Function 速查入口。
|
||||
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
||||
@@ -32,6 +33,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
|
||||
|
||||
`/editor/canvas` 右侧画布 Agent 对话面板、会话持久化、SSE 事件、附件与生成落画板例外见 [【编辑器】画布Agent对话面板-2026-07-03.md](./【编辑器】画布Agent对话面板-2026-07-03.md);消息正文存 OSS、元数据进 SpacetimeDB 的取舍见 [【ADR】画布Agent会话消息存OSS-2026-07-03.md](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)。
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 【ADR】画布Agent会话消息存OSS-2026-07-03
|
||||
|
||||
状态:已接受
|
||||
|
||||
## 背景
|
||||
|
||||
画布Agent对话需要保存历史记录并支持新开会话。消息正文是流式追加、体量随对话增长的 JSON 数据;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,且对话流式更新频率远高于布局保存。
|
||||
|
||||
## 决策
|
||||
|
||||
- SpacetimeDB 表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、时间戳。
|
||||
- 完整消息内容以会话粒度 JSON 对象存 OSS(`editor-agent/{conversationId}.json`),追加消息即整体重写对象。
|
||||
- `editor-agent/` 是服务端内部消息文档前缀,只保存 `editor-agent/{conversationId}.json` 形态的会话级 JSON 文档;它不是浏览器直传前缀,也不是公开 generated 资源前缀。
|
||||
- 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。
|
||||
- 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。
|
||||
- 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。
|
||||
- Agent 工具调用失败时,仍保留 assistant 消息和 `failed` generation record,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不因 SSE 已返回错误而丢弃。
|
||||
- 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **独立 message 表(逐条入 SpacetimeDB)**:查询灵活,但消息文本长、附件结构嵌套,行数与行体积随聊天无界增长,挤占 SpacetimeDB 订阅与快照成本;对话消息没有跨会话结构化查询需求,放表里收益低。
|
||||
2. **消息塞进画布工程快照 payload**:省一张表,但每条消息都会触发整个工程快照保存,与画布布局保存互相竞争,流式期间冲突概率高。
|
||||
3. **每条消息一个 OSS 对象**:追加成本最低,但加载历史需要 N 次取对象或额外清单维护;对话消息量级小,整体读写实现最简单。
|
||||
|
||||
选择"表存引用 + OSS 存整段 JSON":与仓库既有「画布资源」「敲击音效」等 OSS 对象引用模式一致,加载历史一次取对象即可。
|
||||
|
||||
## 影响
|
||||
|
||||
- 消息写入是"读-改-写"整对象;当前由 `api-server` 按 `conversationId` 做会话内串行化,保证同一进程内同一会话不会并发覆写。若未来横向多实例部署,需要补充分布式锁、对象版本条件写或等价的跨实例并发控制。
|
||||
- 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。
|
||||
- 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。
|
||||
- 会话软删只打表标记,OSS 对象保留,便于恢复与审计。
|
||||
- 工具生成失败也会写入消息文档:assistant 消息可标记为失败 / 错误类型,`generations` 中保留 `failed` record、模型和错误信息,便于用户回看失败原因和后续排障。
|
||||
- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user