From 7048c05ebfd9c233df419146bcf5ae93af45ef1f Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 3 Jul 2026 15:19:44 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E7=94=9F=E6=88=90=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新外部编辑器 API skill 的画板、素材库和美术规范引导。 扩展 Python helper,自动准备画板会话、长超时生成并补齐角色动画素材入库。 将新建项目和生成任务 ID 前缀调整为 proj/task,并保留旧 ID 兼容。 更新 OpenAPI 说明和项目记忆。 --- .../genarrative-external-editor-api/SKILL.md | 78 ++++- .../agents/openai.yaml | 4 +- .../references/api-selection.md | 20 +- .../scripts/genarrative_external_api.py | 301 +++++++++++++++++- .../genarrative-external-v1.openapi.json | 2 +- .../shared-memory/decision-log.md | 16 + .../api-server/src/ai_generation_drafts.rs | 2 +- .../api-server/src/editor_generation_queue.rs | 2 +- .../crates/api-server/src/editor_project.rs | 7 +- server-rs/crates/api-server/src/jump_hop.rs | 2 +- .../crates/api-server/src/puzzle/handlers.rs | 12 +- .../crates/api-server/src/puzzle_clear.rs | 2 +- .../crates/api-server/src/wooden_fish.rs | 2 +- server-rs/crates/module-ai/src/domain/ids.rs | 2 +- server-rs/crates/module-ai/src/tests.rs | 5 + 15 files changed, 424 insertions(+), 33 deletions(-) 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/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 1815df5d6..fd8dee488 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3,7 +3,7 @@ "info": { "title": "陶泥儿外部编辑器 OpenAPI", "version": "1.0.0", - "description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。" + "description": "外部系统调用陶泥儿图片画布项目、画布布局、素材库,以及图片、视频、音效、音乐等编辑器素材生成/编辑能力的 v1 契约。新建 projectId 使用 proj- 前缀,新建 taskId / operationId 使用 task- 前缀;历史 editor-project-*、aitask_*、extgen-* ID 仍可作为既有资源标识传入。" }, "servers": [ { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a3f2b474..9ac65580a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库 + +- 背景:外部 API 面向美术 Agent 使用时,需要从自然语言自动选路,并保证生成结果不会只停留在接口回包里;同时后续素材生成需要复用已抽象出的美术规范,避免每次重新追问风格要求。 +- 决策:外部编辑器 API skill 在新对话首个生成前先确认画布名称,并创建 / 复用同名画布项目和素材库文件夹。所有外部生成请求默认携带 `projectId`、`assetFolderId`、素材展示名和 `canvasCompletion`,使结果进入画布和素材库;角色动画端点当前不直接返回 `asset`,由 helper 在动画成功后用首帧补建素材库记录。skill 先把用户需求抽象为可复用美术规范,缺少目标素材必需信息时再追问;已有规范且用户未提出新规范时自动复用。 +- 影响范围:`.codex/skills/genarrative-external-editor-api`、外部 OpenAPI 使用说明、外部画布生成集成方。 +- 验证方式:运行 skill 校验、helper 自测、Python 编译检查、编码检查和 `git diff --check`;真实线上生成 smoke 需要本机 `~/.config/genarrative/external-editor-api.json` 中有有效 API Key。 +- 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 + +## 2026-07-03 新建项目与 AI 任务 ID 使用短前缀 + +- 背景:新建外部画布项目和 AI 任务 ID 需要统一以 `proj`、`task` 开头,同时保留旧 ID 兼容读取和路由。 +- 决策:新建 editor project ID 前缀改为 `proj-`,新建 AI task / 生成任务 / 草稿任务 ID 前缀改为 `task-`。路由和读写仍按字符串处理,不新增拒绝 `editor-project-*`、`aitask_*` 或 `extgen-*` 的校验,历史数据继续兼容。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/module-ai/src/domain/ids.rs`、`server-rs/crates/api-server/src/editor_generation_queue.rs`、玩法外部生成入队路径、外部编辑器 API 新建项目返回值、AI 任务创建链路。 +- 验证方式:运行 `cargo test -p module-ai --manifest-path server-rs/Cargo.toml`、定向 api-server editor project 测试、编码检查和 `git diff --check`。 +- 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`。 + ## 2026-07-01 认证工作集只经 typed projection 同步正式表 - 背景:同手机号重复账号、兑换码白名单错配和微信资料不回写暴露出 `module-auth` 内存工作集、`auth_store_snapshot` 和正式认证表之间仍有历史互刷路径;旧 JSON 快照会把过期手机号索引或用户资料重新带回运行态。 diff --git a/server-rs/crates/api-server/src/ai_generation_drafts.rs b/server-rs/crates/api-server/src/ai_generation_drafts.rs index aa3ac4fde..6626af8d2 100644 --- a/server-rs/crates/api-server/src/ai_generation_drafts.rs +++ b/server-rs/crates/api-server/src/ai_generation_drafts.rs @@ -33,7 +33,7 @@ impl AiGenerationDraftContext { Self { // 生成过程草稿使用稳定 task_id,保证同一模板会话操作重试时能继续定位已有内容。 task_id: format!( - "aitask_draft_{normalized_template}_{normalized_session}_{normalized_operation}" + "task-draft-{normalized_template}-{normalized_session}-{normalized_operation}" ), owner_user_id: owner_user_id.trim().to_string(), request_label: request_label.trim().to_string(), diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index ac72bb7aa..3d6a24b52 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -45,7 +45,7 @@ pub(crate) async fn enqueue_editor_generation_job( where T: Serialize, { - let job_id = build_prefixed_uuid_id("extgen-"); + let job_id = build_prefixed_uuid_id("task-"); let request_payload_json = serde_json::to_string(payload).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index bf08ec167..41f8ef617 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -72,7 +72,7 @@ use crate::{ const EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE: usize = 36; -pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "editor-project-"; +pub(crate) const EDITOR_PROJECT_ID_PREFIX: &str = "proj-"; pub(crate) const EDITOR_RESOURCE_ID_PREFIX: &str = "editor-resource-"; pub(crate) const EDITOR_ASSET_FOLDER_ID_PREFIX: &str = "editor-asset-folder-"; pub(crate) const EDITOR_ASSET_ID_PREFIX: &str = "editor-asset-"; @@ -4963,6 +4963,11 @@ mod tests { ) } + #[test] + fn editor_project_id_prefix_uses_proj_for_new_ids() { + assert_eq!(EDITOR_PROJECT_ID_PREFIX, "proj-"); + } + #[test] fn editor_record_media_src_prefers_object_key_over_legacy_inline_media() { assert_eq!( diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 7a2ed06f2..a689909d0 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -300,7 +300,7 @@ async fn enqueue_jump_hop_compile_draft_job( owner_user_id: &str, payload: JumpHopActionRequest, ) -> Result { - let job_id = build_prefixed_uuid_id("extgen-"); + let job_id = build_prefixed_uuid_id("task-"); let now_micros = current_utc_micros(); let price_mud_points = resolve_jump_hop_generation_points_cost(state).await; let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload { diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index e9336e76e..6e91df017 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -764,7 +764,7 @@ pub async fn execute_puzzle_agent_action( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { - operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_id: build_prefixed_uuid_id("task-inline-"), operation_type: "compile_puzzle_draft".to_string(), status: "completed".to_string(), phase_label: "首关拼图草稿".to_string(), @@ -803,7 +803,7 @@ pub async fn execute_puzzle_agent_action( })), ) })?; - let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let external_generation_job_id = build_prefixed_uuid_id("task-"); let job = state .spacetime_client() .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { @@ -1002,7 +1002,7 @@ pub async fn execute_puzzle_agent_action( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { - operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_id: build_prefixed_uuid_id("task-inline-"), operation_type: "generate_puzzle_images".to_string(), status: "completed".to_string(), phase_label: "拼图图片生成".to_string(), @@ -1025,7 +1025,7 @@ pub async fn execute_puzzle_agent_action( })), ) })?; - let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let external_generation_job_id = build_prefixed_uuid_id("task-"); let source_entity_id = target_level_id .as_deref() .map(|level_id| format!("{session_id}:{level_id}")) @@ -1145,7 +1145,7 @@ pub async fn execute_puzzle_agent_action( Some(&request_context), PuzzleAgentActionResponse { operation: PuzzleAgentOperationResponse { - operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_id: build_prefixed_uuid_id("task-inline-"), operation_type: "generate_puzzle_ui_background".to_string(), status: "completed".to_string(), phase_label: "UI 背景图生成".to_string(), @@ -1168,7 +1168,7 @@ pub async fn execute_puzzle_agent_action( })), ) })?; - let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let external_generation_job_id = build_prefixed_uuid_id("task-"); let source_entity_id = target_level_id .as_deref() .map(|level_id| format!("{session_id}:{level_id}")) diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 139bd161d..77f50d6db 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -267,7 +267,7 @@ async fn enqueue_puzzle_clear_compile_draft_job( author_display_name: &str, payload: PuzzleClearActionRequest, ) -> Result { - let job_id = build_prefixed_uuid_id("extgen-"); + let job_id = build_prefixed_uuid_id("task-"); let now_micros = current_utc_micros(); let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload { session_id: session_id.to_string(), diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 5b8cc0243..ba6214920 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -285,7 +285,7 @@ async fn enqueue_wooden_fish_generate_image_assets_job( author_display_name: &str, payload: WoodenFishActionRequest, ) -> Result { - let job_id = build_prefixed_uuid_id("extgen-"); + let job_id = build_prefixed_uuid_id("task-"); let now_micros = current_utc_micros(); let price_mud_points = resolve_wooden_fish_generation_points_cost(state).await; let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload { diff --git a/server-rs/crates/module-ai/src/domain/ids.rs b/server-rs/crates/module-ai/src/domain/ids.rs index b3ecc4ca2..9f72a16d1 100644 --- a/server-rs/crates/module-ai/src/domain/ids.rs +++ b/server-rs/crates/module-ai/src/domain/ids.rs @@ -5,7 +5,7 @@ use shared_kernel::{ use super::types::AiTaskStageKind; -pub const AI_TASK_ID_PREFIX: &str = "aitask_"; +pub const AI_TASK_ID_PREFIX: &str = "task-"; pub const AI_TASK_STAGE_ID_PREFIX: &str = "aistage_"; pub const AI_RESULT_REF_ID_PREFIX: &str = "aires_"; pub const AI_TEXT_CHUNK_ID_PREFIX: &str = "aichunk_"; diff --git a/server-rs/crates/module-ai/src/tests.rs b/server-rs/crates/module-ai/src/tests.rs index e6d2e5dc6..766320035 100644 --- a/server-rs/crates/module-ai/src/tests.rs +++ b/server-rs/crates/module-ai/src/tests.rs @@ -50,6 +50,11 @@ fn generate_ai_task_stage_id_contains_task_and_stage_slug() { assert_eq!(stage_id, "aistage_aitask_demo_normalize_result"); } +#[test] +fn generate_ai_task_id_uses_task_prefix_for_new_ids() { + assert!(generate_ai_task_id(1_713_680_000_000_000).starts_with("task-")); +} + #[test] fn create_and_start_task_updates_status() { let service = build_service(); From 571a46ac2983fe2ed1c0b93c826532677d907038 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 4 Jul 2026 10:24:42 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将各玩法作品可见性默认值改为关闭。 发布与重新发布流程按首次发布和历史已发布状态保留可见性。 收紧公开详情、互动与正式运行态的可见性门禁。 更新后端公开作品契约文档与项目记忆。 --- .../shared-memory/decision-log.md | 8 ++++ ...架构】统一公开作品ReadModel设计-2026-05-26.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 24 ++++++------ .../spacetime-module/src/bark_battle.rs | 17 ++++++++- .../src/bark_battle/tables.rs | 4 +- .../spacetime-module/src/big_fish/assets.rs | 20 ++++++++-- .../spacetime-module/src/big_fish/runtime.rs | 2 +- .../spacetime-module/src/big_fish/session.rs | 14 +++---- .../spacetime-module/src/big_fish/tables.rs | 4 +- .../spacetime-module/src/custom_world.rs | 27 ++++++++++---- .../crates/spacetime-module/src/jump_hop.rs | 37 +++++++++++++++---- .../spacetime-module/src/jump_hop/tables.rs | 4 +- .../crates/spacetime-module/src/match3d.rs | 18 +++++++-- .../spacetime-module/src/match3d/tables.rs | 4 +- .../crates/spacetime-module/src/migration.rs | 12 +++--- .../crates/spacetime-module/src/puzzle.rs | 17 ++++++--- .../spacetime-module/src/puzzle_clear.rs | 26 ++++++++++++- .../src/puzzle_clear/tables.rs | 4 +- .../spacetime-module/src/square_hole.rs | 27 ++++++++++++-- .../src/square_hole/tables.rs | 4 +- .../spacetime-module/src/visual_novel.rs | 31 +++++++++++++--- .../spacetime-module/src/wooden_fish.rs | 28 +++++++++++++- .../src/wooden_fish/tables.rs | 4 +- 23 files changed, 255 insertions(+), 85 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9ac65580a..389432a1d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-03 作品公开默认关闭 + +- 背景:作品发布完成不应默认进入公开广场 / 公开详情 / 公开互动消费路径,需要先由后台可见性开关明确开启。 +- 决策:各玩法源表的 `visible` 新作品默认值改为 `false`;从草稿首次发布时仍保持 `false`,只有已发布作品再次发布 / 更新时才保留既有 `visible`。公开列表、详情、点赞、Remix 和正式公开 runtime 继续按 `Published + visible=true` 判断。旧迁移数据缺少 `visible` 时仍补 `true`,避免历史已公开作品被批量隐藏。 +- 影响范围:`spacetime-module` 各玩法作品表、发布 / 编译 / Remix 写入路径、统一公开作品 read model、后台作品可见性管理。 +- 验证方式:运行 `cargo fmt --manifest-path server-rs/Cargo.toml --all`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 + ## 2026-07-03 外部编辑器 API 生成默认写入画布与素材库 - 背景:外部 API 面向美术 Agent 使用时,需要从自然语言自动选路,并保证生成结果不会只停留在接口回包里;同时后续素材生成需要复用已抽象出的美术规范,避免每次重新追问风格要求。 diff --git a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md index ac1c723d8..ff8f6f236 100644 --- a/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md +++ b/docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md @@ -39,7 +39,7 @@ - `sort_time_micros` - `detail_payload_json` -作品源表新增 `visible` 可见性字段,默认 `true`。`visible` 属于源表 / source view 过滤条件,不作为统一公开契约默认返回字段;当 `visible=false` 时,对应作品不得进入 `public_work_gallery_entry` 和 `public_work_detail_entry`。 +作品源表新增 `visible` 可见性字段,新作品默认 `false`。`visible` 属于源表 / source view 过滤条件,不作为统一公开契约默认返回字段;当 `visible=false` 时,对应作品不得进入 `public_work_gallery_entry` 和 `public_work_detail_entry`。 其中 `detail_payload_json` 只承载平台详情页展示扩展,不承载正式 runtime 配置、玩法规则或草稿真相。 @@ -76,7 +76,7 @@ - 旧 view 保留,不删除。 - 旧 view 退到底层 source / 兼容职责。 - 新 `public_work_*` view 是 `api-server` 公开列表 / 详情的统一主读模型。 -- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移默认补 `visible=true`,避免历史作品被误隐藏。 +- 各玩法 source view 只暴露 `visible=true` 的已发布作品;旧数据迁移仍补 `visible=true`,避免历史作品被误隐藏;新发布作品默认不公开,需后台开启后才进入公开消费路径。 - RPG / 自定义世界旧数据可能缺少 `published_at`。统一公开详情可以用 `updated_at` 作为展示和排序兜底;点赞、游玩、Remix 等写入路径也必须按 `publication_status=Published + visible=true + 未删除` 判断作品存在,不能额外要求 `published_at` 非空。 - 临时运行约束:SpacetimeDB 2.2 下抓大鹅 `match_3_d_gallery_view` 的 `publication_status` 索引过滤在源表更新触发统一 view 刷新时可能初始化 panic;为避免后台隐藏作品打爆 module instance,统一 `public_work_*` view 暂不级联抓大鹅 source view,抓大鹅公开入口先保留玩法专用路径。后续应以 source projection 表替代索引 view 后再重新并入统一 read model。 - 旧 `/api/runtime//gallery` 响应 shape 保持兼容,由 BFF mapper 把统一 cache 再映射回当前 DTO。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dbf566438..e999cd218 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -313,7 +313,7 @@ npm run check:server-rs-ddd - Rust 结构体:`BarkBattlePublishedConfigRow` - 源码:`server-rs/crates/spacetime-module/src/bark_battle/tables.rs` -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `bark_battle_runtime_run` @@ -350,7 +350,7 @@ npm run check:server-rs-ddd - Rust 结构体:`BigFishCreationSession` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` - 索引:`by_big_fish_session_owner_user_id`、`by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `big_fish_event` @@ -414,13 +414,13 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldGalleryEntry` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` - 作用:自定义世界公开 source 读模型。统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该投影并映射成跨玩法契约;`/api/runtime/custom-world-gallery` 保留旧 HTTP shape,并从统一 public cache 映射回旧 DTO。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `custom_world_profile` - Rust 结构体:`CustomWorldProfile` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 - 兼容约束:历史公开 RPG / 自定义世界 profile 可能存在 `publication_status=Published` 但 `published_at=None`。公开详情、点赞、游玩、Remix 和 `custom_world_gallery_entry` 同步都以 `Published + deleted_at=None + visible=true` 判断作品可公开互动;展示和 gallery 同步时间在 `published_at` 缺失时回退 `updated_at`,不得仅因 `published_at` 为空返回“已发布作品不存在”。 ### `custom_world_session` @@ -527,7 +527,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` - 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `wooden_fish_agent_session` @@ -563,7 +563,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/wooden_fish.rs` - 说明:敲木鱼公开详情兼容投影,包含敲击物图案、背景环境图、主题返回按钮图、敲击音效和飘字配置;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `match3d_agent_message` @@ -593,7 +593,7 @@ npm run check:server-rs-ddd - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/match3d.rs` - 说明:抓大鹅公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `npc_state` @@ -740,7 +740,7 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` - 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime;默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime;新作品默认 `false`,旧迁移数据按历史公开默认补 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。 ### `puzzle_clear_agent_session` @@ -753,7 +753,7 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleClearWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs` - 说明:拼消消作品 profile 表,保存中央底图资产、4 张素材工作表切片后合成的最终 atlas、35 个复合图案组、95 个 1x1 卡牌切片、卡背占位图、发布状态、可见性和基础 play count;公开列表 / 详情只通过 read model 消费,不让前端直接订阅源表。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `puzzle_clear_runtime_run` @@ -842,7 +842,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; 结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。 统一公开作品 BFF 路由是 `GET /api/public-works` 与 `GET /api/public-works/{publicWorkCode}`,响应契约由 `shared-contracts::public_work` 和 `packages/shared/src/contracts/publicWork.ts` 共同维护。前端首期仍走 BFF HTTP,不直接订阅 SpacetimeDB;后续若允许浏览器直连订阅,也只能订阅 `public_work_gallery_entry` / `public_work_detail_entry` 这类稳定公开 read model,不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表。设计细节见 `docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `quest_log` @@ -895,7 +895,7 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/square_hole.rs` - 说明:方洞挑战公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 ### `story_event` @@ -972,4 +972,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 -- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 diff --git a/server-rs/crates/spacetime-module/src/bark_battle.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index 0bf4c65a1..a56905d26 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -272,7 +272,7 @@ fn publish_bark_battle_work_tx( created_at: published_at, updated_at: published_at, published_at, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }; let mut published = published; match ctx @@ -283,6 +283,7 @@ fn publish_bark_battle_work_tx( { Some(existing) => { published.created_at = existing.created_at; + published.visible = existing.visible; ctx.db .bark_battle_published_config() .work_id() @@ -413,11 +414,20 @@ fn get_bark_battle_runtime_config_tx( .work_id() .find(&input.work_id) .ok_or_else(|| "bark_battle published config 不存在".to_string())?; + let requester_is_owner = input + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|owner_user_id| !owner_user_id.is_empty()) + .is_some_and(|owner_user_id| owner_user_id == row.owner_user_id); if let Some(owner_user_id) = input.owner_user_id.as_deref() { - if !owner_user_id.trim().is_empty() && row.owner_user_id != owner_user_id.trim() { + if !owner_user_id.trim().is_empty() && !requester_is_owner { return Err("bark_battle runtime config owner 不匹配".to_string()); } } + if !requester_is_owner && !row.visible { + return Err("bark_battle runtime config 只开放已公开作品".to_string()); + } Ok(runtime_config_snapshot(&row)) } @@ -435,6 +445,9 @@ fn start_bark_battle_run_tx( .work_id() .find(&input.work_id) .ok_or_else(|| "bark_battle published config 不存在".to_string())?; + if published.owner_user_id != input.owner_user_id && !published.visible { + return Err("bark_battle run 只能启动已公开作品".to_string()); + } if published.config_version != input.config_version || published.ruleset_version != input.ruleset_version || published.difficulty_preset != input.difficulty_preset diff --git a/server-rs/crates/spacetime-module/src/bark_battle/tables.rs b/server-rs/crates/spacetime-module/src/bark_battle/tables.rs index e3130fff1..bb9398eb5 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/tables.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = bark_battle_draft_config, @@ -42,7 +42,7 @@ pub struct BarkBattlePublishedConfigRow { pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, pub(crate) published_at: Timestamp, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 9c7fda1fd..8bdd994e6 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -1,4 +1,6 @@ -use crate::big_fish::tables::{big_fish_asset_slot, big_fish_creation_session}; +use crate::big_fish::tables::{ + WORK_VISIBLE_DEFAULT, big_fish_asset_slot, big_fish_creation_session, +}; use crate::*; use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness}; @@ -129,7 +131,13 @@ pub(crate) fn generate_big_fish_asset_tx( published_at: session.published_at, created_at: session.created_at, updated_at, - visible: session.visible, + visible: if session.stage == BigFishCreationStage::Published + || session.published_at.is_some() + { + session.visible + } else { + WORK_VISIBLE_DEFAULT + }, }; replace_big_fish_session(ctx, &session, next_session); for event in readiness.events { @@ -201,7 +209,13 @@ pub(crate) fn publish_big_fish_game_tx( published_at: Some(published_at), created_at: session.created_at, updated_at: published_at, - visible: session.visible, + visible: if session.stage == BigFishCreationStage::Published + || session.published_at.is_some() + { + session.visible + } else { + WORK_VISIBLE_DEFAULT + }, }; replace_big_fish_session(ctx, &session, next_session); for event in readiness.events { diff --git a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs index 6e0f56f8d..f27726577 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs @@ -154,7 +154,7 @@ fn ensure_big_fish_session_playable( if session.owner_user_id == player_user_id { return Ok(()); } - if session.stage == BigFishCreationStage::Published { + if session.stage == BigFishCreationStage::Published && session.visible { return Ok(()); } Err("未发布的大鱼吃小鱼作品不允许非作者启动运行态".to_string()) diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 359afe4fc..ea1239f8e 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -1,5 +1,5 @@ use crate::big_fish::tables::{ - big_fish_agent_message, big_fish_creation_session, big_fish_runtime_run, + WORK_VISIBLE_DEFAULT, big_fish_agent_message, big_fish_creation_session, big_fish_runtime_run, }; use crate::runtime::{ ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, @@ -285,7 +285,7 @@ pub(crate) fn create_big_fish_session_tx( published_at: None, created_at, updated_at: created_at, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { message_id: input.welcome_message_id, @@ -338,7 +338,7 @@ pub(crate) fn list_big_fish_works_tx( .iter() .filter(|row| { if input.published_only { - return row.stage == BigFishCreationStage::Published; + return row.stage == BigFishCreationStage::Published && row.visible; } should_include_big_fish_work(ctx, row) @@ -698,7 +698,7 @@ pub(crate) fn record_big_fish_play_tx( .big_fish_creation_session() .session_id() .find(&input.session_id) - .filter(|row| row.stage == BigFishCreationStage::Published) + .filter(|row| row.stage == BigFishCreationStage::Published && row.visible) .ok_or_else(|| "big_fish 已发布作品不存在".to_string())?; let played_at = Timestamp::from_micros_since_unix_epoch(input.played_at_micros); let draft = session @@ -795,7 +795,7 @@ pub(crate) fn record_big_fish_like_tx( .big_fish_creation_session() .session_id() .find(&session_id.to_string()) - .filter(|row| row.stage == BigFishCreationStage::Published) + .filter(|row| row.stage == BigFishCreationStage::Published && row.visible) .ok_or_else(|| "big_fish 已发布作品不存在,无法点赞".to_string())?; let inserted_like = record_public_work_like( ctx, @@ -875,7 +875,7 @@ fn remix_big_fish_work_tx( .big_fish_creation_session() .session_id() .find(&source_session_id.to_string()) - .filter(|row| row.stage == BigFishCreationStage::Published) + .filter(|row| row.stage == BigFishCreationStage::Published && row.visible) .ok_or_else(|| "big_fish 已发布源作品不存在".to_string())?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); let next_source = BigFishCreationSession { @@ -918,7 +918,7 @@ fn remix_big_fish_work_tx( published_at: None, created_at: remixed_at, updated_at: remixed_at, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }; ctx.db.big_fish_creation_session().insert(target_session); ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index cf6ac376e..fe0acb0ca 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = big_fish_creation_session, @@ -30,7 +30,7 @@ pub struct BigFishCreationSession { pub(crate) like_count: u32, #[default(None::)] pub(crate) published_at: Option, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 646c12ac1..0df1376e2 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2,7 +2,7 @@ use crate::*; use spacetimedb::AnonymousViewContext; use std::collections::{HashMap, HashSet}; -const WORK_VISIBLE_DEFAULT: bool = true; +const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = custom_world_profile, @@ -44,7 +44,7 @@ pub struct CustomWorldProfile { pub(crate) deleted_at: Option, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } @@ -197,7 +197,7 @@ pub struct CustomWorldGalleryEntry { pub(crate) like_count: u32, pub(crate) published_at: Timestamp, pub(crate) updated_at: Timestamp, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } @@ -1210,6 +1210,8 @@ fn upsert_custom_world_profile_record( let next_row = match current { Some(existing) => { + let existing_was_published = + existing.publication_status == CustomWorldPublicationStatus::Published; ctx.db .custom_world_profile() .profile_id() @@ -1237,7 +1239,11 @@ fn upsert_custom_world_profile_record( deleted_at: None, created_at: existing.created_at, updated_at, - visible: existing.visible, + visible: if existing_was_published { + existing.visible + } else { + WORK_VISIBLE_DEFAULT + }, } } None => CustomWorldProfile { @@ -1263,7 +1269,7 @@ fn upsert_custom_world_profile_record( deleted_at: None, created_at: updated_at, updated_at, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }, }; @@ -1379,6 +1385,8 @@ fn publish_custom_world_profile_record( .ok_or_else(|| "custom_world_profile 不存在,无法发布".to_string())?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let existing_was_published = + existing.publication_status == CustomWorldPublicationStatus::Published; ctx.db .custom_world_profile() @@ -1411,7 +1419,11 @@ fn publish_custom_world_profile_record( deleted_at: None, created_at: existing.created_at, updated_at: published_at, - visible: existing.visible, + visible: if existing_was_published { + existing.visible + } else { + WORK_VISIBLE_DEFAULT + }, }; let inserted = ctx.db.custom_world_profile().insert(next_row); @@ -1813,7 +1825,7 @@ fn remix_custom_world_profile_record( deleted_at: None, created_at: remixed_at, updated_at: remixed_at, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }; if let Some(existing_target) = ctx @@ -2597,7 +2609,6 @@ fn is_same_agent_draft_profile_candidate( ) -> bool { row.owner_user_id == owner_user_id && row.deleted_at.is_none() - && row.visible && row.publication_status == CustomWorldPublicationStatus::Draft && row.source_agent_session_id.as_deref() == Some(source_agent_session_id) } diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs index ee865db2e..b6dbf5c48 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -422,6 +422,18 @@ fn compile_jump_hop_draft_tx( .unwrap_or_else(|| JUMP_HOP_GENERATION_READY.to_string()), }; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let previous_published_work = ctx + .db + .jump_hop_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| row.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED); + let previous_visible = previous_published_work + .as_ref() + .map(|row| row.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); + let previous_published_at = previous_published_work.and_then(|row| row.published_at); let row = JumpHopWorkProfileRow { profile_id: input.profile_id.clone(), work_id: input.profile_id.clone(), @@ -455,8 +467,8 @@ fn compile_jump_hop_draft_tx( publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, - published_at: None, - visible: true, + published_at: previous_published_at, + visible: previous_visible, theme_text: Some(draft.theme_text.clone()), }; upsert_work(ctx, row); @@ -542,6 +554,13 @@ fn publish_jump_hop_work_tx( publication_status: JUMP_HOP_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if row.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED + || row.published_at.is_some() + { + row.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(&row) }, ); @@ -652,10 +671,12 @@ fn start_jump_hop_run_tx( if runtime_mode == JUMP_HOP_RUNTIME_MODE_DRAFT && work.owner_user_id != input.owner_user_id { return Err("jump_hop draft runtime 只能由作品所有者启动".to_string()); } - if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED - && work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED - { - return Err("jump_hop published runtime 只能启动已发布作品".to_string()); + if runtime_mode == JUMP_HOP_RUNTIME_MODE_PUBLISHED { + let visible_to_player = work.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED + && (work.visible || work.owner_user_id == input.owner_user_id); + if !visible_to_player { + return Err("jump_hop published runtime 只能启动已公开作品".to_string()); + } } let path = parse_json::(&work.path_json)?; let domain_run = start_run( @@ -743,8 +764,8 @@ fn get_jump_hop_leaderboard_tx( > { require_non_empty(&input.profile_id, "jump_hop profile_id")?; let work = find_work(ctx, &input.profile_id)?; - if work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED { - return Err("jump_hop leaderboard 只开放已发布作品".to_string()); + if work.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED || !work.visible { + return Err("jump_hop leaderboard 只开放已公开作品".to_string()); } let limit = input.limit.clamp(1, 50) as usize; let mut rows = ctx diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs index c5b667717..38611c169 100644 --- a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = jump_hop_agent_session, @@ -53,7 +53,7 @@ pub struct JumpHopWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, // 跳一跳生成主题独立于作品标题;旧行按 work_title 兜底。 diff --git a/server-rs/crates/spacetime-module/src/match3d.rs b/server-rs/crates/spacetime-module/src/match3d.rs index 649c6f8d5..4984520c5 100644 --- a/server-rs/crates/spacetime-module/src/match3d.rs +++ b/server-rs/crates/spacetime-module/src/match3d.rs @@ -541,6 +541,11 @@ fn compile_match3d_draft_tx( .map(|work| work.play_count) .unwrap_or(0); let previous_published_at = existing_work.as_ref().and_then(|work| work.published_at); + let previous_visible = existing_work + .as_ref() + .filter(|work| work.publication_status == MATCH3D_PUBLICATION_PUBLISHED) + .map(|work| work.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); let cover_image_src = resolve_compile_optional_text( &input.cover_image_src, existing_work @@ -572,7 +577,7 @@ fn compile_match3d_draft_tx( updated_at: compiled_at, published_at: previous_published_at, generated_item_assets_json, - visible: true, + visible: previous_visible, }; upsert_work(ctx, work); replace_session( @@ -661,6 +666,13 @@ fn publish_match3d_work_tx( publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if current.publication_status == MATCH3D_PUBLICATION_PUBLISHED + || current.published_at.is_some() + { + current.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(¤t) }; let snapshot = build_work_snapshot(&next)?; @@ -731,7 +743,7 @@ fn get_match3d_work_detail_tx( .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id - || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + || (row.publication_status == MATCH3D_PUBLICATION_PUBLISHED && row.visible) }) .ok_or_else(|| "match_3_d_work_profile 不存在".to_string())?; build_work_snapshot(&row) @@ -786,7 +798,7 @@ fn start_match3d_run_tx( .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id - || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + || (row.publication_status == MATCH3D_PUBLICATION_PUBLISHED && row.visible) }) .ok_or_else(|| "match_3_d_work_profile 不存在".to_string())?; let started_at_ms = if input.started_at_ms > 0 { diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs index bc7d131a0..00f89afe3 100644 --- a/server-rs/crates/spacetime-module/src/match3d/tables.rs +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = match3d_agent_session, @@ -62,7 +62,7 @@ pub struct Match3DWorkProfileRow { pub(crate) published_at: Option, #[default(None::)] pub(crate) generated_item_assets_json: Option, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ff23fe2db..a05a54030 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1272,7 +1272,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde } if table_name == "custom_world_profile" || table_name == "custom_world_gallery_entry" { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); @@ -1290,7 +1290,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde } if table_name == "puzzle_work_profile" { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); @@ -1333,7 +1333,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde } if table_name == "big_fish_creation_session" { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于大鱼吃小鱼创作会话表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于大鱼吃小鱼创作会话表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); @@ -1348,7 +1348,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde | "bark_battle_published_config" ) { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); @@ -1368,7 +1368,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde } if table_name == "match_3_d_work_profile" || table_name == "match3d_work_profile" { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); @@ -1380,7 +1380,7 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde } if table_name == "wooden_fish_work_profile" { if let Some(object) = next_value.as_object_mut() { - // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包按默认显示兼容。 + // 中文注释:作品可见性字段晚于首版作品表加入,旧迁移包保留历史公开默认。 object .entry("visible".to_string()) .or_insert_with(|| serde_json::Value::Bool(true)); diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 2a568294e..9ac0dc5c8 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -42,7 +42,7 @@ use crate::validate_external_generation_job_lease_for_tx; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000; -const WORK_VISIBLE_DEFAULT: bool = true; +const WORK_VISIBLE_DEFAULT: bool = false; const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle"; const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft"; const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images"; @@ -137,7 +137,7 @@ pub struct PuzzleWorkProfileRow { pub(crate) point_incentive_total_half_points: u64, #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] pub(crate) point_incentive_claimed_points: u64, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } @@ -2402,7 +2402,7 @@ fn remix_puzzle_work_tx( created_at: remixed_at, updated_at: remixed_at, published_at: None, - visible: true, + visible: WORK_VISIBLE_DEFAULT, }); get_puzzle_agent_session_tx( @@ -3445,6 +3445,9 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re .profile_id() .find(&profile.profile_id) { + let existing_was_published = + existing.publication_status == PuzzlePublicationStatus::Published; + let next_is_published = profile.publication_status == PuzzlePublicationStatus::Published; replace_puzzle_work_profile( ctx, &existing, @@ -3481,7 +3484,11 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), - visible: existing.visible, + visible: if existing_was_published && next_is_published { + existing.visible + } else { + WORK_VISIBLE_DEFAULT + }, }, ); return Ok(()); @@ -3514,7 +3521,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re published_at: profile .published_at_micros .map(Timestamp::from_micros_since_unix_epoch), - visible: true, + visible: WORK_VISIBLE_DEFAULT, }); Ok(()) } diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear.rs b/server-rs/crates/spacetime-module/src/puzzle_clear.rs index 62b7a42a3..7000a086c 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear.rs @@ -411,6 +411,18 @@ fn compile_puzzle_clear_draft_tx( card_assets: card_assets.clone(), generation_status: generation_status.clone(), }; + let previous_published_work = ctx + .db + .puzzle_clear_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| row.publication_status == PUZZLE_CLEAR_PUBLICATION_PUBLISHED); + let previous_visible = previous_published_work + .as_ref() + .map(|row| row.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); + let previous_published_at = previous_published_work.and_then(|row| row.published_at); let row = PuzzleClearWorkProfileRow { profile_id: input.profile_id.clone(), work_id: input.profile_id.clone(), @@ -435,8 +447,8 @@ fn compile_puzzle_clear_draft_tx( publication_status: PUZZLE_CLEAR_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, - published_at: None, - visible: true, + published_at: previous_published_at, + visible: previous_visible, }; upsert_work(ctx, row); replace_session( @@ -651,6 +663,13 @@ fn publish_puzzle_clear_work_tx( publication_status: PUZZLE_CLEAR_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if row.publication_status == PUZZLE_CLEAR_PUBLICATION_PUBLISHED + || row.published_at.is_some() + { + row.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(&row) }, ); @@ -713,6 +732,9 @@ fn start_puzzle_clear_runtime_run_tx( if work.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED { return Err("拼消消 runtime 只能启动已发布作品".to_string()); } + if work.owner_user_id != input.owner_user_id && !work.visible { + return Err("拼消消 runtime 只能启动已公开作品".to_string()); + } let cards = domain_cards_from_work(&work)?; let (board, deck) = build_level_board_and_deck(1, &work.profile_id, &cards)?; let domain_run = start_puzzle_clear_run( diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs b/server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs index bdae388ac..391ac787e 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = puzzle_clear_agent_session, @@ -46,7 +46,7 @@ pub struct PuzzleClearWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // 中文注释:后台可见性开关,隐藏后不进入公开列表。 + // 中文注释:后台可见性开关,新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/square_hole.rs b/server-rs/crates/spacetime-module/src/square_hole.rs index 27f16684c..210803df4 100644 --- a/server-rs/crates/spacetime-module/src/square_hole.rs +++ b/server-rs/crates/spacetime-module/src/square_hole.rs @@ -500,6 +500,18 @@ fn compile_square_hole_draft_tx( let cover_image_src = clean_optional(&input.cover_image_src) .or_else(|| clean_optional(&Some(config.cover_image_src.clone()))) .unwrap_or_default(); + let previous_published_work = ctx + .db + .square_hole_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| row.publication_status == SQUARE_HOLE_PUBLICATION_PUBLISHED); + let previous_visible = previous_published_work + .as_ref() + .map(|row| row.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); + let previous_published_at = previous_published_work.and_then(|row| row.published_at); let draft = SquareHoleDraftSnapshot { profile_id: input.profile_id.clone(), game_name: domain_draft.game_name.clone(), @@ -537,8 +549,8 @@ fn compile_square_hole_draft_tx( publication_status: SQUARE_HOLE_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, - published_at: None, - visible: true, + published_at: previous_published_at, + visible: previous_visible, }; upsert_work(ctx, work); replace_session( @@ -634,6 +646,13 @@ fn publish_square_hole_work_tx( publication_status: SQUARE_HOLE_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if current.publication_status == SQUARE_HOLE_PUBLICATION_PUBLISHED + || current.published_at.is_some() + { + current.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(¤t) }; let snapshot = build_work_snapshot(&next)?; @@ -698,7 +717,7 @@ fn get_square_hole_work_detail_tx( .find(&input.profile_id) .ok_or_else(|| "方洞挑战作品不存在".to_string())?; if row.owner_user_id != input.owner_user_id - && row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED + && (row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED || !row.visible) { return Err("无权访问该方洞挑战作品".to_string()); } @@ -1033,7 +1052,7 @@ fn find_playable_work( .find(&profile_id.to_string()) .ok_or_else(|| "方洞挑战作品不存在".to_string())?; if row.owner_user_id != owner_user_id - && row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED + && (row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED || !row.visible) { return Err("无权试玩该方洞挑战作品".to_string()); } diff --git a/server-rs/crates/spacetime-module/src/square_hole/tables.rs b/server-rs/crates/spacetime-module/src/square_hole/tables.rs index 6be841ac6..f86e870b9 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/tables.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = square_hole_agent_session, @@ -61,7 +61,7 @@ pub struct SquareHoleWorkProfileRow { pub(crate) play_count: u32, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } diff --git a/server-rs/crates/spacetime-module/src/visual_novel.rs b/server-rs/crates/spacetime-module/src/visual_novel.rs index 8ff05399b..d1929e02a 100644 --- a/server-rs/crates/spacetime-module/src/visual_novel.rs +++ b/server-rs/crates/spacetime-module/src/visual_novel.rs @@ -2,7 +2,7 @@ use crate::*; use serde::de::DeserializeOwned; use spacetimedb::AnonymousViewContext; -const WORK_VISIBLE_DEFAULT: bool = true; +const WORK_VISIBLE_DEFAULT: bool = false; pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea"; pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document"; @@ -95,7 +95,7 @@ pub struct VisualNovelWorkProfileRow { pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, pub(crate) published_at: Option, - // 管理端可见性开关;默认显示,隐藏后不进入广场列表。 + // 管理端可见性开关;新作品默认不公开,开启后才进入广场列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } @@ -1015,6 +1015,18 @@ fn compile_visual_novel_work_profile_tx( .or_else(|| draft_string_field(&draft, "coverImageSrc")) .unwrap_or_default(); let publish_ready = draft_bool_field(&draft, "publishReady"); + let previous_published_work = ctx + .db + .visual_novel_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED); + let previous_visible = previous_published_work + .as_ref() + .map(|row| row.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); + let previous_published_at = previous_published_work.and_then(|row| row.published_at); let work = VisualNovelWorkProfileRow { profile_id: input.profile_id.clone(), @@ -1033,8 +1045,8 @@ fn compile_visual_novel_work_profile_tx( play_count: 0, created_at: compiled_at, updated_at: compiled_at, - published_at: None, - visible: true, + published_at: previous_published_at, + visible: previous_visible, }; upsert_work(ctx, work); replace_session( @@ -1095,6 +1107,13 @@ fn publish_visual_novel_work_tx( publication_status: VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if current.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + || current.published_at.is_some() + { + current.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(¤t) }; if !next.source_session_id.is_empty() { @@ -1165,7 +1184,7 @@ fn get_visual_novel_work_detail_tx( .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id - || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + || (row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED && row.visible) }) .ok_or_else(|| "visual_novel_work_profile 不存在".to_string())?; build_work_snapshot(&row) @@ -1437,7 +1456,7 @@ fn find_readable_work( .find(&profile_id.to_string()) .filter(|row| { row.owner_user_id == owner_user_id - || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + || (row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED && row.visible) }) .ok_or_else(|| "visual_novel_work_profile 不存在".to_string()) } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index bf0e04546..3049abd1f 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -401,6 +401,18 @@ fn compile_wooden_fish_draft_tx( .unwrap_or_else(|| WOODEN_FISH_GENERATION_READY.to_string()), }; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let previous_published_work = ctx + .db + .wooden_fish_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| row.publication_status == WOODEN_FISH_PUBLICATION_PUBLISHED); + let previous_visible = previous_published_work + .as_ref() + .map(|row| row.visible) + .unwrap_or(WORK_VISIBLE_DEFAULT); + let previous_published_at = previous_published_work.and_then(|row| row.published_at); let row = WoodenFishWorkProfileRow { profile_id: input.profile_id.clone(), work_id: input.profile_id.clone(), @@ -430,10 +442,10 @@ fn compile_wooden_fish_draft_tx( publication_status: WOODEN_FISH_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, - published_at: None, + published_at: previous_published_at, background_asset_json: background_asset.as_ref().map(to_json_string), back_button_asset_json: back_button_asset.as_ref().map(to_json_string), - visible: true, + visible: previous_visible, }; upsert_work(ctx, row); let config = config_from_draft(&draft); @@ -567,6 +579,13 @@ fn publish_wooden_fish_work_tx( publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), + visible: if row.publication_status == WOODEN_FISH_PUBLICATION_PUBLISHED + || row.published_at.is_some() + { + row.visible + } else { + WORK_VISIBLE_DEFAULT + }, ..clone_work(&row) }, ); @@ -680,6 +699,11 @@ fn start_wooden_fish_run_tx( require_non_empty(&input.run_id, "wooden_fish run_id")?; let stored_work = find_work(ctx, &input.profile_id)?; let work = backfill_historical_runtime_content(&stored_work); + if work.owner_user_id != input.owner_user_id + && (work.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED || !work.visible) + { + return Err("敲木鱼 runtime 只能启动已公开作品".to_string()); + } if !is_publish_ready(&work) { return Err("敲木鱼运行态需要完整作品配置".to_string()); } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs index 306f444d4..bb911b4ce 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish/tables.rs @@ -1,6 +1,6 @@ use crate::*; -const WORK_VISIBLE_DEFAULT: bool = true; +pub(super) const WORK_VISIBLE_DEFAULT: bool = false; #[spacetimedb::table( accessor = wooden_fish_agent_session, @@ -51,7 +51,7 @@ pub struct WoodenFishWorkProfileRow { pub(crate) background_asset_json: Option, #[default(None::)] pub(crate) back_button_asset_json: Option, - // 后台可见性开关;默认显示,隐藏后不进入公开列表。 + // 后台可见性开关;新作品默认不公开,开启后才进入公开列表。 #[default(WORK_VISIBLE_DEFAULT)] pub(crate) visible: bool, } From 8dad52f04027fdda095519127f59aec46f6e989e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 4 Jul 2026 12:03:58 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=94=BB=E5=B8=83Agent?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布Agent会话契约、领域规则、SpacetimeDB元数据表和生成绑定。 新增api-server画布Agent会话CRUD、OSS消息文档读写、SSE消息流和生成工具编排。 新增前端右侧Agent对话面板、会话状态hook、SSE客户端、BFF客户端和相关测试。 调整画布侧栏、任务栏、小地图默认状态和面板互斥交互。 补齐画布Agent、OSS消息存储、SSE收口、后端契约和项目记忆文档。 --- CONTEXT.md | 12 + docs/README.md | 3 + ...ADR】画布Agent会话消息存OSS-2026-07-03.md | 35 + .../shared-memory/decision-log.md | 8 + .../shared-memory/document-map.md | 3 +- ...架构】SSE客户端传输层收口约定-2026-06-03.md | 16 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 16 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 27 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 1 + .../【编辑器】画布Agent对话面板-2026-07-03.md | 123 + packages/shared/src/contracts/editorAgent.ts | 168 ++ packages/shared/src/contracts/index.ts | 1 + server-rs/Cargo.lock | 12 + server-rs/Cargo.toml | 2 + server-rs/crates/api-server/Cargo.toml | 1 + server-rs/crates/api-server/src/assets.rs | 8 + .../crates/api-server/src/editor_agent.rs | 1998 +++++++++++++++++ .../api-server/src/jump_hop_atlas_slicing.rs | 373 +-- server-rs/crates/api-server/src/main.rs | 1 + .../api-server/src/modules/editor_project.rs | 33 + .../crates/api-server/src/runtime_profile.rs | 44 +- .../crates/module-editor-agent/Cargo.toml | 15 + .../module-editor-agent/src/application.rs | 127 ++ .../module-editor-agent/src/commands.rs | 44 + .../crates/module-editor-agent/src/domain.rs | 57 + .../crates/module-editor-agent/src/errors.rs | 33 + .../crates/module-editor-agent/src/lib.rs | 9 + server-rs/crates/platform-oss/README.md | 7 + server-rs/crates/platform-oss/src/lib.rs | 120 +- .../shared-contracts/src/editor_agent.rs | 384 ++++ server-rs/crates/shared-contracts/src/lib.rs | 1 + .../spacetime-client/src/editor_agent.rs | 136 ++ server-rs/crates/spacetime-client/src/lib.rs | 18 +- .../crates/spacetime-client/src/mapper.rs | 9 + .../src/mapper/editor_agent.rs | 122 + .../src/mapper/runtime_profile.rs | 32 +- .../spacetime-client/src/module_bindings.rs | 56 +- ...agent_conversation_and_return_procedure.rs | 62 + ...agent_conversation_and_return_procedure.rs | 62 + ...or_agent_conversation_create_input_type.rs | 20 + ...or_agent_conversation_delete_input_type.rs | 17 + ...ditor_agent_conversation_get_input_type.rs | 16 + ...itor_agent_conversation_list_input_type.rs | 16 + ...conversation_list_procedure_result_type.rs | 19 + ...gent_conversation_procedure_result_type.rs | 19 + ...editor_agent_conversation_snapshot_type.rs | 22 + .../editor_agent_conversation_table.rs | 162 ++ ...tor_agent_conversation_touch_input_type.rs | 18 + .../editor_agent_conversation_type.rs | 77 + ...agent_conversation_and_return_procedure.rs | 59 + ...gent_conversations_and_return_procedure.rs | 59 + ...agent_conversation_and_return_procedure.rs | 59 + server-rs/crates/spacetime-module/Cargo.toml | 1 + .../src/editor_agent_storage.rs | 320 +++ .../src/editor_project_storage.rs | 39 +- server-rs/crates/spacetime-module/src/lib.rs | 2 + .../crates/spacetime-module/src/migration.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 32 +- .../EditorAgentConversationPanelView.test.tsx | 281 +++ .../EditorAgentConversationPanelView.tsx | 721 ++++++ .../ImageCanvasEditorShellView.test.tsx | 3 + .../ImageCanvasEditorView.test.tsx | 298 ++- .../image-editor/ImageCanvasEditorView.tsx | 113 +- .../ImageCanvasPanelDockView.test.tsx | 24 +- .../image-editor/ImageCanvasPanelDockView.tsx | 27 +- .../ImageCanvasSidebarView.test.tsx | 16 +- .../image-editor/ImageCanvasStageView.tsx | 23 +- .../useEditorAgentConversation.test.tsx | 354 +++ .../useEditorAgentConversation.ts | 651 ++++++ .../useImageCanvasEditorChrome.test.tsx | 5 +- .../useImageCanvasEditorChrome.ts | 9 +- .../image-editor/editorAgentClient.test.ts | 169 ++ .../image-editor/editorAgentClient.ts | 132 ++ .../image-editor/editorAgentSse.test.ts | 88 + src/services/image-editor/editorAgentSse.ts | 70 + 75 files changed, 7836 insertions(+), 285 deletions(-) create mode 100644 docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md create mode 100644 docs/【编辑器】画布Agent对话面板-2026-07-03.md create mode 100644 packages/shared/src/contracts/editorAgent.ts create mode 100644 server-rs/crates/api-server/src/editor_agent.rs create mode 100644 server-rs/crates/module-editor-agent/Cargo.toml create mode 100644 server-rs/crates/module-editor-agent/src/application.rs create mode 100644 server-rs/crates/module-editor-agent/src/commands.rs create mode 100644 server-rs/crates/module-editor-agent/src/domain.rs create mode 100644 server-rs/crates/module-editor-agent/src/errors.rs create mode 100644 server-rs/crates/module-editor-agent/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/src/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/editor_agent_storage.rs create mode 100644 src/components/image-editor/EditorAgentConversationPanelView.test.tsx create mode 100644 src/components/image-editor/EditorAgentConversationPanelView.tsx create mode 100644 src/components/image-editor/useEditorAgentConversation.test.tsx create mode 100644 src/components/image-editor/useEditorAgentConversation.ts create mode 100644 src/services/image-editor/editorAgentClient.test.ts create mode 100644 src/services/image-editor/editorAgentClient.ts create mode 100644 src/services/image-editor/editorAgentSse.test.ts create mode 100644 src/services/image-editor/editorAgentSse.ts 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/docs/README.md b/docs/README.md index 0ceca3f52..f30c79873 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 diff --git a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md new file mode 100644 index 000000000..194a23ea0 --- /dev/null +++ b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.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、模型和错误信息,便于用户回看失败原因和后续排障。 +- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a3f2b474..f664b2658 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-03 画布Agent会话元数据入 SpacetimeDB、消息正文存 OSS + +- 背景:图片画布工程需要对话式编辑历史,但消息正文随流式输出增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。 +- 决策:新增 `module-editor-agent` 承载纯领域规则,`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、SSE、OSS 读写和既有生成工具调用。 +- 影响范围:图片画布右侧 Agent 面板、`shared-contracts` / `packages/shared` 的 `editorAgent` 契约、`spacetime-module` / `spacetime-client`、`platform-oss` 内部读签名边界、画布生成落板规则。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 SSE client 定向测试、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-07-01 认证工作集只经 typed projection 同步正式表 - 背景:同手机号重复账号、兑换码白名单错配和微信资料不回写暴露出 `module-auth` 内存工作集、`auth_store_snapshot` 和正式认证表之间仍有历史互刷路径;旧 JSON 快照会把过期手机号索引或用户资料重新带回运行态。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index ea8afce2b..974e0d77c 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,6 +1,6 @@ # 文档地图与阅读索引 -更新时间:`2026-06-22` +更新时间:`2026-07-03` ## 当前文档入口 @@ -18,6 +18,7 @@ | 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | | 图片画布生成面板与模型定价 | `docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | +| 图片画布右侧 Agent 对话、会话消息 OSS 持久化、SSE 事件契约、与任务侧栏 / 左侧栏互斥规则 | `docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md` | ## 阅读顺序 diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md index de90be6b3..567120384 100644 --- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -13,6 +13,7 @@ - `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。 - `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。 - 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。 +- `src/services/image-editor/editorAgentSse.ts` 消费画布 Agent 的 JSON SSE,只校验并归一 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done` 事件,不持有 reader 循环或事件边界解析。 - OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。 ## 落地范围 @@ -25,12 +26,27 @@ - `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` - `src/services/rpg-entry/rpgProfileClient.ts` - `src/services/llmClient.ts` +- `src/services/image-editor/editorAgentSse.ts` 后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 +## 画布 Agent SSE client 口径 + +`src/services/image-editor/editorAgentSse.ts` 是画布 Agent 的业务事件归一层,只允许做以下事情: + +- 调用 `readSseJsonStream(response, handler)` 读取 JSON SSE。 +- 白名单识别 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。 +- 支持两种兼容输入形态:优先使用 SSE `event:` 字段作为事件名;若 `data` 内含 `{ "event": "...", "data": {...} }`,则归一为同一 `EditorAgentSseEvent`。 +- 对未知事件名、缺失 `data` 或非对象 JSON 静默跳过,保持业务 client 的容错口径。 + +`editorAgentSse.ts` 不得实现 `Response.body.getReader()`、`TextDecoder`、SSE 边界扫描、JSON 字符串拼接、fetch、鉴权、错误文案、会话状态聚合或画布聚焦逻辑;这些职责分别留在 `sseStream.ts`、`editorAgentClient.ts`、`useEditorAgentConversation.ts` 和画布视图层。 + ## 验收 - `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 - `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 +- `src/services/image-editor/editorAgentSse.test.ts` 覆盖画布 Agent 的 `stage`、`generation_result`、`done` 事件解析,以及 `{ event, data }` fallback 形态归一。 +- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 路由和 `/messages/stream` 发起后能收到类型化 SSE 事件。 +- 后续修改 `editorAgentSse.ts` 时,若出现手写 reader、`TextDecoder`、SSE 边界扫描或 fetch 逻辑,应视为违反本收口约定。 - 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 - `npm run typecheck` 不产生新的类型错误。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fc8fffb8f..3789f3fb3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -25,6 +25,7 @@ - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 +- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 ## 交互规则 @@ -53,6 +54,8 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 +- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 +- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -67,6 +70,11 @@ - `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。 - `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。 - `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。 +- `GET /api/editor/projects/{projectId}/agent-conversations`:读取当前工程的画布 Agent 会话列表,按更新时间倒序返回会话摘要。 +- `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。 +- `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。 +- `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。 +- `POST /api/editor/agent-conversations/{conversationId}/messages/stream`:发送画布 Agent 消息并返回 JSON SSE。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空。SSE 事件名固定为 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。生成结果中的图片通过后端写入 project resource / 账号素材后返回轻量快照,前端只消费返回的资源引用并刷新 / 聚焦画布。工具失败时也必须保留失败 generation record,不得只返回一次性错误。 - `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。 - `POST /api/editor/assets/folders`:新建素材文件夹。 - `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。 @@ -90,6 +98,8 @@ - 示例素材可继续复用 `public/creation-type-references/` 下的站内图片;用户上传和后续生成资源必须通过资源记录表达。 - 不把 hover、dragging、临时吸附线、Space 临时抓手等瞬时 UI 状态写入后端。 - 不在 UI 中加入大段功能说明,编辑器界面只展示必要的工具、素材和状态信息。 +- 画布 Agent 前端分层固定为:`editorAgentClient.ts` 负责 BFF 请求、鉴权错误和 stream 发起;`editorAgentSse.ts` 只负责 SSE JSON 事件归一;`useEditorAgentConversation.ts` 负责会话列表、当前会话、消息流、阶段和停止状态;`EditorAgentConversationPanelView.tsx` 只负责右侧面板展示与交互。 +- `editorAgentSse.ts` 必须复用 `src/services/sseStream.ts` 的 `readSseJsonStream`,不得复制 SSE reader、`TextDecoder`、事件边界扫描或 UTF-8 flush 逻辑;该文件不得承接 fetch、鉴权、会话聚合、画布聚焦或 UI 错误文案。 - 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor/canvas` 是独立图片画布工程的画布入口。 ## 验收用例 @@ -109,6 +119,12 @@ - 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 +- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开;素材 / 图层侧栏和任务侧栏被收起。再次点击或点击面板关闭按钮后收起 Agent。打开素材 / 图层侧栏或任务侧栏时,Agent 面板同步关闭。 +- Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 +- Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 +- 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 +- Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 +- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dbf566438..d8e14e27b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,6 +1,6 @@ # server-rs 与 SpacetimeDB 数据契约 -更新时间:`2026-07-01` +更新时间:`2026-07-03` ## 后端主线 @@ -21,7 +21,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 当前主要 crate: - HTTP 服务:`api-server`。 -- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 +- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-editor-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 - 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 @@ -60,7 +60,7 @@ npm run check:server-rs-ddd - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 - 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。 -- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 +- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 - 拼图:`/api/runtime/puzzle/*`。 @@ -75,6 +75,15 @@ npm run check:server-rs-ddd 需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。涉及创作、生成、作品、公开详情、试玩、正式运行态、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、AI task、角色资产工坊或玩法生成支撑资产的路由,不再直接在 `app.rs` 逐玩法 `.merge(...)`,也不挂到 `modules/platform.rs`;必须先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 的统一玩法流程主干,再由主干注册表分发到各领域 HTTP Adapter 或支撑能力 handler。 +### 图片画布 Agent 对话 + +- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取和软删;`/api/editor/agent-conversations/{conversationId}/messages/stream` 负责发送消息并返回 SSE。 +- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 +- `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 +- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、SSE 事件持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。 +- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 +- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 + ### 创作 / 游玩统一流程主干 `modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循: @@ -214,7 +223,7 @@ npm run check:server-rs-ddd - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物和返回按钮上传 OSS 前只做服务端绿幕去背后处理,避免泛抠图误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 -- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。 +- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 @@ -445,11 +454,19 @@ npm run check:server-rs-ddd - 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 +### `editor_agent_conversation` + +- Rust 结构体:`EditorAgentConversation` +- 源码:`server-rs/crates/spacetime-module/src/editor_agent_storage.rs` +- 说明:画布Agent对话会话元数据表,归属单个 `editor_project`;只保存会话 ID、project、owner、标题、消息 OSS 对象键(`editor-agent/{conversationId}.json`)、软删标记和时间戳。消息正文整体存 OSS,按会话粒度整体读写,不进 SpacetimeDB、不进画布工程快照 payload。删除为软删(`deleted = true`,OSS 对象保留)。领域校验(标题截取、附件上限、归属 / 软删规则)沉在 `module-editor-agent`。 +- 过程:只通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 操作;`api-server` 不直接绕过 `spacetime-client` facade 读写表。消息文档当前由 `api-server` 做单会话串行锁和 2 MiB 上限保护,避免同一 SSE 回合并发重写同一个 OSS JSON 文档。 +- 索引:`by_editor_agent_conversation_project_id` 用于会话列表;`by_editor_agent_conversation_owner_user_id` 用于账号级归属校验。 + ### `editor_project` - Rust 结构体:`EditorProject` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。 +- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。 - 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程和项目页工程列表。 ### `editor_canvas` diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 53e44990a..c77a9d1b5 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -100,6 +100,7 @@ - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 - 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。 +- 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。 ## 画布悬浮信息 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md new file mode 100644 index 000000000..81ea47f96 --- /dev/null +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -0,0 +1,123 @@ +# 画布Agent对话面板 + +日期:`2026-07-03` + +## 定位与边界 + +- **画布Agent对话** 是图片画布工程(`/editor/canvas`)右侧的对话式编辑器工具:用户通过自然语言调度画布已有的图片类生成与编辑能力,并可附加画布资源或素材库图片作为参考。 +- 它是画布域工具,**不承接玩法创作**、不产出玩法作品或模板,与 `CONTEXT.md` 中「表单/图片输入创作工作台」的 Avoid 边界不冲突。 +- 独立于拼图专用的 `/api/runtime/creative-agent/sessions`(该会话为 api-server 内存态、拼图领域专用,不复用)。 + +## 能力范围(第一期) + +对话 Agent 可通过 function-calling 触发以下五类工具,全部复用既有计费收口接口: + +| 工具 | 后端接口 | +| --- | --- | +| 生成图片 | `POST /api/editor/images/generations` | +| 修改图片(基于附件/画布素材) | `POST /api/editor/images/edits` | +| 生成角色形象 | 既有角色形象生成入口对应接口 | +| 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` | +| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 | + +- 意图解析与工具编排在后端 api-server,前端只渲染状态,不承接业务规则。 +- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。 +- 视频 / 音频 / 图层操作等其余画板功能第一期不进入对话工具面,仍走现有面板。 + +## 当前分支落地状态 + +- 已落地:会话元数据、OSS 消息文档、会话 CRUD、SSE 消息流、后端 LLM / 启发式工具规划、右侧对话面板、会话历史、新建 / 软删会话、停止当前 SSE 回合、附件从画布资源 / 账号素材库选择,以及五类图片工具对既有生成入口的复用。 +- 已落地:`tool_started` / `tool_completed` 事件携带 `status`;工具失败时也会写入失败 generation record,并随后发送 `stage=failed` 与 `error`,前端应保留消息内失败条目。 +- 未落地:附件弹窗末尾上传格、跨刷新异步生成恢复、external generation task 轮询回填。未落地前,对话消息状态只表示本次 SSE 回合记录,不作为后台任务队列真相。 + +## 会话与持久化 + +- 对话归属单个图片画布工程;每个工程有自己的会话列表,可新开会话。 +- SpacetimeDB 新表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、创建/更新时间。 +- 完整消息内容存 OSS:`editor-agent/{conversationId}.json`,**会话粒度整体读写**(追加消息=重写对象),不按消息拆对象。 +- 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。 +- 会话标题:新会话默认「新对话」,首条用户消息发出后自动截取前 N 字作为标题;第一期不做手动重命名。 +- 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。 + +## 生成结果落画板(对现有占位规则的例外) + +- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由对话消息流承载。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时在对话消息内显示缩略图(点击可让画布聚焦到对应图层;如果当前画布尚无该资源图层,前端先刷新工程快照再聚焦)。 +- 当前第一阶段通过既有编辑器生成 BFF 的 `canvasCompletion` 写回工程快照;刷新后异步任务恢复和轮询回填属于后续能力,不在本阶段声明为已完成。 +- 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 + +## 右侧布局 + +- 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。 +- 对话框与左侧素材 / 图层侧栏也互斥:打开画布 Agent 时收起左侧栏;再次打开素材、图层或任务侧栏时收起 Agent 面板。 +- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 +- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 + +## 附件 + +- 输入区 `+` 按钮打开**图片选择弹窗**(仅图片,无音视频): + - 「画布」页签(默认):展示当前工程图层引用的图片资源; + - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); + - 多选 + 底部「取消 / 应用」。 +- 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 +- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 +- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 + +## LLM 与计费 + +- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),function-calling 注册五类工具。 +- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。 +- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 + +## Lovart 参照做/不做清单(验收标准) + +做(第一期): + +1. 助手文本 SSE 流式输出; +2. 阶段提示行(思考中 → 思考完成 → 生成中 → 完成/失败); +3. 工具/模型标注行(生成时显示模型名 + 图标); +4. 消息内生成结果缩略图(点击 → 画布聚焦对应图层); +5. 生成中的进行中动画; +6. 错误气泡(失败/余额不足,带原因); +7. 发送中断:进行中时发送按钮变「停止」,可中断当前回合(已提交的生成任务不追回,照常落画板)。 + +不做(明确排除,防止后人补齐): + +- 点赞/点踩反馈按钮; +- 消息复制、分享/导出对话; +- Agent 模式切换下拉(固定单一 Agent); +- 语音输入、@引用、多 Agent 协作; +- Lovart 的积分/加速档位显示(泥点扣费只在生成动作上体现)。 + +## 顺手需求 + +- 左侧侧边栏默认隐藏:`useImageCanvasEditorChrome.ts` 中 `activeSidebarPanel` 初始值 `'assets'` → `null`; +- 小地图默认隐藏:`isMinimapOpen` 初始值 `true` → `false`; +- 纯默认值修改,不加 localStorage 偏好记忆。 + +## 后端分层落位 + +- `module-editor-agent`(新 crate):领域规则——会话/消息校验、状态机、附件上限、软删规则、工具清单领域定义;纯逻辑无 IO。 +- `spacetime-module`:新表 `editor_agent_conversation` + procedure;同步 `migration.rs`、表目录、生成绑定,运行 `npm run check:spacetime-schema`。 +- `spacetime-client`:facade 读写方法。 +- `api-server`: + - `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建); + - `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删); + - `POST /api/editor/agent-conversations/{conversationId}/messages/stream`(SSE); + - Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。 +- `shared-contracts` + `packages/shared`:新 `editorAgent` DTO 与 SSE 事件契约(`stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`)。 + +## 实施顺序 + +1. 契约与领域规则(shared-contracts / packages/shared + module-editor-agent); +2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check); +3. api-server 会话 CRUD + 消息 OSS 读写 + SSE 回显桩(不接 LLM,先保证会话链路端到端真实落库); +4. 前端最小纵切(对话框、会话管理、消息流、附件弹窗、侧边栏/小地图默认值)——可与 3 并行:3 只碰 `server-rs/`,4 只碰 `src/` 且先以契约 mock 客户端联调,汇合点在 4 末接真实 API; +5. LLM 编排 + 五类工具接入 + 生成落画板 + 停止/错误态(替换回显桩这一个点); +6. 验证与文档:定向测试、类型检查、`npm run check:encoding`、`git diff --check`、`npm run check:spacetime-schema`、api-server smoke `/healthz`;同步 `CONTEXT.md` 与相关文档。 + +## 第一阶段验收补充 + +- 打开画布 Agent 后,任务侧栏和左侧素材 / 图层面板应关闭;再次打开任务侧栏或素材 / 图层面板时,Agent 面板应关闭。 +- 发送消息时先本地追加用户消息,再消费 SSE 增量;停止按钮只中断当前 SSE 回合,不追回已经提交的生成工具调用。 +- 点击 Agent 消息内生成结果缩略图时,若当前画布已有该资源图层则直接聚焦;若尚无该资源图层,应先刷新工程快照,再聚焦新图层。 diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts new file mode 100644 index 000000000..6d6c5cf7f --- /dev/null +++ b/packages/shared/src/contracts/editorAgent.ts @@ -0,0 +1,168 @@ +// 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 + +export const EDITOR_AGENT_MAX_ATTACHMENTS = 9; +export const EDITOR_AGENT_TITLE_MAX_CHARS = 20; +export const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE = '新对话'; +export const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION = 1; + +export type EditorAgentStage = + | 'idle' + | 'thinking' + | 'responding' + | 'generating' + | 'completed' + | 'failed'; + +export type EditorAgentMessageRole = 'user' | 'assistant'; + +export type EditorAgentMessageKind = 'chat' | 'stage' | 'error'; + +export type EditorAgentMessageStatus = + | 'streaming' + | 'generating' + | 'completed' + | 'failed' + | 'stopped'; + +export type EditorAgentAttachmentSource = 'canvas_resource' | 'library_asset'; + +export type EditorAgentToolName = + | 'generate_image' + | 'edit_image' + | 'generate_character' + | 'generate_icon_spritesheet' + | 'generate_ui_design'; + +export interface EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource; + referenceId: string; + objectKey?: string | null; + imageSrc: string; + thumbnailSrc?: string | null; + label?: string | null; + width?: number | null; + height?: number | null; +} + +export interface EditorAgentGeneratedImage { + resourceId: string | null; + imageSrc: string; + thumbnailSrc: string | null; + width: number | null; + height: number | null; +} + +export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed'; + +export interface EditorAgentGenerationRecord { + toolCallId: string; + toolName: EditorAgentToolName; + taskId: string | null; + status: EditorAgentGenerationStatus; + model: string | null; + images: EditorAgentGeneratedImage[]; + error?: string | null; +} + +export interface EditorAgentMessage { + id: string; + role: EditorAgentMessageRole; + kind: EditorAgentMessageKind; + text: string; + attachments: EditorAgentAttachmentRef[]; + generations: EditorAgentGenerationRecord[]; + status: EditorAgentMessageStatus; + createdAt: string; +} + +export interface EditorAgentConversationSummary { + conversationId: string; + projectId: string; + title: string; + createdAt: string; + updatedAt: string; +} + +export interface EditorAgentConversationDetail + extends EditorAgentConversationSummary { + messages: EditorAgentMessage[]; +} + +export interface EditorAgentConversationMessagesDocument { + version: number; + conversationId: string; + messages: EditorAgentMessage[]; +} + +export interface CreateEditorAgentConversationRequest { + title?: string | null; +} + +export interface EditorAgentConversationListResponse { + conversations: EditorAgentConversationSummary[]; +} + +export interface EditorAgentConversationResponse { + conversation: EditorAgentConversationDetail; +} + +export interface StreamEditorAgentMessageRequest { + clientMessageId: string; + text: string; + attachments?: EditorAgentAttachmentRef[]; +} + +export interface EditorAgentStageEvent { + conversationId: string; + stage: EditorAgentStage; +} + +export interface EditorAgentMessageDeltaEvent { + conversationId: string; + messageId: string; + role: EditorAgentMessageRole; + kind: EditorAgentMessageKind; + textDelta: string; +} + +export interface EditorAgentToolEvent { + conversationId: string; + messageId: string; + toolCallId: string; + toolName: EditorAgentToolName; + summary?: string | null; + taskId?: string | null; + model?: string | null; + status?: EditorAgentGenerationStatus | null; + error?: string | null; +} + +export interface EditorAgentGenerationResultEvent { + conversationId: string; + messageId: string; + toolCallId: string; + toolName: EditorAgentToolName; + model: string | null; + images: EditorAgentGeneratedImage[]; +} + +export interface EditorAgentErrorEvent { + conversationId: string | null; + code: string; + message: string; + recoverable: boolean; +} + +export interface EditorAgentDoneEvent { + conversationId: string; + title: string | null; +} + +export type EditorAgentSseEvent = + | { event: 'stage'; data: EditorAgentStageEvent } + | { event: 'message_delta'; data: EditorAgentMessageDeltaEvent } + | { event: 'tool_started'; data: EditorAgentToolEvent } + | { event: 'tool_completed'; data: EditorAgentToolEvent } + | { event: 'generation_result'; data: EditorAgentGenerationResultEvent } + | { event: 'error'; data: EditorAgentErrorEvent } + | { event: 'done'; data: EditorAgentDoneEvent }; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 02b4005e4..5eb52d4dd 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,6 +1,7 @@ export type * from './barkBattle'; export type * from './creationAudio'; export type * from './creativeAgent'; +export * from './editorAgent'; export * from './hostBridge'; export type * from './hyper3d'; export type * from './jumpHop'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index efa038805..181cd0ac8 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -222,6 +222,7 @@ dependencies = [ "module-combat", "module-creative-agent", "module-custom-world", + "module-editor-agent", "module-inventory", "module-match3d", "module-npc", @@ -3147,6 +3148,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-editor-agent" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-inventory" version = "0.1.0" @@ -5809,6 +5820,7 @@ dependencies = [ "module-big-fish", "module-combat", "module-custom-world", + "module-editor-agent", "module-inventory", "module-jump-hop", "module-match3d", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index a7c331ee5..c7681f2fb 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/module-big-fish", "crates/module-combat", "crates/module-creative-agent", + "crates/module-editor-agent", "crates/module-inventory", "crates/module-custom-world", "crates/module-jump-hop", @@ -64,6 +65,7 @@ module-bark-battle = { path = "crates/module-bark-battle", default-features = fa module-big-fish = { path = "crates/module-big-fish", default-features = false } module-combat = { path = "crates/module-combat", default-features = false } module-creative-agent = { path = "crates/module-creative-agent", default-features = false } +module-editor-agent = { path = "crates/module-editor-agent", default-features = false } module-custom-world = { path = "crates/module-custom-world", default-features = false } module-inventory = { path = "crates/module-inventory", default-features = false } module-jump-hop = { path = "crates/module-jump-hop", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 93df64598..a38b50da4 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -25,6 +25,7 @@ module-bark-battle = { workspace = true } module-combat = { workspace = true } module-creative-agent = { workspace = true } module-custom-world = { workspace = true } +module-editor-agent = { workspace = true } module-inventory = { workspace = true } module-match3d = { workspace = true } module-npc = { workspace = true } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 908576bae..c67e6fa49 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -85,6 +85,14 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, })) })?; + if matches!(legacy_prefix, LegacyAssetPrefix::EditorAgent) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "legacyPrefix", + "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, + })), + ); + } let signed = oss_client .sign_post_object(OssPostObjectRequest { diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs new file mode 100644 index 000000000..088b4e68e --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent.rs @@ -0,0 +1,1998 @@ +use std::{ + collections::BTreeMap, + convert::Infallible, + sync::{Arc, Mutex, OnceLock}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_editor_agent::{ + EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, + EDITOR_AGENT_MESSAGE_ID_PREFIX, derive_conversation_title, editor_agent_messages_object_key, + validate_user_message, +}; +use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; +use platform_oss::{ + LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::assets::{ + EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, +}; +use shared_contracts::editor_agent::{ + CreateEditorAgentConversationRequest, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, + EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, + EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentDoneEvent, + EditorAgentErrorEvent, EditorAgentGeneratedImage, EditorAgentGenerationRecord, + EditorAgentGenerationResultEvent, EditorAgentGenerationStatus, EditorAgentMessage, + EditorAgentMessageDeltaEvent, EditorAgentMessageKind, EditorAgentMessageRole, + EditorAgentMessageStatus, EditorAgentStage, EditorAgentStageEvent, EditorAgentToolEvent, + EditorAgentToolName, StreamEditorAgentMessageRequest, +}; +use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; +use spacetime_client::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, + EditorAssetLibraryRecord, EditorAssetRecord, EditorProjectGetRecordInput, EditorProjectRecord, + EditorProjectResourceRecord, +}; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + editor_project::{ + EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, + EditorImageGenerationRequest, current_utc_micros, edit_editor_image_for_owner, + generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, + map_editor_project_error, + }, + http_error::AppError, + platform_errors::map_oss_error, + request_context::RequestContext, + state::AppState, +}; + +const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; +const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; +const EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS: u64 = 300; +const EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES: usize = 12; +const EDITOR_AGENT_TOOL_CALL_ID_PREFIX: &str = "editor-agent-tool"; +const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; +type EditorAgentConversationLockMap = Mutex>>>; +static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentConversationDeleteResponse { + deleted_conversation_id: String, + conversation: EditorAgentConversationSummary, +} + +pub async fn list_editor_agent_conversations( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + let conversations = state + .spacetime_client() + .list_editor_agent_conversations(project_id, owner_user_id) + .await + .map_err(map_editor_project_error)? + .into_iter() + .map(conversation_summary_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationListResponse { conversations }, + )) +} + +pub async fn create_editor_agent_conversation( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + + let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX); + let messages_object_key = editor_agent_messages_object_key(conversation_id.as_str()); + let title = normalize_optional_string(payload.title) + .unwrap_or_else(|| EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string()); + let now_micros = current_utc_micros(); + let seed_record = EditorAgentConversationRecord { + conversation_id: conversation_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + title: title.clone(), + messages_object_key: messages_object_key.clone(), + deleted: false, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + updated_at_micros: now_micros, + }; + write_messages_document( + &state, + &seed_record, + &empty_messages_document(conversation_id.as_str()), + ) + .await?; + + let conversation = state + .spacetime_client() + .create_editor_agent_conversation(EditorAgentConversationCreateRecordInput { + conversation_id, + project_id, + owner_user_id, + title, + messages_object_key, + created_at_micros: now_micros, + }) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn get_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn delete_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let conversation = state + .spacetime_client() + .delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput { + conversation_id, + owner_user_id, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationDeleteResponse { + deleted_conversation_id: conversation.conversation_id.clone(), + conversation: conversation_summary_from_record(conversation), + }, + )) +} + +pub async fn stream_editor_agent_message( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let client_message_id = normalize_required_string(payload.client_message_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; + let attachment_reference_ids = payload + .attachments + .iter() + .map(|attachment| attachment.reference_id.clone()) + .collect::>(); + validate_user_message(payload.text.as_str(), &attachment_reference_ids) + .map_err(|error| editor_agent_bad_request(error.to_string()))?; + + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id.clone()) + .await + .map_err(map_editor_project_error)?; + let attachments = + normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice()) + .await?; + let conversation_lock = editor_agent_conversation_lock(conversation.conversation_id.as_str()); + let conversation_lock_guard = conversation_lock.lock_owned().await; + let mut document = read_messages_document(&state, &conversation).await?; + if document + .messages + .iter() + .any(|message| message.id == client_message_id) + { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-agent", + "field": "clientMessageId", + "message": "clientMessageId already exists in this conversation", + })), + ); + } + + let was_empty = document.messages.is_empty(); + let user_message = EditorAgentMessage { + id: client_message_id, + role: EditorAgentMessageRole::User, + kind: EditorAgentMessageKind::Chat, + text: payload.text.trim().to_string(), + attachments, + generations: Vec::new(), + status: EditorAgentMessageStatus::Completed, + created_at: now_rfc3339(), + }; + document.messages.push(user_message.clone()); + write_messages_document(&state, &conversation, &document).await?; + + let assistant_message_id = build_prefixed_uuid_id(EDITOR_AGENT_MESSAGE_ID_PREFIX); + let stream_state = state.clone(); + let stream_conversation = conversation.clone(); + let stream_title = was_empty.then(|| derive_conversation_title(user_message.text.as_str())); + let stream_assistant_message_id = assistant_message_id.clone(); + let stream_user_message = user_message.clone(); + let stream_request_context = request_context.clone(); + let stream_conversation_lock_guard = conversation_lock_guard; + + let stream = async_stream::stream! { + let _conversation_lock_guard = stream_conversation_lock_guard; + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Thinking, + }, + )); + + let turn_plan = plan_editor_agent_turn( + &stream_state, + &stream_conversation, + &document, + &stream_user_message, + ) + .await; + let mut assistant_text = turn_plan.reply_text; + if assistant_text.trim().is_empty() { + assistant_text = heuristic_editor_agent_turn_plan( + stream_user_message.text.as_str(), + stream_user_message.attachments.as_slice(), + ) + .reply_text; + } + let mut assistant_status = EditorAgentMessageStatus::Completed; + let mut generation_records = Vec::new(); + + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Responding, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "message_delta", + EditorAgentMessageDeltaEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + role: EditorAgentMessageRole::Assistant, + kind: EditorAgentMessageKind::Chat, + text_delta: assistant_text.clone(), + }, + )); + + if let Some(tool_call) = turn_plan.tool_call { + let tool_call_id = build_prefixed_uuid_id(EDITOR_AGENT_TOOL_CALL_ID_PREFIX); + let tool_summary = tool_call + .summary + .clone() + .or_else(|| Some(editor_agent_tool_default_summary(tool_call.tool_name))); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_started", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id: tool_call_id.clone(), + tool_name: tool_call.tool_name, + summary: tool_summary.clone(), + task_id: None, + model: tool_call.model.clone(), + status: Some(EditorAgentGenerationStatus::Generating), + error: None, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Generating, + }, + )); + + match execute_editor_agent_tool_call( + &stream_state, + &stream_request_context, + &stream_conversation, + &stream_user_message, + &tool_call_id, + tool_call, + ) + .await + { + Ok(execution) => { + generation_records.push(execution.record.clone()); + yield Ok::(editor_agent_sse_json_event_or_error( + "generation_result", + EditorAgentGenerationResultEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id: tool_call_id.clone(), + tool_name: execution.record.tool_name, + model: execution.record.model.clone(), + images: execution.record.images.clone(), + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_completed", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id, + tool_name: execution.record.tool_name, + summary: Some(execution.summary), + task_id: execution.record.task_id.clone(), + model: execution.record.model.clone(), + status: Some(EditorAgentGenerationStatus::Completed), + error: None, + }, + )); + } + Err(error) => { + let error_code = error.error.code().to_string(); + let error_message = error.error.body_text(); + let error_model = error.model.clone(); + assistant_status = EditorAgentMessageStatus::Failed; + generation_records.push(EditorAgentGenerationRecord { + tool_call_id: tool_call_id.clone(), + tool_name: error.tool_name, + task_id: None, + status: EditorAgentGenerationStatus::Failed, + model: error_model.clone(), + images: Vec::new(), + error: Some(error_message.clone()), + }); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_completed", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id, + tool_name: error.tool_name, + summary: Some(editor_agent_tool_default_summary(error.tool_name)), + task_id: None, + model: error_model, + status: Some(EditorAgentGenerationStatus::Failed), + error: Some(error_message.clone()), + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Failed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: error_code, + message: error_message, + recoverable: true, + }, + )); + } + } + } + + let write_result = async { + let mut next_document = read_messages_document(&stream_state, &stream_conversation).await?; + if !next_document + .messages + .iter() + .any(|message| message.id == stream_assistant_message_id) + { + next_document.messages.push(EditorAgentMessage { + id: stream_assistant_message_id.clone(), + role: EditorAgentMessageRole::Assistant, + kind: if assistant_status == EditorAgentMessageStatus::Failed { + EditorAgentMessageKind::Error + } else { + EditorAgentMessageKind::Chat + }, + text: assistant_text, + attachments: Vec::new(), + generations: generation_records, + status: assistant_status, + created_at: now_rfc3339(), + }); + } + write_messages_document(&stream_state, &stream_conversation, &next_document).await + } + .await; + match write_result { + Ok(()) => { + let mut done_title = None; + if let Some(title) = stream_title { + match stream_state.spacetime_client().touch_editor_agent_conversation( + EditorAgentConversationTouchRecordInput { + conversation_id: stream_conversation.conversation_id.clone(), + owner_user_id: stream_conversation.owner_user_id.clone(), + title: Some(title.clone()), + updated_at_micros: current_utc_micros(), + }, + ).await { + Ok(updated) => done_title = Some(updated.title), + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: "SPACETIME_UPDATE_FAILED".to_string(), + message: error.to_string(), + recoverable: true, + }, + )); + } + } + } else { + match stream_state.spacetime_client().touch_editor_agent_conversation( + EditorAgentConversationTouchRecordInput { + conversation_id: stream_conversation.conversation_id.clone(), + owner_user_id: stream_conversation.owner_user_id.clone(), + title: None, + updated_at_micros: current_utc_micros(), + }, + ).await { + Ok(_) => {} + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: "SPACETIME_UPDATE_FAILED".to_string(), + message: error.to_string(), + recoverable: true, + }, + )); + } + } + } + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Completed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "done", + EditorAgentDoneEvent { + conversation_id: stream_conversation.conversation_id.clone(), + title: done_title, + }, + )); + } + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Failed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: error.code().to_string(), + message: error.body_text(), + recoverable: true, + }, + )); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +fn editor_agent_conversation_lock(conversation_id: &str) -> Arc> { + let locks = EDITOR_AGENT_CONVERSATION_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks + .entry(conversation_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} + +async fn ensure_editor_project_access( + state: &AppState, + project_id: &str, + owner_user_id: &str, +) -> Result<(), AppError> { + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map(|_| ()) + .map_err(map_editor_project_error) +} + +async fn normalize_editor_agent_attachments( + state: &AppState, + conversation: &EditorAgentConversationRecord, + attachments: &[EditorAgentAttachmentRef], +) -> Result, AppError> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + + let needs_canvas_resources = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource); + let needs_library_assets = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset); + + let project = if needs_canvas_resources { + Some( + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + let library = if needs_library_assets { + Some( + state + .spacetime_client() + .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + + attachments + .iter() + .map(|attachment| { + normalize_editor_agent_attachment( + conversation, + project.as_ref(), + library.as_ref(), + attachment, + ) + }) + .collect() +} + +fn normalize_editor_agent_attachment( + conversation: &EditorAgentConversationRecord, + project: Option<&EditorProjectRecord>, + library: Option<&EditorAssetLibraryRecord>, + attachment: &EditorAgentAttachmentRef, +) -> Result { + let reference_id = normalize_required_string(attachment.reference_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("attachment.referenceId is required"))?; + match attachment.source { + EditorAgentAttachmentSource::CanvasResource => { + let project = project.ok_or_else(|| { + editor_agent_bad_request("canvas resource attachment project context missing") + })?; + let resource = project + .resources + .iter() + .find(|resource| resource.resource_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "canvas resource attachment not found in current project: {reference_id}" + )) + })?; + normalize_canvas_resource_attachment(conversation, attachment, resource) + } + EditorAgentAttachmentSource::LibraryAsset => { + let library = library.ok_or_else(|| { + editor_agent_bad_request("library asset attachment context missing") + })?; + let asset = library + .assets + .iter() + .find(|asset| asset.asset_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "library asset attachment not found for current user: {reference_id}" + )) + })?; + normalize_library_asset_attachment(attachment, asset) + } + } +} + +fn normalize_canvas_resource_attachment( + conversation: &EditorAgentConversationRecord, + attachment: &EditorAgentAttachmentRef, + resource: &EditorProjectResourceRecord, +) -> Result { + if resource.project_id != conversation.project_id + || resource.owner_user_id != conversation.owner_user_id + { + return Err(editor_agent_bad_request( + "canvas resource attachment does not belong to this conversation project", + )); + } + validate_attachment_object_key( + attachment.object_key.as_deref(), + resource.object_key.as_deref(), + resource.resource_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: resource.resource_id.clone(), + object_key: resource.object_key.clone(), + image_src: resource.image_src.clone(), + thumbnail_src: None, + label: normalize_optional_string(attachment.label.clone()), + width: Some(resource.width), + height: Some(resource.height), + }) +} + +fn normalize_library_asset_attachment( + attachment: &EditorAgentAttachmentRef, + asset: &EditorAssetRecord, +) -> Result { + validate_attachment_object_key( + attachment.object_key.as_deref(), + asset.object_key.as_deref(), + asset.asset_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::LibraryAsset, + reference_id: asset.asset_id.clone(), + object_key: asset.object_key.clone(), + image_src: asset.image_src.clone(), + thumbnail_src: asset.thumbnail_src.clone(), + label: normalize_optional_string(attachment.label.clone()) + .or_else(|| Some(asset.label.clone())), + width: Some(asset.width), + height: Some(asset.height), + }) +} + +fn validate_attachment_object_key( + submitted_object_key: Option<&str>, + stored_object_key: Option<&str>, + reference_id: &str, +) -> Result<(), AppError> { + let Some(submitted_object_key) = submitted_object_key.and_then(normalize_required_string) + else { + return Ok(()); + }; + let Some(stored_object_key) = stored_object_key.and_then(normalize_required_string) else { + return Err(editor_agent_bad_request(format!( + "attachment objectKey is not available for reference: {reference_id}" + ))); + }; + if submitted_object_key != stored_object_key { + return Err(editor_agent_bad_request(format!( + "attachment objectKey does not match reference: {reference_id}" + ))); + } + Ok(()) +} + +fn conversation_summary_from_record( + conversation: EditorAgentConversationRecord, +) -> EditorAgentConversationSummary { + EditorAgentConversationSummary { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + } +} + +fn conversation_detail_from_record( + conversation: EditorAgentConversationRecord, + messages: Vec, +) -> EditorAgentConversationDetail { + EditorAgentConversationDetail { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + messages, + } +} + +fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { + EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: conversation_id.to_string(), + messages: Vec::new(), + } +} + +async fn read_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, +) -> Result { + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let signed = oss_client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: conversation.messages_object_key.clone(), + expire_seconds: Some(EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if !response.status().is_success() { + return Err(editor_agent_oss_read_error(format!( + "OSS returned non-success status {}", + response.status().as_u16() + ))); + } + if response + .content_length() + .is_some_and(|size| size > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES as u64) + { + return Err(editor_agent_messages_document_too_large()); + } + let bytes = response + .bytes() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if bytes.is_empty() { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if bytes.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + let document: EditorAgentConversationMessagesDocument = serde_json::from_slice(&bytes) + .map_err(|error| { + editor_agent_oss_read_error(format!("message document JSON invalid: {error}")) + })?; + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + "conversationId": conversation.conversation_id, + "documentConversationId": document.conversation_id, + })), + ); + } + Ok(document) +} + +async fn write_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, +) -> Result<(), AppError> { + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + })), + ); + } + let body = serde_json::to_vec(document).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": format!("failed to serialize message document: {error}"), + })) + })?; + if body.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let put_result = oss_client + .put_object( + &reqwest::Client::new(), + OssPutObjectRequest { + prefix: LegacyAssetPrefix::EditorAgent, + path_segments: Vec::new(), + file_name: format!("{}.json", conversation.conversation_id), + content_type: Some("application/json; charset=utf-8".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ( + "conversation-id".to_string(), + conversation.conversation_id.clone(), + ), + ("project-id".to_string(), conversation.project_id.clone()), + ( + "owner-user-id".to_string(), + conversation.owner_user_id.clone(), + ), + ]), + body, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + if put_result.object_key != conversation.messages_object_key { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": "OSS object key mismatch while writing message document", + "expectedObjectKey": conversation.messages_object_key, + "actualObjectKey": put_result.object_key, + })), + ); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq)] +struct EditorAgentTurnPlan { + reply_text: String, + tool_call: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct EditorAgentToolCallPlan { + tool_name: EditorAgentToolName, + prompt: String, + summary: Option, + model: Option, + aspect_ratio: Option, + image_size: Option, + icon_descriptions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentRawTurnPlan { + reply_text: Option, + tool_call: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentRawToolCallPlan { + tool_name: EditorAgentToolName, + prompt: Option, + summary: Option, + model: Option, + aspect_ratio: Option, + image_size: Option, + icon_descriptions: Option>, +} + +#[derive(Debug)] +struct EditorAgentToolExecution { + record: EditorAgentGenerationRecord, + summary: String, +} + +#[derive(Debug)] +struct EditorAgentToolExecutionError { + tool_name: EditorAgentToolName, + model: Option, + error: AppError, +} + +async fn plan_editor_agent_turn( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> EditorAgentTurnPlan { + let fallback = heuristic_editor_agent_turn_plan( + user_message.text.as_str(), + user_message.attachments.as_slice(), + ); + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return fallback; + }; + + let request = build_editor_agent_llm_request(state, conversation, document, user_message).await; + match llm_client.request_text(request).await { + Ok(response) => parse_editor_agent_turn_plan( + response.content.as_str(), + fallback.reply_text.as_str(), + user_message.text.as_str(), + ) + .unwrap_or(fallback), + Err(_) => fallback, + } +} + +async fn build_editor_agent_llm_request( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> LlmTextRequest { + let mut user_parts = vec![LlmMessageContentPart::InputText { + text: build_editor_agent_llm_user_prompt(conversation, document, user_message), + }]; + for attachment in &user_message.attachments { + if let Some(image_url) = sign_editor_agent_attachment_image_url(state, attachment) { + user_parts.push(LlmMessageContentPart::InputImage { image_url }); + } + } + + LlmTextRequest::new(vec![ + LlmMessage::system(editor_agent_llm_system_prompt()), + LlmMessage::user_multimodal(user_parts), + ]) + .with_responses_api() + .with_max_tokens(900) +} + +fn editor_agent_llm_system_prompt() -> String { + [ + "你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。", + "必须只输出一个 JSON 对象,不要输出 Markdown、解释或额外文本。", + "JSON 结构:{\"replyText\":\"给用户看的中文回复\",\"toolCall\":null 或 {\"toolName\":\"generate_image|edit_image|generate_character|generate_icon_spritesheet|generate_ui_design\",\"prompt\":\"生成或修改提示词\",\"summary\":\"短动作摘要\",\"model\":null,\"aspectRatio\":null,\"imageSize\":\"1K\",\"iconDescriptions\":[\"图标描述\"]}}。", + "只有用户明确要求生成、重绘、修改、画 UI、出角色或图标时才给 toolCall;普通咨询、评价、解释时 toolCall 为 null。", + "edit_image 和 generate_icon_spritesheet 必须依赖用户附件;没有附件时不要调用这两个工具,在 replyText 中提示先选择参考图。", + "generate_ui_design 表示生成一张 UI 设计图,不是提取 UI 素材。", + "对话回复要简短,不能承诺免费生成;生成工具由后端按模型定价扣泥点。", + ] + .join("\n") +} + +fn build_editor_agent_llm_user_prompt( + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> String { + let history = document + .messages + .iter() + .rev() + .take(EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES) + .collect::>() + .into_iter() + .rev() + .map(|message| { + json!({ + "role": message.role, + "kind": message.kind, + "text": message.text, + "attachmentCount": message.attachments.len(), + "generationCount": message.generations.len(), + }) + }) + .collect::>(); + let attachments = user_message + .attachments + .iter() + .map(|attachment| { + json!({ + "source": attachment.source, + "referenceId": attachment.reference_id, + "label": attachment.label, + "width": attachment.width, + "height": attachment.height, + "hasObjectKey": attachment.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), + }) + }) + .collect::>(); + + json!({ + "conversationId": conversation.conversation_id, + "projectId": conversation.project_id, + "latestUserText": user_message.text, + "latestAttachments": attachments, + "recentMessages": history, + }) + .to_string() +} + +fn sign_editor_agent_attachment_image_url( + state: &AppState, + attachment: &EditorAgentAttachmentRef, +) -> Option { + let object_key = attachment + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + state + .oss_client()? + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.trim_start_matches('/').to_string(), + expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), + }) + .ok() + .map(|signed| signed.signed_url) +} + +fn parse_editor_agent_turn_plan( + raw_text: &str, + fallback_reply_text: &str, + fallback_prompt: &str, +) -> Option { + let json_text = extract_editor_agent_json_object(raw_text)?; + let raw: EditorAgentRawTurnPlan = serde_json::from_str(json_text).ok()?; + let reply_text = normalize_optional_string(raw.reply_text) + .or_else(|| normalize_optional_string(Some(fallback_reply_text.to_string()))) + .unwrap_or_else(|| build_echo_assistant_text(fallback_prompt, 0)); + let tool_call = raw.tool_call.and_then(|tool| { + let prompt = normalize_optional_string(tool.prompt) + .or_else(|| normalize_optional_string(Some(fallback_prompt.to_string())))?; + Some(EditorAgentToolCallPlan { + tool_name: tool.tool_name, + prompt, + summary: normalize_optional_string(tool.summary), + model: normalize_optional_string(tool.model), + aspect_ratio: normalize_optional_string(tool.aspect_ratio), + image_size: normalize_optional_string(tool.image_size), + icon_descriptions: tool + .icon_descriptions + .unwrap_or_default() + .into_iter() + .filter_map(|description| normalize_optional_string(Some(description))) + .collect(), + }) + }); + + Some(EditorAgentTurnPlan { + reply_text, + tool_call, + }) +} + +fn extract_editor_agent_json_object(raw_text: &str) -> Option<&str> { + let trimmed = raw_text.trim(); + let without_fence = if let Some(rest) = trimmed.strip_prefix("```json") { + rest.strip_suffix("```").unwrap_or(rest).trim() + } else if let Some(rest) = trimmed.strip_prefix("```") { + rest.strip_suffix("```").unwrap_or(rest).trim() + } else { + trimmed + }; + let start = without_fence.find('{')?; + let end = without_fence.rfind('}')?; + (start <= end).then_some(&without_fence[start..=end]) +} + +fn heuristic_editor_agent_turn_plan( + text: &str, + attachments: &[EditorAgentAttachmentRef], +) -> EditorAgentTurnPlan { + let trimmed = text.trim(); + let normalized = trimmed.to_lowercase(); + let wants_generation = contains_any( + normalized.as_str(), + &[ + "生成", "出图", "绘制", "设计", "画一", "画个", "画张", "generate", "create", + ], + ); + let wants_edit = contains_any( + normalized.as_str(), + &[ + "修改", "改成", "重绘", "换成", "编辑", "优化", "edit", "redraw", + ], + ); + let wants_character = contains_any( + normalized.as_str(), + &["角色", "人物", "立绘", "形象", "character"], + ); + let wants_icons = contains_any( + normalized.as_str(), + &["图标", "icon", "spritesheet", "素材图集"], + ); + let wants_ui = contains_any( + normalized.as_str(), + &["ui", "界面", "hud", "按钮", "面板", "弹窗"], + ); + + let tool_name = if wants_icons && !attachments.is_empty() { + Some(EditorAgentToolName::GenerateIconSpritesheet) + } else if wants_edit && !attachments.is_empty() { + Some(EditorAgentToolName::EditImage) + } else if wants_character && wants_generation { + Some(EditorAgentToolName::GenerateCharacter) + } else if wants_ui && wants_generation { + Some(EditorAgentToolName::GenerateUiDesign) + } else if wants_generation { + Some(EditorAgentToolName::GenerateImage) + } else { + None + }; + + let reply_text = match tool_name { + Some(EditorAgentToolName::EditImage) => "我来按你的要求修改这张图。".to_string(), + Some(EditorAgentToolName::GenerateCharacter) => "我来生成角色形象。".to_string(), + Some(EditorAgentToolName::GenerateIconSpritesheet) => "我来生成图标素材图集。".to_string(), + Some(EditorAgentToolName::GenerateUiDesign) => "我来生成 UI 设计图。".to_string(), + Some(EditorAgentToolName::GenerateImage) => "我来生成图片。".to_string(), + None => build_echo_assistant_text(trimmed, attachments.len()), + }; + let tool_call = tool_name.map(|tool_name| EditorAgentToolCallPlan { + tool_name, + prompt: if trimmed.is_empty() { + "根据附件生成适合当前画布的图片".to_string() + } else { + trimmed.to_string() + }, + summary: Some(editor_agent_tool_default_summary(tool_name)), + model: None, + aspect_ratio: None, + image_size: Some("1K".to_string()), + icon_descriptions: derive_icon_descriptions(trimmed), + }); + + EditorAgentTurnPlan { + reply_text, + tool_call, + } +} + +fn contains_any(text: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| text.contains(needle)) +} + +fn derive_icon_descriptions(prompt: &str) -> Vec { + let normalized = prompt.trim(); + if normalized.is_empty() { + return vec![ + "主按钮图标".to_string(), + "奖励图标".to_string(), + "关闭图标".to_string(), + ]; + } + normalized + .split(['、', ',', ',', '\n', ';', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(8) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .filter(|item| item.chars().count() <= 80) + .collect::>() + .pipe_non_empty_or_else(|| vec![normalized.to_string()]) +} + +trait VecPipeNonEmpty { + fn pipe_non_empty_or_else(self, fallback: F) -> Self + where + F: FnOnce() -> Self; +} + +impl VecPipeNonEmpty for Vec { + fn pipe_non_empty_or_else(self, fallback: F) -> Self + where + F: FnOnce() -> Self, + { + if self.is_empty() { fallback() } else { self } + } +} + +async fn execute_editor_agent_tool_call( + state: &AppState, + request_context: &RequestContext, + conversation: &EditorAgentConversationRecord, + user_message: &EditorAgentMessage, + tool_call_id: &str, + tool_call: EditorAgentToolCallPlan, +) -> Result { + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error) + .map_err(|error| EditorAgentToolExecutionError { + tool_name: tool_call.tool_name, + model: tool_call.model.clone(), + error, + })?; + let completion = + build_editor_agent_canvas_completion(&project, tool_call.tool_name, &tool_call.prompt); + let caller = EditorGenerationCaller { + owner_user_id: conversation.owner_user_id.clone(), + audit_subject_user_id: Some(conversation.owner_user_id.clone()), + audit_project_id: Some(conversation.project_id.clone()), + }; + let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); + let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); + let generation_inputs = Some(json!({ + "source": "editor-agent", + "conversationId": conversation.conversation_id, + "messageId": user_message.id, + "toolCallId": tool_call_id, + "fields": [{ "title": "用户指令", "value": tool_call.prompt }], + "references": user_message.attachments.iter().map(|attachment| json!({ + "title": attachment.label.clone().unwrap_or_else(|| "对话附件".to_string()), + "label": attachment.label, + "refType": match attachment.source { + EditorAgentAttachmentSource::CanvasResource => "project-resource", + EditorAgentAttachmentSource::LibraryAsset => "asset", + }, + "refId": attachment.reference_id, + })).collect::>(), + })); + + let tool_name = tool_call.tool_name; + let tool_model = tool_call.model.clone(); + let response = match tool_name { + EditorAgentToolName::GenerateImage + | EditorAgentToolName::GenerateCharacter + | EditorAgentToolName::GenerateUiDesign => { + let kind = match tool_name { + EditorAgentToolName::GenerateCharacter => Some("character".to_string()), + EditorAgentToolName::GenerateUiDesign => Some("ui-design".to_string()), + _ => None, + }; + generate_editor_image_for_owner( + state, + &tool_request_context, + caller, + EditorImageGenerationRequest { + prompt: tool_call.prompt.clone(), + size: None, + kind, + model: tool_call.model.clone(), + aspect_ratio: tool_call.aspect_ratio.clone(), + image_size: tool_call.image_size.clone(), + reference_image_srcs: Some(attachment_sources), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), + generation_inputs, + asset_folder_id: None, + asset_label: tool_call.summary.clone(), + source_resource_id: user_message + .attachments + .first() + .map(|attachment| attachment.reference_id.clone()), + canvas_completion: Some(completion), + }, + ) + .await + } + EditorAgentToolName::EditImage => { + let (source_image_src, reference_image_srcs) = + split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { + EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error: editor_agent_bad_request( + "修改图片需要先选择一张画布或素材库图片附件", + ), + } + })?; + edit_editor_image_for_owner( + state, + &tool_request_context, + caller, + EditorImageEditRequest { + prompt: tool_call.prompt.clone(), + source_image_src, + size: None, + model: tool_call.model.clone(), + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), + generation_inputs, + asset_folder_id: None, + asset_label: tool_call.summary.clone(), + source_resource_id: user_message + .attachments + .first() + .map(|attachment| attachment.reference_id.clone()), + target_layer_id: None, + canvas_completion: Some(completion), + }, + ) + .await + } + EditorAgentToolName::GenerateIconSpritesheet => { + let (reference_image_src, extra_references) = + split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { + EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error: editor_agent_bad_request("生成图标素材需要先选择一张图标规范参考图"), + } + })?; + let icon_descriptions = if tool_call.icon_descriptions.is_empty() { + derive_icon_descriptions(tool_call.prompt.as_str()) + } else { + tool_call.icon_descriptions.clone() + }; + generate_editor_icon_spritesheet_for_owner( + state, + &tool_request_context, + caller, + EditorIconSpritesheetGenerationRequest { + reference_image_src, + reference_image_srcs: Some(extra_references), + icon_descriptions, + model: tool_call.model.clone(), + aspect_ratio: tool_call.aspect_ratio.clone(), + image_size: tool_call.image_size.clone(), + project_id: Some(conversation.project_id.clone()), + generation_inputs, + asset_folder_id: None, + canvas_completion: Some(completion), + }, + ) + .await + } + } + .map_err(|error| EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error, + })?; + + Ok(build_editor_agent_tool_execution( + tool_call_id, + tool_name, + tool_call.summary, + response.0, + )) +} + +fn editor_agent_tool_request_context(base: &RequestContext, tool_call_id: &str) -> RequestContext { + RequestContext::new( + format!("{}:{tool_call_id}", base.request_id()), + format!("{} editor-agent-tool", base.operation()), + std::time::Duration::ZERO, + false, + ) +} + +fn build_editor_agent_tool_execution( + tool_call_id: &str, + tool_name: EditorAgentToolName, + summary: Option, + response_body: Value, +) -> EditorAgentToolExecution { + let data = response_body + .get("data") + .filter(|_| response_body.get("ok").and_then(Value::as_bool) == Some(true)) + .unwrap_or(&response_body); + let images = editor_agent_generated_images_from_response(tool_name, data); + let task_id = data + .get("taskId") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + images + .iter() + .find_map(|image| image.resource_id.as_ref().map(|_| ())) + .and_then(|_| { + data.get("taskId") + .and_then(Value::as_str) + .map(str::to_string) + }) + }); + let model = data + .get("model") + .and_then(Value::as_str) + .map(str::to_string); + + EditorAgentToolExecution { + record: EditorAgentGenerationRecord { + tool_call_id: tool_call_id.to_string(), + tool_name, + task_id, + status: EditorAgentGenerationStatus::Completed, + model, + images, + error: None, + }, + summary: summary.unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), + } +} + +fn editor_agent_generated_images_from_response( + tool_name: EditorAgentToolName, + data: &Value, +) -> Vec { + if tool_name == EditorAgentToolName::GenerateIconSpritesheet { + let mut images = Vec::new(); + if let Some(resource) = data.get("spritesheetResource") { + images.push(editor_agent_generated_image_from_resource( + resource, + data.get("spritesheetImageSrc").and_then(Value::as_str), + data.get("spritesheetWidth").and_then(Value::as_u64), + data.get("spritesheetHeight").and_then(Value::as_u64), + )); + } + if let Some(icon_images) = data.get("iconImageSrcs").and_then(Value::as_array) { + for icon in icon_images { + if let Some(resource) = icon.get("resource") { + images.push(editor_agent_generated_image_from_resource( + resource, + icon.get("imageSrc").and_then(Value::as_str), + icon.get("width").and_then(Value::as_u64), + icon.get("height").and_then(Value::as_u64), + )); + } + } + } + return images; + } + + data.get("resource") + .map(|resource| { + vec![editor_agent_generated_image_from_resource( + resource, + data.get("imageSrc").and_then(Value::as_str), + data.get("width").and_then(Value::as_u64), + data.get("height").and_then(Value::as_u64), + )] + }) + .unwrap_or_else(|| { + data.get("imageSrc") + .and_then(Value::as_str) + .map(|image_src| { + vec![EditorAgentGeneratedImage { + resource_id: None, + image_src: image_src.to_string(), + thumbnail_src: None, + width: data + .get("width") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + height: data + .get("height") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + }] + }) + .unwrap_or_default() + }) +} + +fn editor_agent_generated_image_from_resource( + resource: &Value, + fallback_image_src: Option<&str>, + fallback_width: Option, + fallback_height: Option, +) -> EditorAgentGeneratedImage { + EditorAgentGeneratedImage { + resource_id: resource + .get("resourceId") + .and_then(Value::as_str) + .map(str::to_string), + image_src: resource + .get("imageSrc") + .and_then(Value::as_str) + .or(fallback_image_src) + .unwrap_or("") + .to_string(), + thumbnail_src: resource + .get("thumbnailSrc") + .and_then(Value::as_str) + .map(str::to_string), + width: resource + .get("width") + .and_then(Value::as_u64) + .or(fallback_width) + .and_then(|value| u32::try_from(value).ok()), + height: resource + .get("height") + .and_then(Value::as_u64) + .or(fallback_height) + .and_then(|value| u32::try_from(value).ok()), + } +} + +fn editor_agent_attachment_sources(attachments: &[EditorAgentAttachmentRef]) -> Vec { + attachments + .iter() + .filter_map(|attachment| { + attachment + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .or_else(|| normalize_required_string(attachment.reference_id.as_str())) + }) + .collect() +} + +fn split_editor_agent_edit_sources(sources: Vec) -> Option<(String, Vec)> { + let mut iter = sources.into_iter(); + let first = iter.next()?; + Some((first, iter.collect())) +} + +fn editor_agent_tool_asset_kind(tool_name: EditorAgentToolName) -> &'static str { + match tool_name { + EditorAgentToolName::GenerateCharacter => "character", + EditorAgentToolName::GenerateUiDesign => "ui-design", + EditorAgentToolName::EditImage => "editor_agent_edit_image", + EditorAgentToolName::GenerateIconSpritesheet => "icon-spritesheet", + EditorAgentToolName::GenerateImage => "editor_agent_generated_image", + } +} + +fn editor_agent_tool_default_summary(tool_name: EditorAgentToolName) -> String { + match tool_name { + EditorAgentToolName::GenerateImage => "生成图片", + EditorAgentToolName::EditImage => "修改图片", + EditorAgentToolName::GenerateCharacter => "生成角色形象", + EditorAgentToolName::GenerateIconSpritesheet => "生成图标素材", + EditorAgentToolName::GenerateUiDesign => "生成 UI 设计图", + } + .to_string() +} + +fn build_editor_agent_canvas_completion( + project: &EditorProjectRecord, + tool_name: EditorAgentToolName, + title: &str, +) -> EditorCanvasGenerationCompletionPayload { + let (width, height) = editor_agent_tool_display_size(tool_name); + let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height); + EditorCanvasGenerationCompletionPayload { + dialog_id: None, + title: normalize_optional_string(Some(title.to_string())) + .unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x, + y, + width, + height, + original_width: width, + original_height: height, + }, + } +} + +fn editor_agent_tool_display_size(tool_name: EditorAgentToolName) -> (f64, f64) { + match tool_name { + EditorAgentToolName::GenerateUiDesign => (640.0, 360.0), + EditorAgentToolName::GenerateCharacter => (512.0, 768.0), + _ => (512.0, 512.0), + } +} + +fn next_editor_agent_canvas_position(layers: Value, _width: f64, _height: f64) -> (f64, f64) { + let mut max_right: Option = None; + let mut min_y: Option = None; + if let Value::Array(items) = layers { + for item in items { + if item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { + continue; + } + let Some(x) = item.get("x").and_then(Value::as_f64) else { + continue; + }; + let Some(y) = item.get("y").and_then(Value::as_f64) else { + continue; + }; + let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.0); + if !x.is_finite() || !y.is_finite() || !width.is_finite() { + continue; + } + max_right = Some(max_right.map_or(x + width, |value| value.max(x + width))); + min_y = Some(min_y.map_or(y, |value| value.min(y))); + } + } + ( + max_right.map_or(0.0, |right| right + EDITOR_AGENT_CANVAS_RESULT_GAP), + min_y.unwrap_or(0.0), + ) +} + +fn build_echo_assistant_text(text: &str, attachment_count: usize) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return format!("已收到 {attachment_count} 个附件。"); + } + format!("收到:{trimmed}") +} + +fn now_rfc3339() -> String { + shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +fn editor_agent_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": message.into(), + })) +} + +fn editor_agent_oss_unavailable() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS is not configured for editor agent conversations", + })) +} + +fn editor_agent_oss_read_error(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": message.into(), + })) +} + +fn editor_agent_messages_document_too_large() -> AppError { + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ + "provider": "editor-agent", + "message": "message document is too large", + "maxBytes": EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES, + })) +} + +fn editor_agent_sse_json_event_or_error(event_name: &str, payload: T) -> Event +where + T: Serialize, +{ + match serde_json::to_string(&payload) { + Ok(data) => Event::default().event(event_name).data(data), + Err(error) => Event::default().event("error").data( + serde_json::to_string(&EditorAgentErrorEvent { + conversation_id: None, + code: "SERIALIZE_EVENT_FAILED".to_string(), + message: error.to_string(), + recoverable: false, + }) + .unwrap_or_else(|_| "{}".to_string()), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn echo_assistant_text_uses_attachment_fallback() { + assert_eq!( + build_echo_assistant_text(" 画一棵树 ", 0), + "收到:画一棵树" + ); + assert_eq!(build_echo_assistant_text(" ", 2), "已收到 2 个附件。"); + } + + #[test] + fn empty_document_uses_contract_version() { + let document = empty_messages_document("editor-agent-conv-1"); + assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); + assert_eq!(document.conversation_id, "editor-agent-conv-1"); + assert!(document.messages.is_empty()); + } + + #[test] + fn canvas_attachment_normalization_uses_project_resource_truth() { + let conversation = test_conversation_record(); + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: " resource-1 ".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + image_src: "https://client.example/stale.png".to_string(), + thumbnail_src: Some("https://client.example/thumb.png".to_string()), + label: Some(" 参考图 ".to_string()), + width: Some(1), + height: Some(1), + }; + let resource = test_project_resource_record(); + + let normalized = + normalize_canvas_resource_attachment(&conversation, &attachment, &resource) + .expect("attachment should normalize"); + + assert_eq!(normalized.reference_id, "resource-1"); + assert_eq!( + normalized.object_key.as_deref(), + Some("generated-editor-assets/resource-1.png") + ); + assert_eq!(normalized.image_src, "/api/assets/read/resource-1.png"); + assert_eq!(normalized.thumbnail_src, None); + assert_eq!(normalized.label.as_deref(), Some("参考图")); + assert_eq!(normalized.width, Some(512)); + assert_eq!(normalized.height, Some(256)); + } + + #[test] + fn attachment_normalization_rejects_mismatched_object_key() { + let conversation = test_conversation_record(); + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("generated-editor-assets/other.png".to_string()), + image_src: "/api/assets/read/resource-1.png".to_string(), + thumbnail_src: None, + label: None, + width: None, + height: None, + }; + let resource = test_project_resource_record(); + + assert!( + normalize_canvas_resource_attachment(&conversation, &attachment, &resource).is_err() + ); + } + + #[test] + fn library_attachment_normalization_uses_asset_label_fallback() { + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::LibraryAsset, + reference_id: "asset-1".to_string(), + object_key: None, + image_src: "https://client.example/stale.png".to_string(), + thumbnail_src: None, + label: None, + width: None, + height: None, + }; + let asset = EditorAssetRecord { + asset_id: "asset-1".to_string(), + folder_id: "project".to_string(), + label: "素材库图".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/api/assets/read/asset-1.png".to_string(), + object_key: Some("generated-editor-assets/asset-1.png".to_string()), + width: 128, + height: 128, + source_type: "uploaded".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + asset_kind: None, + generation_inputs: None, + source_resource_id: None, + public_showcase_enabled: None, + thumbnail_src: Some("/api/assets/read/asset-1-thumb.png".to_string()), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }; + + let normalized = normalize_library_asset_attachment(&attachment, &asset) + .expect("library attachment should normalize"); + + assert_eq!(normalized.label.as_deref(), Some("素材库图")); + assert_eq!( + normalized.thumbnail_src.as_deref(), + Some("/api/assets/read/asset-1-thumb.png") + ); + assert_eq!(normalized.width, Some(128)); + assert_eq!(normalized.height, Some(128)); + } + + #[test] + fn editor_agent_turn_plan_parses_fenced_json_tool_call() { + let plan = parse_editor_agent_turn_plan( + r#"```json +{ + "replyText": "我来生成一张像素风角色图。", + "toolCall": { + "toolName": "generate_character", + "prompt": "像素风银发游侠", + "summary": "生成像素角色", + "imageSize": "1K" + } +} +```"#, + "fallback", + "像素风银发游侠", + ) + .expect("fenced JSON should parse"); + + assert_eq!(plan.reply_text, "我来生成一张像素风角色图。"); + let tool_call = plan.tool_call.expect("tool call should exist"); + assert_eq!(tool_call.tool_name, EditorAgentToolName::GenerateCharacter); + assert_eq!(tool_call.prompt, "像素风银发游侠"); + assert_eq!(tool_call.summary.as_deref(), Some("生成像素角色")); + assert_eq!(tool_call.image_size.as_deref(), Some("1K")); + } + + #[test] + fn editor_agent_heuristic_routes_tools_from_user_text() { + let character_plan = + heuristic_editor_agent_turn_plan("帮我生成一个角色形象:猫耳骑士", &[]); + assert_eq!( + character_plan + .tool_call + .as_ref() + .expect("character tool expected") + .tool_name, + EditorAgentToolName::GenerateCharacter + ); + + let edit_plan = heuristic_editor_agent_turn_plan( + "把附件这张图改成赛博朋克风格", + &[EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + image_src: "/generated-editor-assets/resource-1.png".to_string(), + thumbnail_src: None, + label: None, + width: Some(512), + height: Some(512), + }], + ); + assert_eq!( + edit_plan + .tool_call + .as_ref() + .expect("edit tool expected") + .tool_name, + EditorAgentToolName::EditImage + ); + + let chat_plan = heuristic_editor_agent_turn_plan("这张图适合做头像吗?", &[]); + assert!(chat_plan.tool_call.is_none()); + } + + #[test] + fn editor_agent_canvas_completion_places_result_after_existing_layers() { + let project = EditorProjectRecord { + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "画布".to_string(), + canvas: spacetime_client::EditorCanvasRecord { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + title: "画布".to_string(), + viewport: spacetime_client::EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([]), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }, + viewport: spacetime_client::EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([ + { + "layerId": "layer-1", + "resourceId": "resource-1", + "x": 10.0, + "y": 20.0, + "width": 180.0, + "height": 120.0 + } + ]), + resources: Vec::new(), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }; + + let completion = build_editor_agent_canvas_completion( + &project, + EditorAgentToolName::GenerateUiDesign, + "UI 设计图", + ); + + assert_eq!(completion.dialog_id, None); + assert_eq!(completion.title, "UI 设计图"); + assert_eq!(completion.placeholder.x, 222.0); + assert_eq!(completion.placeholder.y, 20.0); + assert_eq!(completion.placeholder.width, 640.0); + assert_eq!(completion.placeholder.height, 360.0); + } + + fn test_conversation_record() -> EditorAgentConversationRecord { + EditorAgentConversationRecord { + conversation_id: "editor-agent-conv-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "新对话".to_string(), + messages_object_key: "editor-agent/editor-agent-conv-1.json".to_string(), + deleted: false, + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + updated_at_micros: 1, + } + } + + fn test_project_resource_record() -> EditorProjectResourceRecord { + EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/api/assets/read/resource-1.png".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + width: 512, + height: 256, + source_type: "generated".to_string(), + prompt: Some("森林".to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-1".to_string()), + source_resource_id: None, + asset_kind: Some("editor_generated_image".to_string()), + generation_inputs: None, + public_showcase_enabled: false, + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + } + } +} diff --git a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs index 94cf2b68f..37f08aa4d 100644 --- a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs +++ b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs @@ -11,10 +11,9 @@ use serde_json::json; use crate::{ http_error::AppError, jump_hop::{ - JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS, - crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, - jump_hop_tile_type_by_index, + JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, + crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, jump_hop_tile_type_by_index, }, openai_image_generation::DownloadedOpenAiImage, }; @@ -137,11 +136,7 @@ fn enforce_monotonic(boundaries: &mut [u32]) { /// * `radius` - 搜索半径 /// /// 返回精修后的分界线位置(不含 0 和 max)。 -pub(crate) fn refine_boundaries_seed( - density: &[f32], - seeds: &[u32], - radius: u32, -) -> Vec { +pub(crate) fn refine_boundaries_seed(density: &[f32], seeds: &[u32], radius: u32) -> Vec { let mut refined = Vec::with_capacity(seeds.len()); for &seed in seeds { let pos = find_min_density_position(density, seed, radius); @@ -229,10 +224,7 @@ fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec { } /// 低于阈值的连续区间 → 候选谷列表 -fn extract_valleys_below_threshold( - signal: &[f32], - threshold: f32, -) -> Vec<(usize, usize)> { +fn extract_valleys_below_threshold(signal: &[f32], threshold: f32) -> Vec<(usize, usize)> { let n = signal.len(); let mut valleys = Vec::new(); let mut in_valley = false; @@ -256,10 +248,7 @@ fn extract_valleys_below_threshold( } /// 合并间距 < min_gap 的相邻谷 -fn merge_close_valleys( - valleys: &[(usize, usize)], - min_gap: usize, -) -> Vec<(usize, usize)> { +fn merge_close_valleys(valleys: &[(usize, usize)], min_gap: usize) -> Vec<(usize, usize)> { if valleys.is_empty() { return vec![]; } @@ -286,11 +275,7 @@ fn valley_centers(valleys: &[(usize, usize)]) -> Vec { } /// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组 -fn select_spaced_valleys( - centers: &[u32], - expected_spacing: f32, - target_count: usize, -) -> Vec { +fn select_spaced_valleys(centers: &[u32], expected_spacing: f32, target_count: usize) -> Vec { if centers.len() <= target_count { return centers.to_vec(); } @@ -365,7 +350,10 @@ pub(crate) fn refine_boundaries_valley( .collect(); scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); scored.truncate(expected_valleys + 2); - let mut c: Vec = scored.into_iter().map(|(_, center)| center as u32).collect(); + let mut c: Vec = scored + .into_iter() + .map(|(_, center)| center as u32) + .collect(); c.sort(); c } else { @@ -459,27 +447,28 @@ pub(crate) fn slice_tile_atlas_adaptive( detect_cell_grid_seed(pixels, width, height, rows, cols) } AtlasSliceAlgorithm::ValleyDetection => { - detect_cell_grid_valley(pixels, width, height, rows, cols) - .unwrap_or_else(|_| { - // 谷检测失败时回退到种子点精修 - detect_cell_grid_seed(pixels, width, height, rows, cols) - }) + detect_cell_grid_valley(pixels, width, height, rows, cols).unwrap_or_else(|_| { + // 谷检测失败时回退到种子点精修 + detect_cell_grid_seed(pixels, width, height, rows, cols) + }) } }; if grid.row_boundaries.len() != (rows + 1) as usize || grid.col_boundaries.len() != (cols + 1) as usize { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!( - "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", - rows + 1, - cols + 1, - grid.row_boundaries.len(), - grid.col_boundaries.len(), - ), - }))); + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!( + "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", + rows + 1, + cols + 1, + grid.row_boundaries.len(), + grid.col_boundaries.len(), + ), + })), + ); } let tile_count = (rows * cols) as usize; @@ -540,22 +529,70 @@ fn slice_jump_hop_tile_uv_faces_adaptive( Ok(JumpHopTileFaceSlices { top: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Top, + 1, + 0, )?, front: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Front, + 1, + 1, )?, right: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Right, + 2, + 1, )?, back: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Back, + 3, + 1, )?, left: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Left, + 0, + 1, )?, bottom: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Bottom, + 1, + 2, )?, }) } @@ -592,12 +629,7 @@ fn slice_jump_hop_tile_uv_face_adaptive( Ok(JumpHopTileFaceSlice { face, - source_atlas_cell: format!( - "row-{}-col-{}/{}", - atlas_row + 1, - atlas_col + 1, - face_label - ), + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label), bytes: cursor.into_inner(), }) } @@ -619,7 +651,10 @@ const GRAD_SMOOTH: usize = 3; fn build_cleaned_tile( source: &image::RgbaImage, - tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, + tile_x: u32, + tile_y: u32, + tile_w: u32, + tile_h: u32, ) -> Option { let pixels = source.as_raw(); let sw = source.width() as usize; @@ -634,25 +669,35 @@ fn build_cleaned_tile( for sy in 0..tile_h { for sx in 0..tile_w { let si = idx(sx, sy); - if visited[si] { continue; } + if visited[si] { + continue; + } let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4; - if pixels[go + 3] < BLOB_ALPHA { continue; } + if pixels[go + 3] < BLOB_ALPHA { + continue; + } queue.clear(); queue.push(si); visited[si] = true; let mut qi = 0; while qi < queue.len() { - let cur = queue[qi]; qi += 1; + let cur = queue[qi]; + qi += 1; let cx = cur as u32 % tile_w; let cy = cur as u32 / tile_w; - for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] { + for (dx, dy) in [(1i32, 0i32), (-1, 0), (0, 1), (0, -1)] { let nx = cx as i32 + dx; let ny = cy as i32 + dy; - if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; } + if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { + continue; + } let ni = idx(nx as u32, ny as u32); - if visited[ni] { continue; } - let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4; + if visited[ni] { + continue; + } + let ngo = (tile_y as usize + ny as usize) * stride + + (tile_x as usize + nx as usize) * 4; if pixels[ngo + 3] >= BLOB_ALPHA { visited[ni] = true; queue.push(ni); @@ -660,14 +705,18 @@ fn build_cleaned_tile( } } let area = queue.len(); - if area < MIN_BLOB_AREA { continue; } + if area < MIN_BLOB_AREA { + continue; + } if area > best_comp.len() { best_comp = queue.clone(); } } } - if best_comp.is_empty() { return None; } + if best_comp.is_empty() { + return None; + } // 构造 cleaned 图像:仅含主 blob let mut cleaned = image::RgbaImage::new(tile_w, tile_h); @@ -677,7 +726,11 @@ fn build_cleaned_tile( let gx = tile_x + lx; let gy = tile_y + ly; let go = (gy as usize * sw + gx as usize) * 4; - cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]])); + cleaned.put_pixel( + lx, + ly, + image::Rgba([pixels[go], pixels[go + 1], pixels[go + 2], pixels[go + 3]]), + ); } Some(cleaned) } @@ -685,108 +738,161 @@ fn build_cleaned_tile( // ---- 2. density + gradient → 边界 ---- fn smooth_1d(signal: &[f32], window: usize) -> Vec { - if signal.len() <= window { return signal.to_vec(); } + if signal.len() <= window { + return signal.to_vec(); + } let hw = window / 2; - (0..signal.len()).map(|i| { - let lo = i.saturating_sub(hw); - let hi = (i + hw).min(signal.len() - 1); - let sum: f32 = signal[lo..=hi].iter().sum(); - sum / (hi - lo + 1) as f32 - }).collect() + (0..signal.len()) + .map(|i| { + let lo = i.saturating_sub(hw); + let hi = (i + hw).min(signal.len() - 1); + let sum: f32 = signal[lo..=hi].iter().sum(); + sum / (hi - lo + 1) as f32 + }) + .collect() } fn gradient(signal: &[f32]) -> Vec { - if signal.len() < 2 { return vec![]; } - (0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect() + if signal.len() < 2 { + return vec![]; + } + (0..signal.len() - 1) + .map(|i| signal[i + 1] - signal[i]) + .collect() } /// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。 /// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。 -struct GradPeak { idx: usize, val: f32 } +struct GradPeak { + idx: usize, + val: f32, +} fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec { - if grad.len() < 2 { return vec![]; } + if grad.len() < 2 { + return vec![]; + } // 取绝对值后找局部极大 let abs_grad: Vec = grad.iter().map(|&g| g.abs()).collect(); - let mut peaks: Vec = (1..abs_grad.len()-1) - .filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0) - .map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号 + let mut peaks: Vec = (1..abs_grad.len() - 1) + .filter(|&i| { + abs_grad[i] > abs_grad[i - 1] && abs_grad[i] >= abs_grad[i + 1] && abs_grad[i] > 0.0 + }) + .map(|i| GradPeak { + idx: i, + val: grad[i], + }) // 保留符号 .collect(); - peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal)); + peaks.sort_by(|a, b| { + b.val + .abs() + .partial_cmp(&a.val.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); // 去重:间距小于 min_sep 的只保留最大者 let mut chosen = Vec::new(); for p in peaks { - if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) { + if chosen + .iter() + .all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) + { chosen.push(p); - if chosen.len() >= count { break; } + if chosen.len() >= count { + break; + } } } chosen } /// 行 density → gradient → y₀,y₁,y₂,y₃ -fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> { +fn detect_row_boundaries( + cleaned: &image::RgbaImage, + tile_w: u32, + tile_h: u32, +) -> Option<(u32, u32, u32, u32)> { // 行 density let mut row_density = Vec::with_capacity(tile_h as usize); for y in 0..tile_h { let mut cnt = 0u32; for x in 0..tile_w { - if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; } + if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { + cnt += 1; + } } row_density.push(cnt as f32 / tile_w as f32); } let smooth = smooth_1d(&row_density, GRAD_SMOOTH); let grad = gradient(&smooth); let peaks = find_gradient_peaks(&grad, 4, 4); - if peaks.len() < 2 { return None; } + if peaks.len() < 2 { + return None; + } // 分离正负 let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); - if pos.len() < 1 || neg.len() < 1 { return None; } + if pos.len() < 1 || neg.len() < 1 { + return None; + } // y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄) let y1 = pos[0].idx as u32; let y2 = neg[0].idx as u32; let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; - let y3 = (row_density.len() as u32).saturating_sub( - 1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32 - ) + 1; + let y3 = (row_density.len() as u32) + .saturating_sub(1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32) + + 1; - if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; } + if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { + return None; + } Some((y0, y1, y2, y3)) } /// 列高度 profile(每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃ -fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> { +fn detect_col_boundaries( + cleaned: &image::RgbaImage, + tile_w: u32, + _tile_h: u32, + y0: u32, + y3: u32, +) -> Option<(u32, u32, u32, u32)> { // 每列的 blob 高度 let mut col_height = Vec::with_capacity(tile_w as usize); for x in 0..tile_w { let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); - let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); - col_height.push( - first.map_or(0.0, |f| { - let l = last.unwrap_or(f); - (l - f + 1) as f32 / (y3 - y0).max(1) as f32 - }) - ); + let last = (y0..y3) + .rev() + .find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + col_height.push(first.map_or(0.0, |f| { + let l = last.unwrap_or(f); + (l - f + 1) as f32 / (y3 - y0).max(1) as f32 + })); } let smooth = smooth_1d(&col_height, GRAD_SMOOTH); let grad = gradient(&smooth); let peaks = find_gradient_peaks(&grad, 4, 4); - if peaks.len() < 2 { return None; } + if peaks.len() < 2 { + return None; + } let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); - if pos.len() < 1 || neg.len() < 1 { return None; } + if pos.len() < 1 || neg.len() < 1 { + return None; + } let x1 = pos[0].idx as u32; let x2 = neg[0].idx as u32; let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; - let x3 = (tile_w as usize).saturating_sub( - 1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0) - ) as u32 + 1; + let x3 = (tile_w as usize) + .saturating_sub(1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0)) + as u32 + + 1; - if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; } + if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { + return None; + } Some((x0, x1, x2, x3)) } @@ -795,7 +901,10 @@ fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, /// 在 block 范围内基于 histogram 找最大全不透明矩形。 fn max_opaque_rect( cleaned: &image::RgbaImage, - bx0: u32, by0: u32, bw: u32, bh: u32, + bx0: u32, + by0: u32, + bw: u32, + bh: u32, ) -> Option<(u32, u32, u32, u32)> { let mut heights = vec![0u32; bw as usize]; let mut best_area = 0u32; @@ -866,11 +975,17 @@ mod tests { fn slice_jump_hop_tile_uv_faces_blob( source: &image::RgbaImage, - tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, - atlas_row: u32, atlas_col: u32, + tile_x: u32, + tile_y: u32, + tile_w: u32, + tile_h: u32, + atlas_row: u32, + atlas_col: u32, ) -> Result { let fallback = || { - slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col) + slice_jump_hop_tile_uv_faces_adaptive( + source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col, + ) }; // Step 1: BFS 主 blob → cleaned 图像 @@ -891,16 +1006,16 @@ fn slice_jump_hop_tile_uv_faces_blob( // Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块 // blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom - let blocks: [(u32,u32,u32,u32); 5] = [ - (x1, y0, x2 - x1, y1 - y0), // Top - (x0, y1, x1 - x0, y2 - y1), // Left - (x1, y1, x2 - x1, y2 - y1), // Front - (x2, y1, x3 - x2, y2 - y1), // Right+Back - (x1, y2, x2 - x1, y3 - y2), // Bottom + let blocks: [(u32, u32, u32, u32); 5] = [ + (x1, y0, x2 - x1, y1 - y0), // Top + (x0, y1, x1 - x0, y2 - y1), // Left + (x1, y1, x2 - x1, y2 - y1), // Front + (x2, y1, x3 - x2, y2 - y1), // Right+Back + (x1, y2, x2 - x1, y3 - y2), // Bottom ]; // Step 4: max opaque rectangle per block - let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> { + let rect = |b: (u32, u32, u32, u32), name: &str| -> Result<(u32, u32, u32, u32), AppError> { max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, @@ -909,29 +1024,33 @@ fn slice_jump_hop_tile_uv_faces_blob( }) }; - let top = rect(blocks[0], "Top")?; - let left = rect(blocks[1], "Left")?; - let front = rect(blocks[2], "Front")?; + let top = rect(blocks[0], "Top")?; + let left = rect(blocks[1], "Left")?; + let front = rect(blocks[2], "Front")?; // Right+Back → 从中点拆分 let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3]; let mid = rb_x0 + rb_w / 2; let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?; - let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; + let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; let bottom = rect(blocks[4], "Bottom")?; // Step 5: crop (tile_local → global) - let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); + let global = |r: (u32, u32, u32, u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); - let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result { + let mk = |r: (u32, u32, u32, u32), + face: JumpHopTileFaceKey| + -> Result { let (gx, gy, gw, gh) = global(r); let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh); let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), - })) - })?; + cleaned_dyn + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), + })) + })?; let label = jump_hop_tile_face_key_label(&face); Ok(JumpHopTileFaceSlice { face, @@ -941,11 +1060,11 @@ fn slice_jump_hop_tile_uv_faces_blob( }; Ok(JumpHopTileFaceSlices { - top: mk(top, JumpHopTileFaceKey::Top)?, - left: mk(left, JumpHopTileFaceKey::Left)?, - front: mk(front, JumpHopTileFaceKey::Front)?, - right: mk(right_rect, JumpHopTileFaceKey::Right)?, - back: mk(back_rect, JumpHopTileFaceKey::Back)?, + top: mk(top, JumpHopTileFaceKey::Top)?, + left: mk(left, JumpHopTileFaceKey::Left)?, + front: mk(front, JumpHopTileFaceKey::Front)?, + right: mk(right_rect, JumpHopTileFaceKey::Right)?, + back: mk(back_rect, JumpHopTileFaceKey::Back)?, bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?, }) } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 93e18e9d1..6e109a4cb 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,6 +36,7 @@ mod custom_world_asset_prompts; mod custom_world_foundation_draft; mod custom_world_result_prompts; mod custom_world_rpg_draft_prompts; +mod editor_agent; mod editor_generation_config; mod editor_generation_queue; mod editor_green_screen; diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 977c27b4b..dc640e7ad 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -7,6 +7,11 @@ use axum::{ use crate::{ auth::require_bearer_auth, + editor_agent::{ + create_editor_agent_conversation, delete_editor_agent_conversation, + get_editor_agent_conversation, list_editor_agent_conversations, + stream_editor_agent_message, + }, editor_project::{ create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, @@ -21,6 +26,7 @@ use crate::{ }; const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; +const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024; pub fn router(state: AppState) -> Router { Router::new() @@ -72,6 +78,33 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/projects/{project_id}/agent-conversations", + get(list_editor_agent_conversations) + .post(create_editor_agent_conversation) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/editor/agent-conversations/{conversation_id}", + get(get_editor_agent_conversation) + .delete(delete_editor_agent_conversation) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/editor/agent-conversations/{conversation_id}/messages/stream", + post(stream_editor_agent_message) + .layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/project-resources/{resource_id}/showcase", patch(update_editor_project_resource_showcase).route_layer( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b4589c634..5bd8a8d5b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -13,19 +13,18 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, - RuntimeProfileCodeOperationRecord, RuntimeProfileFeedbackEvidenceSnapshot, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, + RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileMembershipBenefitRecord, - RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileMembershipBenefitRecord, RuntimeProfileMembershipTier, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use platform_wechat::pay::WechatPayNotifyOrder; use serde::{Deserialize, Serialize}; @@ -53,18 +52,17 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, - ProfileCodeOperationAdminResponse, ProfileFeedbackEvidenceItemResponse, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileCodeOperationAdminResponse, + ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, - ProfileInviteCodeAdminResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, - ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, - ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, - ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, - ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse, - ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse, - ProfileWalletConfigAdminResponse, ProfileWalletLedgerEntryResponse, + ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, + ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, + ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, + ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, + ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, + ProfileTaskCenterResponse, ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, + ProfileTaskItemResponse, ProfileWalletConfigAdminResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse, diff --git a/server-rs/crates/module-editor-agent/Cargo.toml b/server-rs/crates/module-editor-agent/Cargo.toml new file mode 100644 index 000000000..e54246781 --- /dev/null +++ b/server-rs/crates/module-editor-agent/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-editor-agent" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +shared-kernel = { workspace = true } +spacetimedb = { workspace = true, optional = true } \ No newline at end of file diff --git a/server-rs/crates/module-editor-agent/src/application.rs b/server-rs/crates/module-editor-agent/src/application.rs new file mode 100644 index 000000000..6a9e100db --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/application.rs @@ -0,0 +1,127 @@ +use shared_kernel::normalize_required_string; + +use crate::{ + EDITOR_AGENT_MAX_ATTACHMENTS, EditorAgentConversationCreateInput, + EditorAgentConversationDeleteInput, EditorAgentConversationTouchInput, EditorAgentError, +}; + +pub fn validate_conversation_key( + conversation_id: &str, + owner_user_id: &str, +) -> Result<(String, String), EditorAgentError> { + let conversation_id = normalize_required_string(conversation_id) + .ok_or(EditorAgentError::MissingConversationId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(EditorAgentError::MissingOwnerUserId)?; + Ok((conversation_id, owner_user_id)) +} + +pub fn validate_create_conversation( + input: &EditorAgentConversationCreateInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + if normalize_required_string(&input.project_id).is_none() { + return Err(EditorAgentError::MissingProjectId); + } + Ok(()) +} + +pub fn validate_touch_conversation( + input: &EditorAgentConversationTouchInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + Ok(()) +} + +pub fn validate_delete_conversation( + input: &EditorAgentConversationDeleteInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + Ok(()) +} + +/// 校验会话访问权:拥有者匹配且未软删。 +pub fn ensure_conversation_accessible( + snapshot_owner_user_id: &str, + snapshot_deleted: bool, + requester_user_id: &str, +) -> Result<(), EditorAgentError> { + if snapshot_owner_user_id != requester_user_id { + return Err(EditorAgentError::NotConversationOwner); + } + if snapshot_deleted { + return Err(EditorAgentError::ConversationDeleted); + } + Ok(()) +} + +/// 校验用户消息:文本与附件不可同时为空,附件数量不超过上限,附件引用需带资源标识。 +pub fn validate_user_message( + text: &str, + attachment_reference_ids: &[String], +) -> Result<(), EditorAgentError> { + let has_text = normalize_required_string(text).is_some(); + if !has_text && attachment_reference_ids.is_empty() { + return Err(EditorAgentError::EmptyMessage); + } + if attachment_reference_ids.len() > EDITOR_AGENT_MAX_ATTACHMENTS { + return Err(EditorAgentError::TooManyAttachments); + } + if attachment_reference_ids + .iter() + .any(|reference_id| normalize_required_string(reference_id).is_none()) + { + return Err(EditorAgentError::InvalidAttachmentReference); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive_conversation_title; + + #[test] + fn derive_title_truncates_and_normalizes_whitespace() { + assert_eq!( + derive_conversation_title(" 帮我生成\n一张森林背景 "), + "帮我生成 一张森林背景" + ); + assert_eq!(derive_conversation_title(""), "新对话"); + assert_eq!(derive_conversation_title(" \n "), "新对话"); + let long_text = "一二三四五六七八九十一二三四五六七八九十超出部分"; + assert_eq!(derive_conversation_title(long_text).chars().count(), 20); + } + + #[test] + fn validate_user_message_rules() { + assert_eq!( + validate_user_message("", &[]), + Err(EditorAgentError::EmptyMessage) + ); + assert!(validate_user_message("", &["resource-1".to_string()]).is_ok()); + assert!(validate_user_message("画一棵树", &[]).is_ok()); + let too_many: Vec = (0..10).map(|i| format!("resource-{i}")).collect(); + assert_eq!( + validate_user_message("画一棵树", &too_many), + Err(EditorAgentError::TooManyAttachments) + ); + assert_eq!( + validate_user_message("画一棵树", &[" ".to_string()]), + Err(EditorAgentError::InvalidAttachmentReference) + ); + } + + #[test] + fn ensure_conversation_accessible_rules() { + assert!(ensure_conversation_accessible("user-1", false, "user-1").is_ok()); + assert_eq!( + ensure_conversation_accessible("user-1", false, "user-2"), + Err(EditorAgentError::NotConversationOwner) + ); + assert_eq!( + ensure_conversation_accessible("user-1", true, "user-1"), + Err(EditorAgentError::ConversationDeleted) + ); + } +} diff --git a/server-rs/crates/module-editor-agent/src/commands.rs b/server-rs/crates/module-editor-agent/src/commands.rs new file mode 100644 index 000000000..936465fef --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/commands.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationCreateInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationTouchInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationDeleteInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationListInput { + pub project_id: String, + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationGetInput { + pub conversation_id: String, + pub owner_user_id: String, +} diff --git a/server-rs/crates/module-editor-agent/src/domain.rs b/server-rs/crates/module-editor-agent/src/domain.rs new file mode 100644 index 000000000..1b98a4264 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/domain.rs @@ -0,0 +1,57 @@ +//! 画布Agent对话领域模型。 +//! +//! 本 crate 只描述会话元数据、消息与附件的纯领域事实与规则; +//! LLM、SSE、OSS 读写和 SpacetimeDB 写表均留在外层 adapter。 + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const EDITOR_AGENT_CONVERSATION_ID_PREFIX: &str = "editor-agent-conv-"; +pub const EDITOR_AGENT_MESSAGE_ID_PREFIX: &str = "editor-agent-message-"; +/// 会话消息 OSS 对象目录前缀,完整键见 [`editor_agent_messages_object_key`]。 +pub const EDITOR_AGENT_MESSAGES_OBJECT_PREFIX: &str = "editor-agent/"; +/// 单条消息附件上限(超出由前端拦截、后端兜底拒绝)。 +pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +/// 会话标题取首条用户消息的前 N 个字符。 +pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; +/// 新会话默认标题。 +pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; + +/// 会话元数据快照:SpacetimeDB 表只存这些字段,消息正文整体存 OSS。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationSnapshot { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +/// 会话消息 OSS 对象键:`editor-agent/{conversationId}.json`,会话粒度整体读写。 +pub fn editor_agent_messages_object_key(conversation_id: &str) -> String { + format!("{EDITOR_AGENT_MESSAGES_OBJECT_PREFIX}{conversation_id}.json") +} + +/// 从首条用户消息推导会话标题:去掉首尾空白与换行后截取前 N 个字符; +/// 空文本(例如纯附件消息)退回默认标题。 +pub fn derive_conversation_title(first_message_text: &str) -> String { + let normalized: String = first_message_text + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if normalized.is_empty() { + return EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string(); + } + normalized + .chars() + .take(EDITOR_AGENT_TITLE_MAX_CHARS) + .collect() +} diff --git a/server-rs/crates/module-editor-agent/src/errors.rs b/server-rs/crates/module-editor-agent/src/errors.rs new file mode 100644 index 000000000..69e31f2f8 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/errors.rs @@ -0,0 +1,33 @@ +use std::{error::Error, fmt}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorAgentError { + MissingConversationId, + MissingProjectId, + MissingOwnerUserId, + MissingMessageId, + EmptyMessage, + TooManyAttachments, + InvalidAttachmentReference, + ConversationDeleted, + NotConversationOwner, +} + +impl fmt::Display for EditorAgentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingConversationId => "editor agent conversation_id 缺失", + Self::MissingProjectId => "editor agent project_id 缺失", + Self::MissingOwnerUserId => "editor agent owner_user_id 缺失", + Self::MissingMessageId => "editor agent message_id 缺失", + Self::EmptyMessage => "消息内容为空(文本与附件均缺失)", + Self::TooManyAttachments => "单条消息附件超过上限", + Self::InvalidAttachmentReference => "附件引用缺少资源标识", + Self::ConversationDeleted => "会话已删除", + Self::NotConversationOwner => "当前用户不是会话拥有者", + }; + write!(f, "{message}") + } +} + +impl Error for EditorAgentError {} diff --git a/server-rs/crates/module-editor-agent/src/lib.rs b/server-rs/crates/module-editor-agent/src/lib.rs new file mode 100644 index 000000000..b68fa5247 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/lib.rs @@ -0,0 +1,9 @@ +mod application; +mod commands; +mod domain; +mod errors; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 17dd52566..06dff5292 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -24,6 +24,7 @@ 7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志 9. generated 私有对象上传默认写入 `Cache-Control: public, max-age=31536000, immutable` +10. `editor-agent/` 服务端内部消息文档前缀,以及仅供后端使用的内部短期读签名能力 当前仍未落地的内容: @@ -41,9 +42,15 @@ 6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 7. 完整 OSS URL 不能当作 object key 传入签名接口;前端收到 `https://*.oss-*.aliyuncs.com/generated-*` 时应先归一为 legacy public path,再通过 `/api/assets/read-url` 换取短期 signed URL。 8. generated 资源缓存的主路径是 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN,不允许改成 api-server 本地磁盘静态资源兜底。 +9. `PostObject` 是 OSS 基础设施能力,不代表所有前缀都允许浏览器直传;`editor-agent/` 只用于画布 Agent 会话消息文档,属于服务端内部前缀,不向浏览器开放写入。 +10. 画布 Agent 消息文档固定使用 `editor-agent/{conversationId}.json`,由 `api-server` 通过服务端 `PutObject` 整体写入,通过内部读签名读取;浏览器只调用会话 API,不直接持有 `editor-agent/` 对象的 OSS 写权限、STS 写权限或直传表单。 +11. `editor-agent/` 不属于 legacy `/generated-*` 公开资源前缀,也不应通过通用 `/api/assets/read-url` 暴露为前端可自行读写的对象地址。 +12. 画布 Agent 消息文档的业务上限、会话内串行写入、失败 generation record 保留等规则由 `api-server` 与 ADR 约束;`platform-oss` 只负责对象 key 规范化、签名和服务端上传能力。 ## 3. 边界约束 1. `platform-oss` 只承接对象存储平台适配,不承接业务实体状态与业务规则。 2. 资产状态与对象绑定最终由业务模块和 `apps/spacetime-module` 管理,前端接口由 `apps/api-server` 暴露。 3. 不允许把 OSS SDK、签名逻辑和 URL 策略重新散落到多个业务模块里各自实现。 +4. `editor-agent/` 前缀是服务端内部消息文档命名空间,只允许保存 `editor-agent/{conversationId}.json` 形态的会话级 JSON 文档。 +5. 浏览器直传能力不得用于 `editor-agent/` 前缀;若未来需要上传用户附件,应走独立的附件 / 资产前缀和确认链路,不得复用会话消息文档对象。 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index d4aee7772..37dd079a1 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -49,6 +49,7 @@ pub enum OssObjectAccess { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LegacyAssetPrefix { + EditorAgent, CharacterDrafts, Characters, Animations, @@ -242,6 +243,7 @@ impl LegacyAssetPrefix { .trim_end_matches('/'); match normalized { + "editor-agent" => Some(Self::EditorAgent), "generated-character-drafts" => Some(Self::CharacterDrafts), "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), @@ -262,6 +264,7 @@ impl LegacyAssetPrefix { pub fn as_str(&self) -> &'static str { match self { + Self::EditorAgent => "editor-agent", Self::CharacterDrafts => "generated-character-drafts", Self::Characters => "generated-characters", Self::Animations => "generated-animations", @@ -535,6 +538,30 @@ impl OssClient { pub fn sign_get_object_url( &self, request: OssSignedGetObjectUrlRequest, + ) -> Result { + self.sign_get_object_url_with_normalizer( + request, + normalize_object_key, + "sign_get_object_url", + ) + } + + pub fn sign_internal_get_object_url( + &self, + request: OssSignedGetObjectUrlRequest, + ) -> Result { + self.sign_get_object_url_with_normalizer( + request, + normalize_editor_agent_messages_object_key, + "sign_internal_get_object_url", + ) + } + + fn sign_get_object_url_with_normalizer( + &self, + request: OssSignedGetObjectUrlRequest, + normalize: fn(&str) -> Result, + operation: &'static str, ) -> Result { let started_at = Instant::now(); let requested_object_key = request @@ -555,7 +582,7 @@ impl OssClient { )); } - let object_key = normalize_object_key(&request.object_key)?; + let object_key = normalize(&request.object_key)?; let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), @@ -627,7 +654,7 @@ impl OssClient { match &result { Ok(response) => info!( provider = OSS_PROVIDER, - operation = "sign_get_object_url", + operation, bucket = %response.bucket, endpoint = %response.endpoint, object_key = %response.object_key, @@ -637,7 +664,7 @@ impl OssClient { ), Err(error) => warn!( provider = OSS_PROVIDER, - operation = "sign_get_object_url", + operation, bucket = %self.config.bucket(), endpoint = %self.config.endpoint(), object_key = %requested_object_key, @@ -1031,12 +1058,48 @@ fn normalize_object_key(raw: &str) -> Result { return Err(OssError::InvalidRequest("objectKey 不能为空".to_string())); } - if LegacyAssetPrefix::from_object_key(&normalized).is_none() { + match LegacyAssetPrefix::from_object_key(&normalized) { + Some(LegacyAssetPrefix::EditorAgent) | None => { + return Err(OssError::InvalidRequest( + "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + )); + } + Some(_) => {} + } + + validate_object_key_segments(&normalized)?; + Ok(normalized) +} + +fn normalize_editor_agent_messages_object_key(raw: &str) -> Result { + let normalized = raw.trim().trim_start_matches('/').trim().to_string(); + if normalized.is_empty() { + return Err(OssError::InvalidRequest("objectKey 不能为空".to_string())); + } + validate_object_key_segments(&normalized)?; + + let mut segments = normalized.split('/'); + let prefix = segments.next(); + let file_name = segments.next(); + if prefix != Some(LegacyAssetPrefix::EditorAgent.as_str()) + || segments.next().is_some() + || !file_name + .map(|value| { + value.starts_with("editor-agent-conv-") + && value.ends_with(".json") + && value.len() > "editor-agent-conv-.json".len() + }) + .unwrap_or(false) + { return Err(OssError::InvalidRequest( - "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + "objectKey 必须是 editor-agent 消息文档".to_string(), )); } + Ok(normalized) +} + +fn validate_object_key_segments(normalized: &str) -> Result<(), OssError> { let segments = normalized.split('/').collect::>(); if segments.len() < 2 { return Err(OssError::InvalidRequest( @@ -1058,7 +1121,7 @@ fn normalize_object_key(raw: &str) -> Result { } } - Ok(normalized) + Ok(()) } fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String { @@ -1860,6 +1923,51 @@ mod tests { ); } + #[test] + fn sign_internal_get_object_url_allows_editor_agent_messages_document() { + let client = build_client(); + + let response = client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: "editor-agent/editor-agent-conv-1.json".to_string(), + expire_seconds: Some(300), + }) + .expect("editor agent messages document should build signed url"); + + assert_eq!( + response.object_key, + "editor-agent/editor-agent-conv-1.json".to_string() + ); + assert!(response.signed_url.starts_with( + "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/editor-agent/editor-agent-conv-1.json?" + )); + } + + #[test] + fn sign_internal_get_object_url_rejects_non_editor_agent_messages_document() { + let client = build_client(); + + for object_key in [ + "workflow-cache/task-1.json", + "generated-characters/hero/master.png", + "editor-agent/editor-agent-conv-1/part.json", + "editor-agent/other.json", + "editor-agent/editor-agent-conv-1.png", + ] { + let error = client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(300), + }) + .expect_err("non editor agent messages document should fail"); + + assert_eq!( + error, + OssError::InvalidRequest("objectKey 必须是 editor-agent 消息文档".to_string()) + ); + } + } + #[test] fn legacy_prefix_can_be_resolved_from_object_key() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs new file mode 100644 index 000000000..7fa5a17d2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -0,0 +1,384 @@ +//! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。 +//! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 + +use serde::{Deserialize, Serialize}; + +pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; +pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; +pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentStage { + Idle, + Thinking, + Responding, + Generating, + Completed, + Failed, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EditorAgentMessageRole { + User, + Assistant, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentMessageKind { + Chat, + Stage, + Error, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentMessageStatus { + Streaming, + Generating, + Completed, + Failed, + Stopped, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentAttachmentSource { + CanvasResource, + LibraryAsset, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentToolName { + GenerateImage, + EditImage, + GenerateCharacter, + GenerateIconSpritesheet, + GenerateUiDesign, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentGenerationStatus { + Generating, + Completed, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentAttachmentRef { + pub source: EditorAgentAttachmentSource, + pub reference_id: String, + #[serde(default)] + pub object_key: Option, + pub image_src: String, + #[serde(default)] + pub thumbnail_src: Option, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGeneratedImage { + #[serde(default)] + pub resource_id: Option, + pub image_src: String, + #[serde(default)] + pub thumbnail_src: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGenerationRecord { + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub task_id: Option, + pub status: EditorAgentGenerationStatus, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentMessage { + pub id: String, + pub role: EditorAgentMessageRole, + pub kind: EditorAgentMessageKind, + pub text: String, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub generations: Vec, + pub status: EditorAgentMessageStatus, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationSummary { + pub conversation_id: String, + pub project_id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationDetail { + pub conversation_id: String, + pub project_id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationMessagesDocument { + pub version: u32, + pub conversation_id: String, + pub messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateEditorAgentConversationRequest { + #[serde(default)] + pub title: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationListResponse { + pub conversations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationResponse { + pub conversation: EditorAgentConversationDetail, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StreamEditorAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentSseEventType { + Stage, + MessageDelta, + ToolStarted, + ToolCompleted, + GenerationResult, + Error, + Done, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentSseEnvelope { + pub event: EditorAgentSseEventType, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentStageEvent { + pub conversation_id: String, + pub stage: EditorAgentStage, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentMessageDeltaEvent { + pub conversation_id: String, + pub message_id: String, + pub role: EditorAgentMessageRole, + pub kind: EditorAgentMessageKind, + pub text_delta: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolEvent { + pub conversation_id: String, + pub message_id: String, + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub task_id: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGenerationResultEvent { + pub conversation_id: String, + pub message_id: String, + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub model: Option, + pub images: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentErrorEvent { + #[serde(default)] + pub conversation_id: Option, + pub code: String, + pub message: String, + pub recoverable: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentDoneEvent { + pub conversation_id: String, + #[serde(default)] + pub title: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn editor_agent_conversation_detail_uses_camel_case() { + let detail = EditorAgentConversationDetail { + conversation_id: "editor-agent-conv-1".to_string(), + project_id: "project-1".to_string(), + title: "新对话".to_string(), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:01Z".to_string(), + messages: vec![EditorAgentMessage { + id: "message-1".to_string(), + role: EditorAgentMessageRole::User, + kind: EditorAgentMessageKind::Chat, + text: "帮我生成一张森林背景".to_string(), + attachments: vec![EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("editor/resource-1.png".to_string()), + image_src: "https://example.test/resource-1.png".to_string(), + thumbnail_src: None, + label: Some("森林参考".to_string()), + width: Some(1024), + height: Some(768), + }], + generations: vec![], + status: EditorAgentMessageStatus::Completed, + created_at: "2026-07-03T00:00:00Z".to_string(), + }], + }; + + let payload = serde_json::to_value(&detail).expect("detail should serialize"); + assert_eq!(payload["conversationId"], json!("editor-agent-conv-1")); + assert_eq!(payload["messages"][0]["role"], json!("user")); + assert_eq!( + payload["messages"][0]["attachments"][0]["source"], + json!("canvas_resource") + ); + assert_eq!( + payload["messages"][0]["attachments"][0]["referenceId"], + json!("resource-1") + ); + assert_eq!(payload["messages"][0]["status"], json!("completed")); + + let decoded: EditorAgentConversationDetail = + serde_json::from_value(payload).expect("detail should deserialize"); + assert_eq!(decoded, detail); + } + + #[test] + fn editor_agent_sse_events_serialize_event_names() { + let envelope = EditorAgentSseEnvelope { + event: EditorAgentSseEventType::GenerationResult, + data: serde_json::to_value(EditorAgentGenerationResultEvent { + conversation_id: "editor-agent-conv-1".to_string(), + message_id: "message-2".to_string(), + tool_call_id: "tool-call-1".to_string(), + tool_name: EditorAgentToolName::GenerateImage, + model: Some("z-image".to_string()), + images: vec![EditorAgentGeneratedImage { + resource_id: Some("resource-2".to_string()), + image_src: "https://example.test/generated.png".to_string(), + thumbnail_src: None, + width: Some(1024), + height: Some(1024), + }], + }) + .expect("event data should serialize"), + }; + + let payload = serde_json::to_value(envelope).expect("envelope should serialize"); + assert_eq!(payload["event"], json!("generation_result")); + assert_eq!(payload["data"]["toolName"], json!("generate_image")); + assert_eq!( + payload["data"]["images"][0]["resourceId"], + json!("resource-2") + ); + + let error_envelope = EditorAgentSseEnvelope { + event: EditorAgentSseEventType::Error, + data: serde_json::to_value(EditorAgentErrorEvent { + conversation_id: Some("editor-agent-conv-1".to_string()), + code: "INSUFFICIENT_BALANCE".to_string(), + message: "泥点余额不足,本次生成未执行".to_string(), + recoverable: true, + }) + .expect("event data should serialize"), + }; + let error_payload = + serde_json::to_value(error_envelope).expect("envelope should serialize"); + assert_eq!(error_payload["event"], json!("error")); + assert_eq!(error_payload["data"]["recoverable"], json!(true)); + } + + #[test] + fn stream_request_defaults_attachments_to_empty() { + let payload = json!({ + "clientMessageId": "client-message-1", + "text": "随便聊聊" + }); + let decoded: StreamEditorAgentMessageRequest = + serde_json::from_value(payload).expect("request should deserialize"); + assert!(decoded.attachments.is_empty()); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 8f21eacb1..6298f0cc5 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -11,6 +11,7 @@ pub mod creation_agent_document_input; pub mod creation_audio; pub mod creation_entry_config; pub mod creative_agent; +pub mod editor_agent; pub mod external_generation; pub mod hyper3d; pub mod jump_hop; diff --git a/server-rs/crates/spacetime-client/src/editor_agent.rs b/server-rs/crates/spacetime-client/src/editor_agent.rs new file mode 100644 index 000000000..c9b9a2d71 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/editor_agent.rs @@ -0,0 +1,136 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_editor_agent_conversation( + &self, + input: EditorAgentConversationCreateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "create_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .create_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_editor_agent_conversations( + &self, + project_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = EditorAgentConversationListInput { + project_id, + owner_user_id, + }; + + self.call_after_connect( + "list_editor_agent_conversations_and_return", + move |connection, sender| { + connection + .procedures() + .list_editor_agent_conversations_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_list_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_editor_agent_conversation( + &self, + conversation_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = EditorAgentConversationGetInput { + conversation_id, + owner_user_id, + }; + + self.call_after_connect( + "get_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .get_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn touch_editor_agent_conversation( + &self, + input: EditorAgentConversationTouchRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "touch_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .touch_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn delete_editor_agent_conversation( + &self, + input: EditorAgentConversationDeleteRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "delete_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .delete_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1ad8c2924..09978f973 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,9 +30,12 @@ pub use mapper::{ CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, - EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, - EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, + CustomWorldWorkSummaryRecord, EditorAgentConversationCreateRecordInput, + EditorAgentConversationDeleteRecordInput, EditorAgentConversationRecord, + EditorAgentConversationTouchRecordInput, EditorAssetCreateRecordInput, + EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, @@ -129,6 +132,7 @@ pub use bark_battle::{ pub mod big_fish; pub mod combat; pub mod custom_world; +pub mod editor_agent; pub mod editor_project; pub mod external_api_key; pub mod external_generation; @@ -202,8 +206,7 @@ use module_runtime::{ RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeAdminListRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductConfigRecord, - RuntimeProfileRedeemCodeAdminListRecord, + RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeAdminListRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, @@ -214,9 +217,8 @@ use module_runtime::{ RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, - build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, - build_runtime_profile_code_operation_record, - build_runtime_profile_feedback_submission_input, + build_runtime_profile_code_operation_record, build_runtime_profile_dashboard_get_input, + build_runtime_profile_dashboard_record, build_runtime_profile_feedback_submission_input, build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 10247312e..0b2c95fcc 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -8,6 +8,7 @@ mod big_fish; mod combat; mod common; mod custom_world; +mod editor_agent; mod editor_project; mod external_api_key; mod external_generation; @@ -72,6 +73,10 @@ pub use self::common::{ VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelWorkCompileRecordInput, }; +pub use self::editor_agent::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, +}; pub use self::editor_project::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, @@ -207,6 +212,10 @@ pub(crate) use self::custom_world::{ parse_rpg_agent_operation_status_record, parse_rpg_agent_operation_type_record, parse_rpg_agent_stage_record, }; +pub(crate) use self::editor_agent::{ + map_editor_agent_conversation_list_procedure_result, + map_editor_agent_conversation_procedure_result, +}; pub(crate) use self::editor_project::{ map_editor_asset_folder_library_procedure_result, map_editor_asset_folder_procedure_result, map_editor_asset_library_procedure_result, map_editor_asset_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs b/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs new file mode 100644 index 000000000..65cd0a5c3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs @@ -0,0 +1,122 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorAgentConversationRecord { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at: String, + pub updated_at: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationCreateRecordInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationTouchRecordInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationDeleteRecordInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} + +impl From + for crate::module_bindings::EditorAgentConversationCreateInput +{ + fn from(input: EditorAgentConversationCreateRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + project_id: input.project_id, + owner_user_id: input.owner_user_id, + title: input.title, + messages_object_key: input.messages_object_key, + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorAgentConversationTouchInput +{ + fn from(input: EditorAgentConversationTouchRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + owner_user_id: input.owner_user_id, + title: input.title, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorAgentConversationDeleteInput +{ + fn from(input: EditorAgentConversationDeleteRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + owner_user_id: input.owner_user_id, + updated_at_micros: input.updated_at_micros, + } + } +} + +fn map_editor_agent_conversation_snapshot( + snapshot: crate::module_bindings::EditorAgentConversationSnapshot, +) -> EditorAgentConversationRecord { + EditorAgentConversationRecord { + conversation_id: snapshot.conversation_id, + project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, + title: snapshot.title, + messages_object_key: snapshot.messages_object_key, + deleted: snapshot.deleted, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_editor_agent_conversation_procedure_result( + result: crate::module_bindings::EditorAgentConversationProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .conversation + .map(map_editor_agent_conversation_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("画布Agent会话快照")) +} + +pub(crate) fn map_editor_agent_conversation_list_procedure_result( + result: crate::module_bindings::EditorAgentConversationListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .conversations + .into_iter() + .map(map_editor_agent_conversation_snapshot) + .collect()) +} diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 825b463d2..8fbad4d71 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -675,14 +675,14 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( Ok(RuntimeProfileRedeemCodeAdminListRecord { entries: result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( - snapshot, - )) - }) - .collect(), + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( + snapshot, + )) + }) + .collect(), operations: result .operations .into_iter() @@ -724,14 +724,14 @@ pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( Ok(RuntimeProfileInviteCodeAdminListRecord { entries: result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( - snapshot, - )) - }) - .collect(), + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( + snapshot, + )) + }) + .collect(), operations: result .operations .into_iter() diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index a19453595..46dcda62b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). +// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -240,6 +240,7 @@ pub mod create_battle_state_and_return_procedure; pub mod create_battle_state_reducer; pub mod create_big_fish_session_procedure; pub mod create_custom_world_agent_session_procedure; +pub mod create_editor_agent_conversation_and_return_procedure; pub mod create_editor_asset_and_return_procedure; pub mod create_editor_asset_folder_and_return_procedure; pub mod create_editor_project_and_return_procedure; @@ -345,6 +346,7 @@ pub mod delete_bark_battle_work_procedure; pub mod delete_big_fish_work_procedure; pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_profile_and_return_procedure; +pub mod delete_editor_agent_conversation_and_return_procedure; pub mod delete_editor_asset_and_return_procedure; pub mod delete_editor_asset_folder_and_return_procedure; pub mod delete_editor_project_and_return_procedure; @@ -357,6 +359,16 @@ pub mod delete_visual_novel_work_procedure; pub mod delete_wooden_fish_work_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod drop_square_hole_shape_procedure; +pub mod editor_agent_conversation_create_input_type; +pub mod editor_agent_conversation_delete_input_type; +pub mod editor_agent_conversation_get_input_type; +pub mod editor_agent_conversation_list_input_type; +pub mod editor_agent_conversation_list_procedure_result_type; +pub mod editor_agent_conversation_procedure_result_type; +pub mod editor_agent_conversation_snapshot_type; +pub mod editor_agent_conversation_table; +pub mod editor_agent_conversation_touch_input_type; +pub mod editor_agent_conversation_type; pub mod editor_asset_create_input_type; pub mod editor_asset_delete_input_type; pub mod editor_asset_folder_create_input_type; @@ -459,6 +471,7 @@ pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; +pub mod get_editor_agent_conversation_and_return_procedure; pub mod get_editor_asset_library_and_return_procedure; pub mod get_editor_project_and_return_procedure; pub mod get_external_generation_job_and_return_procedure; @@ -572,6 +585,7 @@ pub mod list_big_fish_works_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; +pub mod list_editor_agent_conversations_and_return_procedure; pub mod list_editor_projects_and_return_procedure; pub mod list_external_api_keys_and_return_procedure; pub mod list_external_generation_jobs_and_return_procedure; @@ -1110,6 +1124,7 @@ pub mod submit_visual_novel_agent_message_procedure; pub mod swap_puzzle_clear_cards_procedure; pub mod swap_puzzle_pieces_procedure; pub mod sync_auth_store_projection_procedure; +pub mod touch_editor_agent_conversation_and_return_procedure; pub mod tracking_daily_stat_table; pub mod tracking_daily_stat_type; pub mod tracking_event_table; @@ -1471,6 +1486,7 @@ pub use create_battle_state_and_return_procedure::create_battle_state_and_return pub use create_battle_state_reducer::create_battle_state; pub use create_big_fish_session_procedure::create_big_fish_session; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; +pub use create_editor_agent_conversation_and_return_procedure::create_editor_agent_conversation_and_return; pub use create_editor_asset_and_return_procedure::create_editor_asset_and_return; pub use create_editor_asset_folder_and_return_procedure::create_editor_asset_folder_and_return; pub use create_editor_project_and_return_procedure::create_editor_project_and_return; @@ -1576,6 +1592,7 @@ pub use delete_bark_battle_work_procedure::delete_bark_battle_work; pub use delete_big_fish_work_procedure::delete_big_fish_work; pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return; +pub use delete_editor_agent_conversation_and_return_procedure::delete_editor_agent_conversation_and_return; pub use delete_editor_asset_and_return_procedure::delete_editor_asset_and_return; pub use delete_editor_asset_folder_and_return_procedure::delete_editor_asset_folder_and_return; pub use delete_editor_project_and_return_procedure::delete_editor_project_and_return; @@ -1588,6 +1605,16 @@ pub use delete_visual_novel_work_procedure::delete_visual_novel_work; pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drop_square_hole_shape_procedure::drop_square_hole_shape; +pub use editor_agent_conversation_create_input_type::EditorAgentConversationCreateInput; +pub use editor_agent_conversation_delete_input_type::EditorAgentConversationDeleteInput; +pub use editor_agent_conversation_get_input_type::EditorAgentConversationGetInput; +pub use editor_agent_conversation_list_input_type::EditorAgentConversationListInput; +pub use editor_agent_conversation_list_procedure_result_type::EditorAgentConversationListProcedureResult; +pub use editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; +pub use editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; +pub use editor_agent_conversation_table::*; +pub use editor_agent_conversation_touch_input_type::EditorAgentConversationTouchInput; +pub use editor_agent_conversation_type::EditorAgentConversation; pub use editor_asset_create_input_type::EditorAssetCreateInput; pub use editor_asset_delete_input_type::EditorAssetDeleteInput; pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput; @@ -1690,6 +1717,7 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return; pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return; pub use get_editor_project_and_return_procedure::get_editor_project_and_return; pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; @@ -1803,6 +1831,7 @@ pub use list_big_fish_works_procedure::list_big_fish_works; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; +pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return; pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return; @@ -2341,6 +2370,7 @@ pub use submit_visual_novel_agent_message_procedure::submit_visual_novel_agent_m pub use swap_puzzle_clear_cards_procedure::swap_puzzle_clear_cards; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use sync_auth_store_projection_procedure::sync_auth_store_projection; +pub use touch_editor_agent_conversation_and_return_procedure::touch_editor_agent_conversation_and_return; pub use tracking_daily_stat_table::*; pub use tracking_daily_stat_type::TrackingDailyStat; pub use tracking_event_table::*; @@ -2778,6 +2808,7 @@ pub struct DbUpdate { custom_world_session: __sdk::TableUpdate, database_migration_import_chunk: __sdk::TableUpdate, database_migration_operator: __sdk::TableUpdate, + editor_agent_conversation: __sdk::TableUpdate, editor_asset: __sdk::TableUpdate, editor_asset_folder: __sdk::TableUpdate, editor_canvas: __sdk::TableUpdate, @@ -2997,6 +3028,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "database_migration_operator" => db_update.database_migration_operator.append( database_migration_operator_table::parse_table_update(table_update)?, ), + "editor_agent_conversation" => db_update.editor_agent_conversation.append( + editor_agent_conversation_table::parse_table_update(table_update)?, + ), "editor_asset" => db_update .editor_asset .append(editor_asset_table::parse_table_update(table_update)?), @@ -3481,6 +3515,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.database_migration_operator, ) .with_updates_by_pk(|row| &row.operator_identity); + diff.editor_agent_conversation = cache + .apply_diff_to_table::( + "editor_agent_conversation", + &self.editor_agent_conversation, + ) + .with_updates_by_pk(|row| &row.conversation_id); diff.editor_asset = cache .apply_diff_to_table::("editor_asset", &self.editor_asset) .with_updates_by_pk(|row| &row.asset_id); @@ -4053,6 +4093,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_agent_conversation" => db_update + .editor_agent_conversation + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_asset" => db_update .editor_asset .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4447,6 +4490,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_agent_conversation" => db_update + .editor_agent_conversation + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_asset" => db_update .editor_asset .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4769,6 +4815,7 @@ pub struct AppliedDiff<'r> { custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>, database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>, database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>, + editor_agent_conversation: __sdk::TableAppliedDiff<'r, EditorAgentConversation>, editor_asset: __sdk::TableAppliedDiff<'r, EditorAsset>, editor_asset_folder: __sdk::TableAppliedDiff<'r, EditorAssetFolder>, editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>, @@ -5054,6 +5101,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.database_migration_operator, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_agent_conversation", + &self.editor_agent_conversation, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_asset", &self.editor_asset, @@ -6185,6 +6237,7 @@ impl __sdk::SpacetimeModule for RemoteModule { custom_world_session_table::register_table(client_cache); database_migration_import_chunk_table::register_table(client_cache); database_migration_operator_table::register_table(client_cache); + editor_agent_conversation_table::register_table(client_cache); editor_asset_table::register_table(client_cache); editor_asset_folder_table::register_table(client_cache); editor_canvas_table::register_table(client_cache); @@ -6314,6 +6367,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "custom_world_session", "database_migration_import_chunk", "database_migration_operator", + "editor_agent_conversation", "editor_asset", "editor_asset_folder", "editor_canvas", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..6e45ee7c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_create_input_type::EditorAgentConversationCreateInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationCreateInput, +} + +impl __sdk::InModule for CreateEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_editor_agent_conversation_and_return { + fn create_editor_agent_conversation_and_return( + &self, + input: EditorAgentConversationCreateInput, + ) { + self.create_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn create_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_editor_agent_conversation_and_return for super::RemoteProcedures { + fn create_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "create_editor_agent_conversation_and_return", + CreateEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e7bf76bfd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_delete_input_type::EditorAgentConversationDeleteInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct DeleteEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationDeleteInput, +} + +impl __sdk::InModule for DeleteEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_editor_agent_conversation_and_return { + fn delete_editor_agent_conversation_and_return( + &self, + input: EditorAgentConversationDeleteInput, + ) { + self.delete_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn delete_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl delete_editor_agent_conversation_and_return for super::RemoteProcedures { + fn delete_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "delete_editor_agent_conversation_and_return", + DeleteEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs new file mode 100644 index 000000000..cc5fdf357 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationCreateInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs new file mode 100644 index 000000000..b58f724da --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationDeleteInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs new file mode 100644 index 000000000..fbd9797ac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationGetInput { + pub conversation_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAgentConversationGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs new file mode 100644 index 000000000..c2b916e83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationListInput { + pub project_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAgentConversationListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs new file mode 100644 index 000000000..210496963 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationListProcedureResult { + pub ok: bool, + pub conversations: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for EditorAgentConversationListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs new file mode 100644 index 000000000..347e6a577 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationProcedureResult { + pub ok: bool, + pub conversation: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorAgentConversationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs new file mode 100644 index 000000000..e1b35531c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationSnapshot { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs new file mode 100644 index 000000000..09558b858 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_agent_conversation_type::EditorAgentConversation; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_agent_conversation`. +/// +/// Obtain a handle from the [`EditorAgentConversationTableAccess::editor_agent_conversation`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_agent_conversation()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_agent_conversation().on_insert(...)`. +pub struct EditorAgentConversationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_agent_conversation`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorAgentConversationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorAgentConversationTableHandle`], which mediates access to the table `editor_agent_conversation`. + fn editor_agent_conversation(&self) -> EditorAgentConversationTableHandle<'_>; +} + +impl EditorAgentConversationTableAccess for super::RemoteTables { + fn editor_agent_conversation(&self) -> EditorAgentConversationTableHandle<'_> { + EditorAgentConversationTableHandle { + imp: self + .imp + .get_table::("editor_agent_conversation"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorAgentConversationInsertCallbackId(__sdk::CallbackId); +pub struct EditorAgentConversationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorAgentConversationTableHandle<'ctx> { + type Row = EditorAgentConversation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorAgentConversationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationInsertCallbackId { + EditorAgentConversationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorAgentConversationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorAgentConversationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationDeleteCallbackId { + EditorAgentConversationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorAgentConversationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorAgentConversationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorAgentConversationTableHandle<'ctx> { + type UpdateCallbackId = EditorAgentConversationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationUpdateCallbackId { + EditorAgentConversationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorAgentConversationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `conversation_id` unique index on the table `editor_agent_conversation`, +/// which allows point queries on the field of the same name +/// via the [`EditorAgentConversationConversationIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_agent_conversation().conversation_id().find(...)`. +pub struct EditorAgentConversationConversationIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorAgentConversationTableHandle<'ctx> { + /// Get a handle on the `conversation_id` unique index on the table `editor_agent_conversation`. + pub fn conversation_id(&self) -> EditorAgentConversationConversationIdUnique<'ctx> { + EditorAgentConversationConversationIdUnique { + imp: self.imp.get_unique_constraint::("conversation_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorAgentConversationConversationIdUnique<'ctx> { + /// Find the subscribed row whose `conversation_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("editor_agent_conversation"); + _table.add_unique_constraint::("conversation_id", |row| &row.conversation_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorAgentConversation`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_agent_conversationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorAgentConversation`. + fn editor_agent_conversation(&self) -> __sdk::__query_builder::Table; +} + +impl editor_agent_conversationQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_agent_conversation(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_agent_conversation") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs new file mode 100644 index 000000000..8142bd62b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationTouchInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationTouchInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs new file mode 100644 index 000000000..e50ec9088 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs @@ -0,0 +1,77 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversation { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorAgentConversation { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorAgentConversation`. +/// +/// Provides typed access to columns for query building. +pub struct EditorAgentConversationCols { + pub conversation_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub messages_object_key: __sdk::__query_builder::Col, + pub deleted: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorAgentConversation { + type Cols = EditorAgentConversationCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorAgentConversationCols { + conversation_id: __sdk::__query_builder::Col::new(table_name, "conversation_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + messages_object_key: __sdk::__query_builder::Col::new( + table_name, + "messages_object_key", + ), + deleted: __sdk::__query_builder::Col::new(table_name, "deleted"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorAgentConversation`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorAgentConversationIxCols { + pub conversation_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorAgentConversation { + type IxCols = EditorAgentConversationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorAgentConversationIxCols { + conversation_id: __sdk::__query_builder::IxCol::new(table_name, "conversation_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorAgentConversation {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e45514af6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_get_input_type::EditorAgentConversationGetInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationGetInput, +} + +impl __sdk::InModule for GetEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_editor_agent_conversation_and_return { + fn get_editor_agent_conversation_and_return(&self, input: EditorAgentConversationGetInput) { + self.get_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn get_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_editor_agent_conversation_and_return for super::RemoteProcedures { + fn get_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "get_editor_agent_conversation_and_return", + GetEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs new file mode 100644 index 000000000..b1f34b959 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_list_input_type::EditorAgentConversationListInput; +use super::editor_agent_conversation_list_procedure_result_type::EditorAgentConversationListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListEditorAgentConversationsAndReturnArgs { + pub input: EditorAgentConversationListInput, +} + +impl __sdk::InModule for ListEditorAgentConversationsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_editor_agent_conversations_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_editor_agent_conversations_and_return { + fn list_editor_agent_conversations_and_return(&self, input: EditorAgentConversationListInput) { + self.list_editor_agent_conversations_and_return_then(input, |_, _| {}); + } + + fn list_editor_agent_conversations_and_return_then( + &self, + input: EditorAgentConversationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_editor_agent_conversations_and_return for super::RemoteProcedures { + fn list_editor_agent_conversations_and_return_then( + &self, + input: EditorAgentConversationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationListProcedureResult>( + "list_editor_agent_conversations_and_return", + ListEditorAgentConversationsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e331ab348 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; +use super::editor_agent_conversation_touch_input_type::EditorAgentConversationTouchInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct TouchEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationTouchInput, +} + +impl __sdk::InModule for TouchEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `touch_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait touch_editor_agent_conversation_and_return { + fn touch_editor_agent_conversation_and_return(&self, input: EditorAgentConversationTouchInput) { + self.touch_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn touch_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationTouchInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl touch_editor_agent_conversation_and_return for super::RemoteProcedures { + fn touch_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationTouchInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "touch_editor_agent_conversation_and_return", + TouchEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index d2bcf4501..2a62b2921 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -19,6 +19,7 @@ module-big-fish = { workspace = true, features = ["spacetime-types"] } module-combat = { workspace = true, features = ["spacetime-types"] } module-inventory = { workspace = true, features = ["spacetime-types"] } module-custom-world = { workspace = true, features = ["spacetime-types"] } +module-editor-agent = { workspace = true, features = ["spacetime-types"] } module-jump-hop = { workspace = true, features = ["spacetime-types"] } module-wooden-fish = { workspace = true, features = ["spacetime-types"] } module-match3d = { workspace = true } diff --git a/server-rs/crates/spacetime-module/src/editor_agent_storage.rs b/server-rs/crates/spacetime-module/src/editor_agent_storage.rs new file mode 100644 index 000000000..3b5a1a607 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/editor_agent_storage.rs @@ -0,0 +1,320 @@ +// 中文注释:画布Agent对话会话元数据表。消息正文整体存 OSS(editor-agent/{conversationId}.json), +// 表中只保存会话归属、标题、OSS 对象键与软删标记;领域校验沉在 module-editor-agent。 + +use crate::*; +use module_editor_agent::{ + EditorAgentConversationCreateInput, EditorAgentConversationDeleteInput, + EditorAgentConversationGetInput, EditorAgentConversationListInput, + EditorAgentConversationSnapshot, EditorAgentConversationTouchInput, + validate_create_conversation, validate_delete_conversation, validate_touch_conversation, +}; + +#[spacetimedb::table( + accessor = editor_agent_conversation, + index(accessor = by_editor_agent_conversation_project_id, btree(columns = [project_id])), + index(accessor = by_editor_agent_conversation_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct EditorAgentConversation { + #[primary_key] + conversation_id: String, + project_id: String, + owner_user_id: String, + title: String, + messages_object_key: String, + deleted: bool, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAgentConversationProcedureResult { + pub ok: bool, + pub conversation: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAgentConversationListProcedureResult { + pub ok: bool, + pub conversations: Vec, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn create_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationCreateInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| create_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_editor_agent_conversations_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationListInput, +) -> EditorAgentConversationListProcedureResult { + match ctx.try_with_tx(|tx| list_editor_agent_conversations(tx, input.clone())) { + Ok(conversations) => EditorAgentConversationListProcedureResult { + ok: true, + conversations, + error_message: None, + }, + Err(message) => EditorAgentConversationListProcedureResult { + ok: false, + conversations: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationGetInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| get_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn touch_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationTouchInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| touch_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationDeleteInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| delete_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +fn create_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationCreateInput, +) -> Result { + validate_create_conversation(&input).map_err(|error| error.to_string())?; + let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + + if ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&input.conversation_id) + .is_some() + { + return Err("画布Agent会话已存在".to_string()); + } + crate::ensure_owned_editor_project( + ctx, + input.project_id.as_str(), + input.owner_user_id.as_str(), + )?; + + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: input.conversation_id.clone(), + project_id: input.project_id, + owner_user_id: input.owner_user_id, + title: input.title, + messages_object_key: input.messages_object_key, + deleted: false, + created_at: now, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, input.conversation_id.as_str()) +} + +fn list_editor_agent_conversations( + ctx: &ReducerContext, + input: EditorAgentConversationListInput, +) -> Result, String> { + if input.project_id.trim().is_empty() { + return Err("editor_agent_conversation.project_id 缺失".to_string()); + } + if input.owner_user_id.trim().is_empty() { + return Err("editor_agent_conversation.owner_user_id 缺失".to_string()); + } + let project_id = input.project_id.trim().to_string(); + let owner_user_id = input.owner_user_id.trim(); + + let mut conversations = ctx + .db + .editor_agent_conversation() + .by_editor_agent_conversation_project_id() + .filter(&project_id) + .filter(|conversation| conversation.owner_user_id == owner_user_id && !conversation.deleted) + .map(|conversation| conversation_row_to_snapshot(&conversation)) + .collect::>(); + + conversations.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| right.conversation_id.cmp(&left.conversation_id)) + }); + + Ok(conversations) +} + +fn get_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationGetInput, +) -> Result { + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + Ok(conversation_row_to_snapshot(&conversation)) +} + +fn touch_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationTouchInput, +) -> Result { + validate_touch_conversation(&input).map_err(|error| error.to_string())?; + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let title = match input.title { + Some(title) if !title.trim().is_empty() => title.trim().to_string(), + _ => conversation.title.clone(), + }; + + ctx.db + .editor_agent_conversation() + .conversation_id() + .delete(&conversation.conversation_id); + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id, + owner_user_id: conversation.owner_user_id, + title, + messages_object_key: conversation.messages_object_key, + deleted: conversation.deleted, + created_at: conversation.created_at, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, conversation.conversation_id.as_str()) +} + +fn delete_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationDeleteInput, +) -> Result { + validate_delete_conversation(&input).map_err(|error| error.to_string())?; + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .editor_agent_conversation() + .conversation_id() + .delete(&conversation.conversation_id); + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id, + owner_user_id: conversation.owner_user_id, + title: conversation.title, + messages_object_key: conversation.messages_object_key, + deleted: true, + created_at: conversation.created_at, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, conversation.conversation_id.as_str()) +} + +fn require_owned_editor_agent_conversation( + ctx: &ReducerContext, + conversation_id: &str, + owner_user_id: &str, +) -> Result { + let conversation = ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&conversation_id.to_string()) + .ok_or_else(|| "画布Agent会话不存在".to_string())?; + if conversation.owner_user_id != owner_user_id { + return Err("当前用户不是会话拥有者".to_string()); + } + if conversation.deleted { + return Err("画布Agent会话已删除".to_string()); + } + Ok(conversation) +} + +fn build_editor_agent_conversation_snapshot( + ctx: &ReducerContext, + conversation_id: &str, +) -> Result { + let conversation = ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&conversation_id.to_string()) + .ok_or_else(|| "画布Agent会话不存在".to_string())?; + Ok(conversation_row_to_snapshot(&conversation)) +} + +fn conversation_row_to_snapshot( + conversation: &EditorAgentConversation, +) -> EditorAgentConversationSnapshot { + EditorAgentConversationSnapshot { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + title: conversation.title.clone(), + messages_object_key: conversation.messages_object_key.clone(), + deleted: conversation.deleted, + created_at_micros: conversation.created_at.to_micros_since_unix_epoch(), + updated_at_micros: conversation.updated_at.to_micros_since_unix_epoch(), + } +} + +fn editor_agent_conversation_ok( + conversation: Option, +) -> EditorAgentConversationProcedureResult { + EditorAgentConversationProcedureResult { + ok: true, + conversation, + error_message: None, + } +} + +fn editor_agent_conversation_error(message: String) -> EditorAgentConversationProcedureResult { + EditorAgentConversationProcedureResult { + ok: false, + conversation: None, + error_message: Some(message), + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ba0fbb3cf..17fa92866 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1586,6 +1586,15 @@ fn canvas_snapshot_from_row(row: EditorCanvas) -> EditorCanvasSnapshot { } } +// 中文注释:供 editor_agent_storage 校验画布工程归属,仅返回是否可访问,不暴露表行。 +pub(crate) fn ensure_owned_editor_project( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + require_owned_project(ctx, project_id, owner_user_id).map(|_| ()) +} + fn require_owned_project( ctx: &ReducerContext, project_id: &str, @@ -2520,10 +2529,30 @@ mod tests { fn public_showcase_cursor_filters_strictly_after_desc_position() { let cursor = parse_public_showcase_cursor(Some("2000:resource-b")); - assert!(is_after_public_showcase_cursor(1999, "resource-z", cursor.as_ref())); - assert!(is_after_public_showcase_cursor(2000, "resource-a", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2000, "resource-b", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2000, "resource-c", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2001, "resource-a", cursor.as_ref())); + assert!(is_after_public_showcase_cursor( + 1999, + "resource-z", + cursor.as_ref() + )); + assert!(is_after_public_showcase_cursor( + 2000, + "resource-a", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2000, + "resource-b", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2000, + "resource-c", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2001, + "resource-a", + cursor.as_ref() + )); } } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a6fe765b5..6b9b5e7e5 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -30,6 +30,7 @@ mod bark_battle; mod big_fish; mod custom_world; mod domain_types; +mod editor_agent_storage; mod editor_project_storage; mod entry; mod external_api_key_storage; @@ -53,6 +54,7 @@ pub use bark_battle::*; pub use big_fish::*; pub use custom_world::*; pub use domain_types::*; +pub use editor_agent_storage::*; pub use editor_project_storage::*; pub use entry::*; pub use external_api_key_storage::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ff23fe2db..d8ad66721 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -232,6 +232,7 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, external_api_key, + editor_agent_conversation, editor_project, editor_canvas, editor_project_resource, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index e085c38ae..492130b3c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -3624,21 +3624,23 @@ fn insert_profile_code_operation( .by_profile_code_operation_kind_code() .filter((code_kind, code)) .count(); - ctx.db.profile_code_operation().insert(ProfileCodeOperation { - operation_id: format!( - "{}:{}:{}:{}:{}", - code_kind.trim(), - code.trim(), - action.trim(), - created_at_micros, - operation_index - ), - code_kind: code_kind.to_string(), - code: code.to_string(), - action: action.to_string(), - operator_user_id: operator_user_id.to_string(), - created_at, - }); + ctx.db + .profile_code_operation() + .insert(ProfileCodeOperation { + operation_id: format!( + "{}:{}:{}:{}:{}", + code_kind.trim(), + code.trim(), + action.trim(), + created_at_micros, + operation_index + ), + code_kind: code_kind.to_string(), + code: code.to_string(), + action: action.to_string(), + operator_user_id: operator_user_id.to_string(), + created_at, + }); } fn profile_code_operation_snapshots( diff --git a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx new file mode 100644 index 000000000..6bf4024d4 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx @@ -0,0 +1,281 @@ +/* @vitest-environment jsdom */ + +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView'; +import type { EditorAgentConversationClient } from './useEditorAgentConversation'; + +function createClient(): EditorAgentConversationClient { + return { + listConversations: vi.fn().mockResolvedValue([ + { + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + }, + ]), + createConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '新对话', + messages: [], + createdAt: '2026-07-03T00:01:00.000Z', + updatedAt: '2026-07-03T00:01:00.000Z', + }), + getConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [ + { + id: 'assistant-old', + role: 'assistant', + kind: 'chat', + text: '已经看到画布内容', + attachments: [], + generations: [], + status: 'completed', + createdAt: '2026-07-03T00:00:10.000Z', + }, + ], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }), + deleteConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + }), + streamMessage: vi + .fn() + .mockImplementation(async (_conversationId, _payload, options) => { + options.onEvent?.({ + event: 'stage', + data: { conversationId: _conversationId, stage: 'responding' }, + }); + options.onEvent?.({ + event: 'message_delta', + data: { + conversationId: _conversationId, + messageId: 'assistant-stream', + role: 'assistant', + kind: 'chat', + textDelta: '收到,我会参考这张图。', + }, + }); + options.onEvent?.({ + event: 'done', + data: { conversationId: _conversationId, title: null }, + }); + }), + }; +} + +describe('EditorAgentConversationPanelView', () => { + it('manages conversations, attachments and message sending inside the panel', async () => { + const client = createClient(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '新建对话' })); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + const attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + expect( + within(attachmentDialog).queryByRole('checkbox', { + name: '选择画布图片 未入库图层', + }), + ).toBeNull(); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 角色图层', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + expect(screen.getByText('角色图层')).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '参考附件做像素风' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy(); + }); + expect(client.streamMessage).toHaveBeenCalledWith( + 'conversation-2', + expect.objectContaining({ + text: '参考附件做像素风', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-1', + }), + ], + }), + expect.any(Object), + ); + }); + + it('sends selected attachments even when the text input is empty', async () => { + const client = createClient(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + const attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 角色图层', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(client.streamMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + text: '', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-1', + }), + ], + }), + expect.any(Object), + ); + }); + }); + + it('confirms conversation deletion from an independent dialog', async () => { + const client = createClient(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); + const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); + fireEvent.click( + within(confirmDialog).getByRole('button', { name: '确认删除' }), + ); + + await waitFor(() => { + expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1'); + }); + }); +}); diff --git a/src/components/image-editor/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversationPanelView.tsx new file mode 100644 index 000000000..48e288f13 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversationPanelView.tsx @@ -0,0 +1,721 @@ +import { + Bot, + Image as ImageIcon, + Loader2, + MessageCircle, + Paperclip, + Plus, + Send, + Square, + Trash2, + X, +} from 'lucide-react'; +import { type FormEvent, useMemo, useState } from 'react'; + +import { + EDITOR_AGENT_MAX_ATTACHMENTS, + type EditorAgentAttachmentRef, + type EditorAgentGenerationRecord, + type EditorAgentMessage, + type EditorAgentStage, +} from '../../../packages/shared/src/contracts/editorAgent'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; +import { + type EditorAgentConversationClient, + useEditorAgentConversation, +} from './useEditorAgentConversation'; + +type AttachmentPickerTab = 'canvas' | 'library'; + +type EditorAgentAttachmentOption = { + key: string; + sourceLabel: string; + attachment: EditorAgentAttachmentRef; +}; + +type EditorAgentConversationPanelViewProps = { + projectId?: string | null; + open: boolean; + onToggleOpen: () => void; + layers?: CanvasLayer[]; + assets?: EditorAsset[]; + onFocusResource?: (resourceId: string) => void; + client?: EditorAgentConversationClient; +}; + +function attachmentKey(attachment: EditorAgentAttachmentRef) { + return `${attachment.source}:${attachment.referenceId}`; +} + +function isImageLayer(layer: CanvasLayer) { + return ( + (layer.mediaType ?? 'image') === 'image' && + Boolean(layer.resourceId?.trim()) && + layer.src.trim() + ); +} + +function isImageAsset(asset: EditorAsset) { + return (asset.mediaType ?? 'image') === 'image' && asset.src.trim(); +} + +function createCanvasAttachmentOptions( + layers: CanvasLayer[] = [], +): EditorAgentAttachmentOption[] { + return layers.filter(isImageLayer).map((layer) => { + const attachment: EditorAgentAttachmentRef = { + source: 'canvas_resource', + referenceId: layer.resourceId || layer.id, + objectKey: layer.objectKey ?? null, + imageSrc: layer.src, + thumbnailSrc: layer.thumbnailSrc ?? null, + label: layer.title, + width: layer.width, + height: layer.height, + }; + return { + key: attachmentKey(attachment), + sourceLabel: '画布', + attachment, + }; + }); +} + +function createLibraryAttachmentOptions( + assets: EditorAsset[] = [], +): EditorAgentAttachmentOption[] { + return assets.filter(isImageAsset).map((asset) => { + const attachment: EditorAgentAttachmentRef = { + source: 'library_asset', + referenceId: asset.id, + objectKey: asset.objectKey ?? null, + imageSrc: asset.src, + thumbnailSrc: asset.thumbnailSrc ?? null, + label: asset.label, + width: asset.width, + height: asset.height, + }; + return { + key: attachmentKey(attachment), + sourceLabel: '素材库', + attachment, + }; + }); +} + +function stageLabel(stage: EditorAgentStage) { + if (stage === 'thinking') { + return '思考中'; + } + if (stage === 'responding') { + return '回复中'; + } + if (stage === 'generating') { + return '生成中'; + } + if (stage === 'completed') { + return '完成'; + } + if (stage === 'failed') { + return '失败'; + } + return ''; +} + +function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) { + if (toolName === 'edit_image') { + return '修改图片'; + } + if (toolName === 'generate_character') { + return '生成角色'; + } + if (toolName === 'generate_icon_spritesheet') { + return '生成图标'; + } + if (toolName === 'generate_ui_design') { + return '生成 UI'; + } + return '生成图片'; +} + +function messageRoleLabel(role: EditorAgentMessage['role']) { + return role === 'user' ? '你' : 'Agent'; +} + +function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: EditorAgentAttachmentRef; + onRemove?: () => void; +}) { + const label = attachment.label?.trim() || attachment.referenceId; + return ( + + + ); +} + +function GenerationRecordsView({ + generations, + onFocusResource, +}: { + generations: EditorAgentGenerationRecord[]; + onFocusResource?: (resourceId: string) => void; +}) { + if (!generations.length) { + return null; + } + return ( +
+ {generations.map((generation) => ( +
+
+ {generation.status === 'generating' ? ( +
+ {generation.error ? ( +
{generation.error}
+ ) : null} + {generation.images.length ? ( +
+ {generation.images.map((image, index) => ( + + ))} +
+ ) : null} +
+ ))} +
+ ); +} + +function MessageBubble({ + message, + onFocusResource, +}: { + message: EditorAgentMessage; + onFocusResource?: (resourceId: string) => void; +}) { + const isUser = message.role === 'user'; + return ( +
+
+
+ {message.text || (message.status === 'streaming' ? '...' : '')} +
+ {message.attachments.length ? ( +
+ {message.attachments.map((attachment) => ( + + ))} +
+ ) : null} + +
+
+ ); +} + +function AttachmentPickerModal({ + open, + tab, + canvasOptions, + libraryOptions, + selectedKeys, + attachmentError, + onTabChange, + onToggleKey, + onApply, + onClose, +}: { + open: boolean; + tab: AttachmentPickerTab; + canvasOptions: EditorAgentAttachmentOption[]; + libraryOptions: EditorAgentAttachmentOption[]; + selectedKeys: Set; + attachmentError: string | null; + onTabChange: (tab: AttachmentPickerTab) => void; + onToggleKey: (key: string) => void; + onApply: () => void; + onClose: () => void; +}) { + const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions; + + return ( + + + 取消 + + + 应用 + + + } + > +
+
+ + +
+ {attachmentError ? ( +
+ {attachmentError} +
+ ) : null} +
+ {visibleOptions.map((option) => { + const label = + option.attachment.label?.trim() || option.attachment.referenceId; + return ( + + ); + })} +
+
+
+ ); +} + +export function EditorAgentConversationPanelView({ + projectId, + open, + onToggleOpen, + layers = [], + assets = [], + onFocusResource, + client, +}: EditorAgentConversationPanelViewProps) { + const effectiveProjectId = open ? projectId : null; + const { + conversations, + activeConversationId, + messages, + stage, + isLoadingConversations, + isLoadingMessages, + isCreatingConversation, + isDeletingConversation, + isStreaming, + errorMessage, + createConversation, + selectConversation, + sendMessage, + stopCurrentTurn, + deleteActiveConversation, + } = useEditorAgentConversation({ projectId: effectiveProjectId, client }); + const [draftText, setDraftText] = useState(''); + const [attachments, setAttachments] = useState( + [], + ); + const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); + const [attachmentPickerTab, setAttachmentPickerTab] = + useState('canvas'); + const [attachmentError, setAttachmentError] = useState(null); + const [draftAttachmentKeys, setDraftAttachmentKeys] = useState>( + () => new Set(), + ); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + + const canvasAttachmentOptions = useMemo( + () => createCanvasAttachmentOptions(layers), + [layers], + ); + const libraryAttachmentOptions = useMemo( + () => createLibraryAttachmentOptions(assets), + [assets], + ); + const attachmentOptionsByKey = useMemo(() => { + const optionMap = new Map(); + [...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach( + (option) => optionMap.set(option.key, option), + ); + return optionMap; + }, [canvasAttachmentOptions, libraryAttachmentOptions]); + + const currentStageLabel = stageLabel(stage); + const hasProject = Boolean(projectId?.trim()); + + const openAttachmentPicker = () => { + setAttachmentError(null); + setDraftAttachmentKeys(new Set(attachments.map(attachmentKey))); + setAttachmentPickerOpen(true); + }; + + const toggleAttachmentKey = (key: string) => { + setDraftAttachmentKeys((currentKeys) => { + const nextKeys = new Set(currentKeys); + if (nextKeys.has(key)) { + nextKeys.delete(key); + } else { + nextKeys.add(key); + } + return nextKeys; + }); + }; + + const applyAttachmentSelection = () => { + const nextAttachments = Array.from(draftAttachmentKeys) + .map((key) => attachmentOptionsByKey.get(key)?.attachment) + .filter((attachment): attachment is EditorAgentAttachmentRef => + Boolean(attachment), + ); + if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) { + setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`); + return; + } + setAttachments(nextAttachments); + setAttachmentPickerOpen(false); + }; + + const submitMessage = (event: FormEvent) => { + event.preventDefault(); + if (isStreaming) { + stopCurrentTurn(); + return; + } + const text = draftText.trim(); + if (!text && !attachments.length) { + return; + } + setDraftText(''); + const nextAttachments = attachments; + setAttachments([]); + void sendMessage(text, nextAttachments).catch(() => { + setDraftText((currentText) => (currentText ? currentText : text)); + setAttachments((currentAttachments) => + currentAttachments.length ? currentAttachments : nextAttachments, + ); + }); + }; + + const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => { + const key = attachmentKey(targetAttachment); + setAttachments((currentAttachments) => + currentAttachments.filter( + (attachment) => attachmentKey(attachment) !== key, + ), + ); + }; + + if (!open) { + return ( + + ); + } + + return ( + <> +