diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index 498545105..abf8f6f3a 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -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": "", + "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. diff --git a/.codex/skills/genarrative-external-editor-api/agents/openai.yaml b/.codex/skills/genarrative-external-editor-api/agents/openai.yaml index 4a23c1b13..a9b66dccb 100644 --- a/.codex/skills/genarrative-external-editor-api/agents/openai.yaml +++ b/.codex/skills/genarrative-external-editor-api/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Genarrative External Editor API" - short_description: "Auto-route 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 diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 71f2c261b..747d7ea28 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -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 `. - 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: diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index f6adcce12..6998db36c 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -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") diff --git a/.env b/.env index e1ed925f9..72324e587 100644 --- a/.env +++ b/.env @@ -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="" diff --git a/.env.example b/.env.example index 4d8a13877..32a49b796 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.env.local b/.env.local index 311781f60..cbc3ab9a0 100644 --- a/.env.local +++ b/.env.local @@ -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" diff --git a/CONTEXT.md b/CONTEXT.md index 93a8f0dda..e5467abc7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 状态、直接内嵌图片二进制 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index c08b03d85..10e0215ce 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + `/api/assets/read-url${buildAssetReadUrlQuery(query)}`, + ); +} + +export function listAdminEditorAssets( + token: string, + query: AdminEditorAssetListQuery = {}, +) { + return request( + `/admin/api/editor-assets${buildEditorAssetListQuery(query)}`, + { token }, + ); +} + +export function listAdminEditorShowcaseAssets( + token: string, + query: AdminEditorShowcaseListQuery = {}, +) { + return request( + `/admin/api/editor-showcase/assets${buildEditorShowcaseListQuery(query)}`, + { token }, + ); +} + +export function reviewAdminEditorShowcaseAsset( + token: string, + payload: AdminEditorShowcaseReviewRequest, +) { + return request( + '/admin/api/editor-showcase/assets/review', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function updateAdminEditorShowcaseDisplay( + token: string, + payload: AdminEditorShowcaseDisplayRequest, +) { + return request( + '/admin/api/editor-showcase/assets/display', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function getAdminEditorShowcaseCampaign(token: string) { + return request( + '/admin/api/editor-showcase/campaign', + { token }, + ); +} + +export function upsertAdminEditorShowcaseCampaign( + token: string, + payload: AdminUpsertEditorShowcaseCampaignRequest, +) { + return request( + '/admin/api/editor-showcase/campaign', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export async function uploadAdminEditorShowcaseCampaignImage( + token: string, + file: File, +): Promise { + const contentType = resolveAdminImageContentType(file); + const response = await request( + '/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( '/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, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 5d408ff65..8119889cb 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 | 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 | 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; +} + +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; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 6a09d347c..01dd05e86 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} + {routeId === 'editor-assets' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 708b9dda5..aa9bef9dd 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -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, diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index cd26f5405..832565db7 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -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'); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 870e31a19..3449f5fc1 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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'}, diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx new file mode 100644 index 000000000..346a7335d --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -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( + , + ); + + expect(await screen.findByText('作者昵称')).toBeTruthy(); + expect(screen.getByText('SY-00000042')).toBeTruthy(); + expect(screen.queryByText('user-1')).toBeNull(); +}); + +test('后台素材查询按用户、搜索和时间调用查询接口', async () => { + render( + , + ); + + 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( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + expect(screen.queryByLabelText('分类')).toBeNull(); + expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull(); +}); + +test('后台素材查询缩略图使用 objectKey 换签后展示', async () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + expect(screen.queryByText('1783231493.573727Z')).toBeNull(); +}); + +test('后台素材查询可查看素材详情', async () => { + render( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx new file mode 100644 index 000000000..177865f24 --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -0,0 +1,534 @@ +import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; + +import { + getAdminAssetReadUrl, + listAdminEditorAssets, +} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; +import type { + AdminEditorAssetListQuery, + AdminEditorAssetPayload, +} from '../api/adminApiTypes'; +import { handlePageError } from './pageUtils'; + +interface AdminEditorAssetQueryPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; + +export function AdminEditorAssetQueryPage({ + token, + onUnauthorized, +}: AdminEditorAssetQueryPageProps) { + const [entries, setEntries] = useState([]); + const [keyword, setKeyword] = useState(''); + const [ownerUserId, setOwnerUserId] = useState(''); + const [createdAfter, setCreatedAfter] = useState(''); + const [createdBefore, setCreatedBefore] = useState(''); + const [nextCursor, setNextCursor] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [detailEntry, setDetailEntry] = + useState(null); + const [promptPreview, setPromptPreview] = useState<{ + title: string; + prompt: string; + } | null>(null); + + useEffect(() => { + void refreshPage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, ownerUserId, keyword, createdAfter, createdBefore]); + + async function refreshPage() { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminEditorAssets(token, buildListQuery()); + setEntries(response.entries); + setNextCursor(response.nextCursor ?? null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function loadMore() { + if (!nextCursor || isLoadingMore) { + return; + } + setIsLoadingMore(true); + setErrorMessage(''); + try { + const response = await listAdminEditorAssets(token, { + ...buildListQuery(), + cursor: nextCursor, + }); + setEntries((current) => mergeAssetEntries(current, response.entries)); + setNextCursor(response.nextCursor ?? null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoadingMore(false); + } + } + + function buildListQuery(): AdminEditorAssetListQuery { + return { + ownerUserId: ownerUserId || null, + keyword: keyword.trim() || null, + createdAfter: dateInputToStartRfc3339(createdAfter), + createdBefore: dateInputToEndRfc3339(createdBefore), + limit: 80, + }; + } + + return ( +
+
+
+

素材查询

+
+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + {entries.map((entry) => { + const promptText = entry.prompt || entry.actualPrompt || '-'; + return ( + + + + + + + + + ); + })} + +
资源图创建时间用户提示词生成成本详情
+ + {entry.label || '-'} + {formatDateTime(entry.createdAt)} + {authorDisplayName(entry)} + {entry.authorPublicUserCode?.trim() || '-'} + + + {entry.generationCostMudPoints} 泥点 + +
+
+ + {!isLoading && entries.length === 0 ? ( +
暂无生成素材
+ ) : null} + {nextCursor ? ( + + ) : null} +
+ + {detailEntry ? ( + setDetailEntry(null)} + onPromptPreview={(entry, prompt) => + setPromptPreview({ + title: entry.label || entry.assetId, + prompt, + }) + } + /> + ) : null} + + {promptPreview ? ( +
+
+
+
+

完整提示词

+ {promptPreview.title} +
+ +
+
+              {promptPreview.prompt}
+            
+
+
+ ) : null} +
+ ); +} + +function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) { + const isAudio = isAdminAudioAsset(entry); + const imageSrc = useAdminResolvedAssetImageSrc( + isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc, + isAudio ? null : entry.objectKey, + ); + const alt = `素材:${entry.label || entry.assetId}`; + + return imageSrc ? ( + {alt} + ) : ( +
+ ); +} + +function isAdminAudioAsset(entry: AdminEditorAssetPayload) { + const assetKind = entry.assetKind?.trim() ?? ''; + return ( + assetKind === 'sound-effect' || + assetKind === 'background-music' || + assetKind === 'editor_uploaded_audio' || + /\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim()) + ); +} + +function AdminAssetDetailDialog({ + entry, + onClose, + onPromptPreview, +}: { + entry: AdminEditorAssetPayload; + onClose: () => void; + onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void; +}) { + const promptText = entry.prompt || entry.actualPrompt || ''; + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+
+ +
+ + {authorDisplayName(entry)} + {entry.authorPublicUserCode?.trim() || '-'} + + {entry.ownerUserId} + + {entry.width} x {entry.height} + + + {entry.generationCostMudPoints} 泥点 + + {entry.model || '-'} + + {entry.provider || '-'} + + {entry.taskId || '-'} + + {entry.objectKey || '-'} + + + {formatDateTime(entry.createdAt)} + + + {formatDateTime(entry.updatedAt)} + + + {promptText ? ( + + ) : ( + '-' + )} + + +
+                {formatGenerationInputs(entry.generationInputs)}
+              
+
+
+
+
+
+ ); +} + +function AdminInfoItem({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function useAdminResolvedAssetImageSrc( + imageSrc: string | null | undefined, + objectKey: string | null | undefined, +) { + const normalizedImageSrc = imageSrc?.trim() ?? ''; + const normalizedObjectKey = normalizeAdminObjectKey(objectKey); + const shouldResolve = + Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc); + const [resolvedImageSrc, setResolvedImageSrc] = useState( + shouldResolve ? '' : normalizedImageSrc, + ); + + useEffect(() => { + if (!normalizedImageSrc && !normalizedObjectKey) { + setResolvedImageSrc(''); + return; + } + if (!shouldResolve) { + setResolvedImageSrc(normalizedImageSrc); + return; + } + + let cancelled = false; + setResolvedImageSrc(''); + + void getAdminAssetReadUrl( + normalizedObjectKey + ? { + objectKey: normalizedObjectKey, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + } + : { + legacyPublicPath: normalizedImageSrc, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + }, + ) + .then(resolveAdminAssetReadSignedUrl) + .then((signedUrl) => { + if (!cancelled) { + setResolvedImageSrc(signedUrl); + } + }) + .catch(() => { + if (!cancelled) { + setResolvedImageSrc(''); + } + }); + + return () => { + cancelled = true; + }; + }, [normalizedImageSrc, normalizedObjectKey, shouldResolve]); + + return resolvedImageSrc; +} + +function normalizeAdminObjectKey(value: string | null | undefined) { + return value?.trim().replace(/^\/+/u, '') ?? ''; +} + +function isGeneratedLegacyPath(value: string) { + return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); +} + +function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) { + const read = response.read ?? response; + return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : ''; +} + +function mergeAssetEntries( + current: AdminEditorAssetPayload[], + incoming: AdminEditorAssetPayload[], +) { + const byId = new Map(); + [...current, ...incoming].forEach((entry) => byId.set(entry.assetId, entry)); + return [...byId.values()].sort( + (left, right) => + parseAdminTimestamp(right.createdAt) - + parseAdminTimestamp(left.createdAt) || + right.assetId.localeCompare(left.assetId), + ); +} + +function dateInputToStartRfc3339(value: string) { + return value ? `${value}T00:00:00+08:00` : null; +} + +function dateInputToEndRfc3339(value: string) { + return value ? `${value}T23:59:59.999+08:00` : null; +} + +function formatDateTime(value: string) { + const timestamp = parseAdminTimestamp(value); + if (!Number.isFinite(timestamp)) { + return value || '-'; + } + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp); +} + +function parseAdminTimestamp(value: string | null | undefined) { + const normalizedValue = value?.trim() ?? ''; + const secondsMicrosMatch = normalizedValue.match(/^(-?\d+)\.(\d{6})Z$/u); + if (secondsMicrosMatch) { + const seconds = Number(secondsMicrosMatch[1]); + const micros = Number(secondsMicrosMatch[2]); + if (Number.isFinite(seconds) && Number.isFinite(micros)) { + return seconds * 1000 + Math.floor(micros / 1000); + } + } + return Date.parse(normalizedValue); +} + +function authorDisplayName(entry: AdminEditorAssetPayload) { + return ( + entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-' + ); +} + +function formatGenerationInputs( + value: Record | null | undefined, +) { + if (!value) { + return '-'; + } + return JSON.stringify(value, null, 2); +} diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx new file mode 100644 index 000000000..4c384daee --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), { + target: { value: '' }, + }); + + await waitFor(() => { + expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith( + 'admin-token', + { + showcaseId: 'showcase-2', + displayEnabled: true, + showcaseCategory: '', + }, + ); + }); +}); diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx new file mode 100644 index 000000000..ebf45a2e5 --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.tsx @@ -0,0 +1,965 @@ +import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { + getAdminAssetReadUrl, + getAdminEditorShowcaseCampaign, + listAdminEditorShowcaseAssets, + reviewAdminEditorShowcaseAsset, + updateAdminEditorShowcaseDisplay, + uploadAdminEditorShowcaseCampaignImage, + upsertAdminEditorShowcaseCampaign, +} from '../api/adminApiClient'; +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; +import type { + AdminEditorShowcaseAssetPayload, + AdminEditorShowcaseCampaignPayload, + AdminEditorShowcaseListQuery, +} from '../api/adminApiTypes'; +import { handlePageError } from './pageUtils'; + +interface AdminEditorShowcaseReviewPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300; +const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; + +const showcaseCategoryOptions = [ + { value: 'characters', label: '角色' }, + { value: 'ui', label: 'UI' }, + { value: 'music', label: '音乐' }, + { value: 'marketing', label: '美宣' }, +]; + +const reviewStatusOptions = [ + { value: '', label: '全部' }, + { value: 'pending', label: '待审核' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已拒绝' }, +]; + +export function AdminEditorShowcaseReviewPage({ + token, + onUnauthorized, +}: AdminEditorShowcaseReviewPageProps) { + const [entries, setEntries] = useState([]); + const [reviewStatus, setReviewStatus] = useState('pending'); + const [ownerUserId, setOwnerUserId] = useState(''); + const [submittedAfter, setSubmittedAfter] = useState(''); + const [submittedBefore, setSubmittedBefore] = useState(''); + const [nextCursor, setNextCursor] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [reviewNotes, setReviewNotes] = useState>({}); + const [detailEntry, setDetailEntry] = + useState(null); + const [promptPreview, setPromptPreview] = useState<{ + title: string; + prompt: string; + } | null>(null); + const [campaignDraft, setCampaignDraft] = + useState({ + enabled: false, + title: '', + imageSrc: '', + prompt: '', + author: '', + costText: '', + imageObjectKey: null, + }); + const [isSavingCampaign, setIsSavingCampaign] = useState(false); + const [isUploadingCampaignImage, setIsUploadingCampaignImage] = + useState(false); + const campaignImageInputRef = useRef(null); + + useEffect(() => { + void refreshPage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, reviewStatus, ownerUserId, submittedAfter, submittedBefore]); + + useEffect(() => { + void getAdminEditorShowcaseCampaign(token) + .then((response) => { + if (response.campaign) { + setCampaignDraft(response.campaign); + } + }) + .catch((error: unknown) => + handlePageError(error, onUnauthorized, setErrorMessage), + ); + }, [token, onUnauthorized]); + + async function refreshPage() { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminEditorShowcaseAssets( + token, + buildListQuery(), + ); + setEntries(response.entries); + setNextCursor(response.nextCursor ?? null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function loadMore() { + if (!nextCursor || isLoadingMore) { + return; + } + setIsLoadingMore(true); + setErrorMessage(''); + try { + const response = await listAdminEditorShowcaseAssets(token, { + ...buildListQuery(), + cursor: nextCursor, + }); + setEntries((current) => mergeShowcaseEntries(current, response.entries)); + setNextCursor(response.nextCursor ?? null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoadingMore(false); + } + } + + function buildListQuery(): AdminEditorShowcaseListQuery { + return { + ownerUserId: ownerUserId || null, + reviewStatus: reviewStatus || null, + submittedAfter: dateInputToStartRfc3339(submittedAfter), + submittedBefore: dateInputToEndRfc3339(submittedBefore), + limit: 80, + }; + } + + async function submitReview( + entry: AdminEditorShowcaseAssetPayload, + nextStatus: 'approved' | 'rejected', + ) { + setErrorMessage(''); + try { + const response = await reviewAdminEditorShowcaseAsset(token, { + showcaseId: entry.showcaseId, + reviewStatus: nextStatus, + reviewNote: reviewNotes[entry.showcaseId]?.trim() || null, + }); + replaceEntry(response.entry); + setReviewNotes((current) => ({ + ...current, + [entry.showcaseId]: '', + })); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } + + async function toggleDisplay(entry: AdminEditorShowcaseAssetPayload) { + setErrorMessage(''); + try { + const response = await updateAdminEditorShowcaseDisplay(token, { + showcaseId: entry.showcaseId, + displayEnabled: !entry.displayEnabled, + showcaseCategory: entry.showcaseCategory ?? null, + }); + replaceEntry(response.entry); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } + + async function updateCategory( + entry: AdminEditorShowcaseAssetPayload, + showcaseCategory: string, + ) { + setErrorMessage(''); + const nextCategory = showcaseCategory.trim(); + try { + const response = await updateAdminEditorShowcaseDisplay(token, { + showcaseId: entry.showcaseId, + displayEnabled: entry.displayEnabled, + showcaseCategory: nextCategory, + }); + replaceEntry(response.entry); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } + } + + async function saveCampaign() { + setIsSavingCampaign(true); + setErrorMessage(''); + try { + const response = await upsertAdminEditorShowcaseCampaign(token, { + enabled: campaignDraft.enabled, + title: campaignDraft.title, + imageSrc: campaignDraft.imageSrc, + imageObjectKey: campaignDraft.imageObjectKey ?? null, + prompt: campaignDraft.prompt, + author: campaignDraft.author, + costText: campaignDraft.costText, + }); + if (response.campaign) { + setCampaignDraft(response.campaign); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSavingCampaign(false); + } + } + + async function handleCampaignImageFile(file: File | null | undefined) { + if (!file) { + return; + } + setIsUploadingCampaignImage(true); + setErrorMessage(''); + try { + const upload = await uploadAdminEditorShowcaseCampaignImage(token, file); + setCampaignDraft((current) => ({ + ...current, + imageSrc: upload.imageSrc, + imageObjectKey: upload.imageObjectKey, + })); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsUploadingCampaignImage(false); + if (campaignImageInputRef.current) { + campaignImageInputRef.current.value = ''; + } + } + } + + function replaceEntry(entry: AdminEditorShowcaseAssetPayload) { + setEntries((current) => + current.map((item) => + item.showcaseId === entry.showcaseId ? entry : item, + ), + ); + setDetailEntry((current) => + current?.showcaseId === entry.showcaseId ? entry : current, + ); + } + + return ( +
+
+
+

精选审核

+
+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + {entries.map((entry) => { + const promptText = entry.prompt || entry.actualPrompt || '-'; + return ( + + + + + + + + + + + ); + })} + +
资源图提交时间作者分类状态提示词成本 / 返还审核
+ + {entry.label || '-'} + {formatDateTime(entry.submittedAt)} + {authorDisplayName(entry)} + {entry.authorPublicUserCode?.trim() || '-'} + + {entry.reviewStatus === 'approved' ? ( + + ) : ( + '-' + )} + + + {reviewStatusLabel(entry.reviewStatus)} + + {entry.reviewStatus === 'approved' ? ( + + {entry.displayEnabled ? '展示中' : '未展示'} + + ) : null} + + + + {entry.generationCostMudPoints} 泥点 + {entry.refundMudPoints} 泥点 + +
+ {entry.reviewStatus === 'pending' ? ( + <> + + setReviewNotes((current) => ({ + ...current, + [entry.showcaseId]: event.target.value, + })) + } + /> + + + + ) : null} + {entry.reviewStatus === 'approved' ? ( + + ) : null} + +
+
+
+ + {!isLoading && entries.length === 0 ? ( +
暂无精选审核素材
+ ) : null} + {nextCursor ? ( + + ) : null} +
+ +
+
+
+

精选活动卡

+
+ +
+
+ + + + + +