合并 origin/master 最新变更
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m38s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Backend tests (pull_request) Successful in 4m31s
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m46s
Project CI / Native shell tests (pull_request) Successful in 6m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 3m38s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Backend tests (pull_request) Successful in 4m31s
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m46s
Project CI / Native shell tests (pull_request) Successful in 6m16s
合入主分支 73090437f,保留现役认证、发布、线程队列与 UI 工作流 协调桌面接线拆分与共享测试编译边界,迁移取消回合断言 通过配置、壳契约、编码及文档检查;Rust 检查受本地 Claude SDK 随包依赖缺失阻塞
This commit is contained in:
@@ -5,131 +5,42 @@ description: Guide use of Genarrative's hosted external editor/canvas MCP or asy
|
||||
|
||||
# Genarrative External Editor API
|
||||
|
||||
Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
|
||||
Use Genarrative to manage canvas projects and asset-library records, generate images, character animations, videos, and audio, and edit images. Discover the hosted integration at `https://www.genarrative.world/api/external/v1/agent-integration.json`.
|
||||
|
||||
Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
|
||||
Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable HTTP and a Bearer API Key. Read tool descriptions and input schemas from `tools/list`; read the documents below through `resources/read` when needed. The hosted service needs no local MCP server. For direct REST, use the live `/api/external/v1/openapi.json` contract (in this repository: `docs/openapi/genarrative-external-v1.openapi.json`).
|
||||
|
||||
Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
|
||||
## Working with the Service
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
|
||||
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
|
||||
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
|
||||
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
|
||||
5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
|
||||
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
|
||||
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
|
||||
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
|
||||
9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
|
||||
10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
|
||||
|
||||
## Essential Invariants
|
||||
|
||||
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
|
||||
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
|
||||
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
|
||||
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
||||
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
||||
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
||||
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
|
||||
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
||||
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
|
||||
- Select tools by the requested outcome. Use `find_canvas_projects` and `find_assets` to locate existing context; create projects or folders only when the task needs them. A folder need not have the same name as the project.
|
||||
- For generation, specify project, library, and `canvasCompletion` fields only as supported by the selected tool and needed for the requested destination. Do not duplicate records already created by generation.
|
||||
- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
|
||||
- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
|
||||
- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
|
||||
- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
|
||||
|
||||
## Documentation Navigation
|
||||
|
||||
Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
|
||||
Read the reference relevant to the current operation; exact input fields and enums come from the tool schema or OpenAPI.
|
||||
|
||||
- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
|
||||
- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
|
||||
- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
|
||||
- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
|
||||
| Need | Reference | MCP resource URI |
|
||||
| --- | --- | --- |
|
||||
| Choose tools and actions by user intent | [Capability routing](references/capability-routing.md) | `genarrative://external-editor/skill/references/capability-routing.md` |
|
||||
| Map tool calls to REST operations | [API operations](references/api-operations.md) | `genarrative://external-editor/skill/references/api-operations.md` |
|
||||
| Configure credentials, upload files, handle retries and deletion | [Authentication and safety](references/authentication-and-safety.md) | `genarrative://external-editor/skill/references/authentication-and-safety.md` |
|
||||
| Construct requests, poll results, place media, handle warnings | [Requests and outputs](references/requests-and-outputs.md) | `genarrative://external-editor/skill/references/requests-and-outputs.md` |
|
||||
|
||||
The hosted MCP exposes the same documents through:
|
||||
`genarrative://external-editor/usage` contains the short service instructions; `genarrative://external-editor/openapi` contains the REST contract. This entry is available at `genarrative://external-editor/skill`. Reading a resource does not install the downloadable Skill or its Python helper.
|
||||
|
||||
- `genarrative://external-editor/skill`
|
||||
- `genarrative://external-editor/skill/references/capability-routing.md`
|
||||
- `genarrative://external-editor/skill/references/api-operations.md`
|
||||
- `genarrative://external-editor/skill/references/authentication-and-safety.md`
|
||||
- `genarrative://external-editor/skill/references/requests-and-outputs.md`
|
||||
- `genarrative://external-editor/openapi`
|
||||
## Direct REST and Local Helpers
|
||||
|
||||
## Hosted Integration Discovery
|
||||
When remote MCP is unavailable or local-file orchestration needs a helper, the complete package is available at `GET /api/external/v1/skill.zip`; the raw entry is at `GET /api/external/v1/skill/SKILL.md`. Verify the archive SHA-256 against the integration manifest before installing. The archive includes this entry, four references, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Discovery and documentation downloads are public; MCP and business calls require authentication.
|
||||
|
||||
- Manifest: `GET /api/external/v1/agent-integration.json`.
|
||||
- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
|
||||
- OpenAPI: `GET /api/external/v1/openapi.json`.
|
||||
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
|
||||
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
|
||||
|
||||
The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
|
||||
|
||||
## Python Helper
|
||||
|
||||
Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "tnr_sk_..."
|
||||
}
|
||||
```
|
||||
|
||||
Set restrictive permissions where possible, then smoke-test without printing the key:
|
||||
The Python stdlib helper reads the private API Key file described in [authentication and safety](references/authentication-and-safety.md) and uses the production base URL. For a read-only smoke test:
|
||||
|
||||
```bash
|
||||
chmod 600 ~/.config/genarrative/external-editor-api.json
|
||||
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
|
||||
```
|
||||
|
||||
For a canvas-backed generation:
|
||||
Its `prepare_canvas_session` convenience method creates or reuses a project and a same-name folder. Use it only when that organization matches the task; it is not a prerequisite for MCP or REST calls. Convenience generation methods wait locally while the server uses short asynchronous submit/status requests. Use `submit_generation`, `get_generation`, and `wait_for_generation` for caller-controlled orchestration; see [requests and outputs](references/requests-and-outputs.md).
|
||||
|
||||
```python
|
||||
from genarrative_external_api import GenarrativeExternalClient
|
||||
|
||||
client = GenarrativeExternalClient()
|
||||
session = client.prepare_canvas_session("新画板")
|
||||
client.generate_image(
|
||||
"生成一张 16:9 幻想森林游戏背景",
|
||||
canvasSession=session,
|
||||
assetLabel="森林背景",
|
||||
aspectRatio="16:9",
|
||||
imageSize="1K",
|
||||
artSpec={
|
||||
"assetType": "background",
|
||||
"subject": "幻想森林主视觉",
|
||||
"style": "手绘游戏概念图",
|
||||
"palette": "翡翠绿与金色光斑",
|
||||
"composition": "横版,中心留出角色站位",
|
||||
"format": "16:9, 1K",
|
||||
"constraints": "无文字、无 UI 按钮",
|
||||
"references": [],
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
|
||||
|
||||
```python
|
||||
session = client.prepare_canvas_session("去背景画布")
|
||||
client.remove_background(
|
||||
"editor-upload/object.png",
|
||||
source_width=720,
|
||||
source_height=1280,
|
||||
canvasSession=session,
|
||||
assetLabel="去背景结果",
|
||||
)
|
||||
```
|
||||
|
||||
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
|
||||
|
||||
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
|
||||
|
||||
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not change the fixed production base URL in generated examples.
|
||||
- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
|
||||
- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
|
||||
- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
|
||||
- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
|
||||
Stay within `/api/external/v1` for this integration. Internal workers, queues, admin/profile APIs, and SpacetimeDB endpoints are outside this contract.
|
||||
|
||||
@@ -4,6 +4,45 @@ Use this reference after selecting a capability. Treat `GET /api/external/v1/ope
|
||||
|
||||
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
|
||||
|
||||
## MCP Tool to API Map
|
||||
|
||||
The hosted MCP offers the following tools. Choose the task tool when its action matches the request; the operation tool calls the indicated REST operation directly. Task tools with actions take `{ "action": "...", "input": { ... } }`; tools without actions take the operation fields directly. `idempotencyKey` is top-level in task tools. Operation tools use `body`, `pathParameters`, and `queryParameters` wrappers from their live input schemas. Read the live tool schema and OpenAPI for exact required fields.
|
||||
|
||||
| REST operation | Task tool (action) | Operation tool |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/external/v1/openapi.json` | — | `get_external_open_api_json` |
|
||||
| `GET /api/external/v1/editor/projects` | `find_canvas_projects` (`list`) | `list_editor_projects` |
|
||||
| `GET /api/external/v1/editor/projects/recent` | `find_canvas_projects` (`recent`) | `load_recent_editor_project` |
|
||||
| `GET /api/external/v1/editor/projects/{projectId}` | `find_canvas_projects` (`get`), `find_assets` (`get_project_resources`), `edit_canvas` (`get`) | `get_editor_project` |
|
||||
| `POST /api/external/v1/editor/projects` | `manage_canvas_projects` (`create`) | `create_editor_project` |
|
||||
| `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `manage_canvas_projects` (`rename`) | `rename_editor_project` |
|
||||
| `DELETE /api/external/v1/editor/projects/{projectId}` | `delete_resources` (`delete_project`) | `delete_editor_project` |
|
||||
| `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `edit_canvas` (`save_layout`) | `save_editor_project_canvas` |
|
||||
| `POST /api/external/v1/editor/projects/{projectId}/resources` | `edit_canvas` (`register_resource`) | `create_editor_project_resource` |
|
||||
| `POST /api/external/v1/assets/direct-upload-tickets` | `prepare_asset_upload` (`create_upload_ticket`) | `create_external_direct_upload_ticket` |
|
||||
| `POST /api/external/v1/assets/objects/confirm` | `prepare_asset_upload` (`confirm_upload`) | `confirm_external_asset_object` |
|
||||
| `GET /api/external/v1/assets/read-url` | `find_assets` (`get_download_url`) | `get_external_asset_read_url` |
|
||||
| `GET /api/external/v1/editor/assets/library` | `find_assets` (`list_library`) | `get_editor_asset_library` |
|
||||
| `POST /api/external/v1/editor/assets/folders` | `organize_asset_library` (`create_folder`) | `create_editor_asset_folder` |
|
||||
| `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `organize_asset_library` (`update_folder`) | `update_editor_asset_folder` |
|
||||
| `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `delete_resources` (`delete_folder`) | `delete_editor_asset_folder` |
|
||||
| `POST /api/external/v1/editor/assets` | `organize_asset_library` (`create_asset`) | `create_editor_asset` |
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
| `POST /api/external/v1/editor/ui-designs/assets/extractions` | `extract_ui_assets` | `extract_external_editor_ui_design_assets` |
|
||||
| `POST /api/external/v1/editor/character-animations/generations` | `generate_character_animation` | `generate_external_editor_character_animation` |
|
||||
| `POST /api/external/v1/editor/videos/generations` | `generate_video` | `generate_external_editor_video` |
|
||||
| `POST /api/external/v1/editor/audios/sound-effects/generations` | `generate_audio` (`sound_effect`) | `generate_external_editor_sound_effect` |
|
||||
| `POST /api/external/v1/editor/audios/background-music/generations` | `generate_audio` (`background_music`) | `generate_external_editor_background_music` |
|
||||
| `GET /api/external/v1/generations/{operationId}` | `check_generation` | `get_external_editor_generation_job` |
|
||||
|
||||
The public `agent-integration.json`, `skill/SKILL.md`, and `skill.zip` routes and the MCP transport route are HTTP entry points, not callable MCP tools. The hosted resource URIs remain `genarrative://external-editor/skill`, `genarrative://external-editor/skill/references/capability-routing.md`, `genarrative://external-editor/skill/references/api-operations.md`, `genarrative://external-editor/skill/references/authentication-and-safety.md`, `genarrative://external-editor/skill/references/requests-and-outputs.md`, and `genarrative://external-editor/openapi`.
|
||||
|
||||
## Project and Canvas Operations
|
||||
|
||||
| Operation | Method and path | Minimum input |
|
||||
@@ -23,7 +62,7 @@ Project listing supports two views:
|
||||
|
||||
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
|
||||
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
|
||||
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
|
||||
- Hosted MCP `list_editor_projects` and `find_canvas_projects` (`list`) use `summary`; call `get_editor_project` or `find_canvas_projects` (`get`) after selecting a `projectId` when complete authoritative state is required.
|
||||
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
|
||||
|
||||
## Asset and Upload Operations
|
||||
@@ -42,6 +81,7 @@ Project listing supports two views:
|
||||
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
|
||||
|
||||
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
|
||||
`prepare_asset_upload` handles the ticket and confirmation as separate calls; it does not send local bytes to OSS or automatically register a project resource, asset record, or canvas layer. `manage_canvas_projects` (`create`) likewise does not create a same-name asset folder. Register or organize records only when the task needs them.
|
||||
|
||||
## Generation Operations
|
||||
|
||||
@@ -50,16 +90,17 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
||||
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -69,10 +110,10 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
|
||||
|
||||
## Canvas and Library Field Rules
|
||||
|
||||
- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
|
||||
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
|
||||
- Pass `projectId` and `canvasCompletion` when the task calls for generated output in a canvas.
|
||||
- Pass `assetFolderId` plus the relevant label field when the task calls for a library record. Neither destination requires the other, and their names need not match.
|
||||
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
|
||||
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
|
||||
- Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume returned artifacts and persisted identities; never create a duplicate first-frame resource or asset.
|
||||
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
|
||||
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
|
||||
- Reload project/library state after completion when full current state is required.
|
||||
@@ -101,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
+15
-7
@@ -21,7 +21,7 @@ Authorization: Bearer <tnr_sk_...>
|
||||
|
||||
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
|
||||
|
||||
Store it outside repositories in the user's private JSON file:
|
||||
For hosted MCP, configure the Bearer token in the client's private connection settings. For the bundled REST helper, store it outside repositories in the user's private JSON file:
|
||||
|
||||
```text
|
||||
~/.config/genarrative/external-editor-api.json
|
||||
@@ -61,7 +61,7 @@ The OpenAPI document, integration manifest, raw Skill entry, and Skill archive a
|
||||
|
||||
For each logical generation:
|
||||
|
||||
1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
|
||||
1. Create one printable ASCII key of 1-128 bytes. MCP takes `idempotencyKey` at the top level of the tool arguments (outside `input`); REST takes the `Idempotency-Key` header.
|
||||
2. Persist the key with the exact request body and returned `operationId`.
|
||||
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
|
||||
4. Never allocate a new key merely because the outcome is unknown.
|
||||
@@ -69,15 +69,21 @@ For each logical generation:
|
||||
|
||||
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
|
||||
|
||||
Keep the API operation as well as the request and key unchanged across a submission retry. A different tool name does not create a separate idempotency namespace. When an operation ID is known, query `check_generation` directly. A rejected submission is not permission to switch keys and generate again.
|
||||
|
||||
`manage_canvas_projects/create`, `edit_canvas/register_resource`, and `organize_asset_library/create_folder` accept optional top-level `idempotencyKey`. Other non-generation actions do not accept it; in particular, `create_asset` is not an idempotent generation submission.
|
||||
|
||||
## Local Reference Upload
|
||||
|
||||
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
|
||||
|
||||
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
|
||||
2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
|
||||
2. Call `prepare_asset_upload` with `action=create_upload_ticket` and the ticket body in `input` (REST: `POST /api/external/v1/assets/direct-upload-tickets`).
|
||||
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
|
||||
4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
|
||||
5. Pass the confirmed stable `objectKey` to the selected editor operation.
|
||||
4. Call `prepare_asset_upload` with `action=confirm_upload` and the confirmation body in `input` (REST: `POST /api/external/v1/assets/objects/confirm`).
|
||||
5. Pass the confirmed stable `objectKey` where the selected tool permits it. For operations requiring a registered source, register a project resource or asset first and use its ID.
|
||||
|
||||
The MCP tool does not transfer file bytes and does not accept a local path or base64. The client needs an HTTP/file-transfer capability for step 3. Object ownership comes from the API Key; do not supply `ownerUserId`. Confirmation alone creates neither a library record nor a canvas layer.
|
||||
|
||||
For a private reference image, use a ticket body shaped like:
|
||||
|
||||
@@ -107,7 +113,7 @@ Confirm with the actual file metadata:
|
||||
|
||||
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
|
||||
|
||||
For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
|
||||
For character animation, use source identity and dimensions from the actual selected resource; do not invent an existing canvas layer. The bundled local-file helper can maintain its own stable source label, which is not evidence of a persisted canvas layer.
|
||||
|
||||
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
|
||||
|
||||
@@ -126,7 +132,7 @@ Do not print the complete confirmation response if it may contain temporary acce
|
||||
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
|
||||
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
|
||||
- Never use a Blob URL outside the browser process that created it.
|
||||
- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
|
||||
- Use `find_assets` with `action=get_download_url` (REST: `GET /api/external/v1/assets/read-url`) to obtain a short-lived `signedUrl` for display/download. This returns a URL; the client still performs any download.
|
||||
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
|
||||
|
||||
## Logging and Command Safety
|
||||
@@ -139,6 +145,8 @@ Do not print the complete confirmation response if it may contain temporary acce
|
||||
|
||||
## Scope and Retry Guardrails
|
||||
|
||||
- Generation spends account credits. Respect the user's authorized task and scope; do not restart generation merely because a requested derivative is missing.
|
||||
- For `delete_resources`, identify the precise IDs and obtain authorization for the actual deletion scope. Project deletion cascades to its default canvas and project-resource metadata. Folder deletion moves its assets to the default folder; the default folder cannot be deleted. Deleting a folder or asset record does not delete the underlying OSS file.
|
||||
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
|
||||
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
|
||||
- Do not bypass upload confirmation or invent an object key.
|
||||
|
||||
@@ -10,21 +10,17 @@ Use this reference to translate user intent into a hosted MCP tool or its corres
|
||||
- Public contract: `GET /api/external/v1/openapi.json`.
|
||||
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
|
||||
|
||||
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
|
||||
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. Choose a task-oriented tool below for ordinary requests, or the corresponding operation tool in [API Operations](api-operations.md) when the request needs direct control of one REST call. All 44 tools remain available. Discover the live tool schema before calling it; OpenAPI remains the field-level authority. The public discovery and Skill download routes are listed below, but are not MCP tools.
|
||||
|
||||
## Canvas Session
|
||||
## Project and Asset Destination
|
||||
|
||||
Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
|
||||
Use `find_canvas_projects` (`action=list`, `recent`, or `get`) to locate an existing canvas when the request involves one. Use `manage_canvas_projects` (`create` or `rename`) only when the user needs a project created or renamed. A new project does not create an asset folder automatically. Use `find_assets` and `organize_asset_library` when the task involves library records or folders. A project and folder may have different names, and either may be unnecessary for a standalone generation.
|
||||
|
||||
1. List or create a project. When creating one, use the canvas name as `title`.
|
||||
2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
|
||||
3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
|
||||
|
||||
Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame.
|
||||
For generation, pass `projectId` with `canvasCompletion` when the result should enter a canvas, and `assetFolderId` with the endpoint's label field when it should enter the library. Use both only when the task requires both destinations. Character animation returns its final resource and asset directly when those destinations are requested; do not duplicate its first frame.
|
||||
|
||||
## Art Spec Routing
|
||||
|
||||
Before art generation, normalize the user's request into:
|
||||
For a series of related art requests, an optional reusable spec can carry the shared requirements:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -43,48 +39,48 @@ Infer what is already clear and ask only for missing fields that block the selec
|
||||
|
||||
## Intent Map
|
||||
|
||||
| User intent | MCP/REST capability |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
|
||||
| Redraw, retouch, or replace an existing image | Image edit |
|
||||
| Remove the background from an existing image | Background removal |
|
||||
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
|
||||
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
|
||||
| Extract marked assets from an existing UI design | UI design asset extraction |
|
||||
| Animate a character into frames | Character animation generation |
|
||||
| Generate video | Video generation |
|
||||
| Generate a sound effect | Sound-effect generation |
|
||||
| Generate background music/BGM | Background-music generation |
|
||||
| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm |
|
||||
| Save viewport/layers | Canvas save |
|
||||
| Create, load, rename, or delete a canvas | Project operations |
|
||||
| Organize folders and asset records | Asset-library operations |
|
||||
| Obtain temporary access to private media | Signed read URL |
|
||||
| Check generation progress or retrieve its result | Generation query |
|
||||
| User intent | MCP tool and action |
|
||||
| --- | --- |
|
||||
| Find, open, create, or rename a canvas project | `find_canvas_projects` (`list`, `recent`, `get`); `manage_canvas_projects` (`create`, `rename`) |
|
||||
| Read project resources or library records | `find_assets` (`get_project_resources`, `list_library`) |
|
||||
| Create or change folders and asset records | `organize_asset_library` (`create_folder`, `update_folder`, `create_asset`, `update_asset`) |
|
||||
| Upload a local image/audio/video asset | `prepare_asset_upload` (`create_upload_ticket`), client-side OSS form upload, then `prepare_asset_upload` (`confirm_upload`) |
|
||||
| Register existing media in a project, read a canvas, or save its full layout | `edit_canvas` (`register_resource`, `get`, `save_layout`) |
|
||||
| Generate a background, character, spec, UI mockup, or publication image | `generate_image` |
|
||||
| Retouch an existing image, make a reference variation, or remove its background | `modify_image` (`edit`, `variation`, `remove_background`) |
|
||||
| Build a transparent icon/game atlas from a registered visual spec | `generate_icon_spritesheet` |
|
||||
| Generate marked assets from an existing UI design | `extract_ui_assets` |
|
||||
| Animate a character into frames | `generate_character_animation` |
|
||||
| Generate video | `generate_video` |
|
||||
| Generate a sound effect or background music | `generate_audio` (`sound_effect`, `background_music`) |
|
||||
| Check generation progress or retrieve its result | `check_generation` |
|
||||
| Obtain temporary access to private media | `find_assets` (`get_download_url`) |
|
||||
| Delete an exact project, folder, or asset record | `delete_resources` (`delete_project`, `delete_folder`, `delete_asset`) |
|
||||
|
||||
For tools with actions, send `{ "action": "...", "input": { ... } }`; place `idempotencyKey` at the top level when supported or required. Tools without actions accept operation fields directly, with `idempotencyKey` at the top level for generation. The direct operation tools use `body`, `pathParameters`, and `queryParameters` wrappers as shown by their live schemas.
|
||||
|
||||
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
|
||||
|
||||
## Route-Specific Decisions
|
||||
|
||||
- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
|
||||
- Use `modify_image` `edit` when the requested output modifies a registered source image. Use `variation` when reference images should guide a new `quick-edit` image; it is image generation with fixed `kind="quick-edit"`. Use `remove_background` for a static source image. With `projectId`, `targetLayerId` may replace a matching existing layer when no explicit `canvasCompletion` is supplied.
|
||||
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
|
||||
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
|
||||
- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
|
||||
- Character animation requires a real `sourceLayerId`, source image, and dimensions from an existing resource. A local-only file must first be uploaded and registered where needed; do not invent a layer ID.
|
||||
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
|
||||
- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
|
||||
- `prepare_asset_upload` obtains a ticket and confirms an uploaded object; it does not transfer the file or register a project resource, asset record, or canvas layer. Use `edit_canvas` `register_resource` or `organize_asset_library` `create_asset` only when the task needs those records.
|
||||
- Use temporary signed URLs only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation as each operation permits.
|
||||
|
||||
## AI Game Creator Canonical Visual DAG
|
||||
## Example: Reusable Icon Assets
|
||||
|
||||
Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
|
||||
When the task needs a visual spec and a reusable icon atlas:
|
||||
|
||||
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
|
||||
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
||||
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
|
||||
1. Reuse an existing registered `icon-spec`, or generate the requested spec using `generate_image` with `kind=spec` and register it as `assetKind=icon-spec` if necessary.
|
||||
2. Call `generate_icon_spritesheet` with that registered ID as `referenceId`, concrete `iconDescriptions`, and explicit `sliceMode`. Choose `grid` only for requested equal cells or fixed slots and provide those `gridX`/`gridY` values; otherwise use `connected-components`, optionally with `sliceCount`.
|
||||
3. Query `check_generation` and inspect the full sheet and actual slices. Preserve warnings; a usable full sheet does not imply that individual slices exist. Use returned slice identities and dimensions rather than guessing crop coordinates.
|
||||
|
||||
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
||||
|
||||
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
|
||||
An ordinary UI mockup or uploaded image is not automatically an `icon-spec`. For extracting marked components from a UI design, use `extract_ui_assets`, which includes generation and does not promise pixel-exact cropping.
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
|
||||
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. The hosted MCP also exposes the Skill and OpenAPI resources at `genarrative://external-editor/skill`, its four `skill/references/*.md` URIs, and `genarrative://external-editor/openapi`; keep those URI names unchanged.
|
||||
|
||||
@@ -4,15 +4,46 @@ Use this reference to build generation payloads, carry canvas/library context, p
|
||||
|
||||
## Contents
|
||||
|
||||
- [MCP Argument Shapes](#mcp-argument-shapes)
|
||||
- [Asynchronous Submission](#asynchronous-submission)
|
||||
- [Polling State Machine](#polling-state-machine)
|
||||
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
|
||||
- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
|
||||
- [Art Spec and Image Request](#art-spec-and-image-request)
|
||||
- [Local Reference Requests](#local-reference-requests)
|
||||
- [Compact Completed Result](#compact-completed-result)
|
||||
- [Warning Semantics](#warning-semantics)
|
||||
- [Output Handling Checklist](#output-handling-checklist)
|
||||
|
||||
## MCP Argument Shapes
|
||||
|
||||
Pass these objects as the `arguments` of the named tool in `tools/call`. They are not REST request envelopes.
|
||||
|
||||
Single-function tools take business fields directly. For example, `generate_image`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "一张横版幻想森林背景,无文字",
|
||||
"aspectRatio": "16:9",
|
||||
"imageSize": "1K",
|
||||
"idempotencyKey": "forest-image-001"
|
||||
}
|
||||
```
|
||||
|
||||
Multi-function tools take `action` and `input`. A generation key stays outside `input`. For example, `generate_audio`:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sound_effect",
|
||||
"input": {"prompt": "轻柔的游戏菜单确认音", "duration": 1},
|
||||
"idempotencyKey": "menu-sound-001"
|
||||
}
|
||||
```
|
||||
|
||||
Keys above identify distinct example requests; create and persist your own key for each new logical generation. An action with no business fields still requires `input: {}`, such as `find_canvas_projects` with `action=list`. Use only fields belonging to the selected action; do not combine branches. REST examples below use the business body directly and put the key in the HTTP header instead.
|
||||
|
||||
MCP returns business data in `structuredContent`, without the REST `data` envelope. Check `isError` before using it; an HTTP-successful MCP exchange can still carry a tool error. Generation acceptance contains an `operationId`, not the final media.
|
||||
|
||||
## Asynchronous Submission
|
||||
|
||||
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
|
||||
@@ -50,7 +81,7 @@ Persist the key, exact request body, and `operationId`. If submission outcome is
|
||||
|
||||
## Polling State Machine
|
||||
|
||||
Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
|
||||
With MCP, call `check_generation` with `{"operationId":"<returned operationId>"}`. Each call queries once and does not wait for completion. With REST, poll `statusUrl` or `GET /api/external/v1/generations/{operationId}`. Query no faster than `pollAfterMs`:
|
||||
|
||||
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
|
||||
- `completed`: consume the compact `result` and all warning fields, then stop polling.
|
||||
@@ -75,10 +106,10 @@ Background removal uses the same submission and polling state machine. `sourceIm
|
||||
|
||||
## Canvas and Asset-Library Completion
|
||||
|
||||
For endpoints that support these fields, include:
|
||||
Choose destinations according to the task. Locate an existing project with `find_canvas_projects` and inspect folders with `find_assets/list_library`; create missing destinations with `manage_canvas_projects/create` and `organize_asset_library/create_folder` only when needed. Project creation does not create a folder. For endpoints that support the requested destinations, include:
|
||||
|
||||
- `projectId`: target canvas project.
|
||||
- `assetFolderId`: folder whose label matches the canvas name.
|
||||
- `assetFolderId`: target asset-library folder; its name need not match the project.
|
||||
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
|
||||
- `canvasCompletion`: backend canvas placement instructions.
|
||||
|
||||
@@ -102,15 +133,24 @@ A minimal `canvasCompletion` is:
|
||||
|
||||
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
|
||||
|
||||
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
|
||||
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
|
||||
|
||||
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
|
||||
|
||||
## Saving Existing Canvas Layout
|
||||
|
||||
1. Call `edit_canvas` with `action=get` and `input.projectId` to read the latest project and canvas revision.
|
||||
2. Build the intended complete `viewport` and `layers`, preserving unrelated layers. `save_layout` replaces the layout; it is not a one-layer patch.
|
||||
3. Call `edit_canvas` with `action=save_layout` and `input` containing `projectId`, the read `expectedRevision`, and the complete `viewport` and `layers`.
|
||||
4. On a revision conflict, reread and reconcile with the current layout before retrying. Do not blindly resend stale layers with a refreshed revision.
|
||||
|
||||
`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -177,11 +217,11 @@ Image edit/redraw has a stricter main-source identity rule. After upload confirm
|
||||
|
||||
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
|
||||
|
||||
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
|
||||
For character animation, use the selected source identity and actual dimensions. The following is a business-body example; `generate_character_animation` also requires a top-level `idempotencyKey`:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceLayerId": "external-reference-hero",
|
||||
"sourceLayerId": "<selected source layer ID>",
|
||||
"sourceImageSrc": "<confirmed objectKey>",
|
||||
"sourceWidth": 720,
|
||||
"sourceHeight": 1280,
|
||||
@@ -209,7 +249,7 @@ The completed `result` may contain stable artifact fields such as:
|
||||
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
|
||||
- `warning` and `sliceWarning` structures.
|
||||
|
||||
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
|
||||
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `find_assets/get_download_url` for temporary access to a stable `objectKey` (REST: `/assets/read-url`). Reading a record or obtaining a URL does not itself inspect or download the media.
|
||||
|
||||
## Warning Semantics
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ env:
|
||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
||||
jobs:
|
||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;
|
||||
# build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。
|
||||
ai-game-creator-shell-rust-lane-1:
|
||||
name: AI game creator shell Rust lane 1/2
|
||||
runs-on: genarrative-ci
|
||||
@@ -62,6 +62,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -118,6 +121,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -154,7 +160,7 @@ jobs:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
ai-game-creator-shell-rust-smoke:
|
||||
name: AI game creator shell Rust smoke
|
||||
@@ -169,6 +175,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -56,7 +56,14 @@ temp*build*/
|
||||
/plugins/agc-unity-editor/dotnet/native-build/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
@@ -65,6 +72,7 @@ temp*build*/
|
||||
/apps/preview-deployer-web/node_modules/
|
||||
/server-rs/.spacetimedb/
|
||||
/server-rs/.data/
|
||||
**/server-rs/.data/
|
||||
/public/generated-animations
|
||||
/public/generated-character-drafts
|
||||
/public/generated-characters
|
||||
|
||||
+46
-1
@@ -48,6 +48,23 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
|
||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||
|
||||
**UI 设计文档**:
|
||||
AGC 本地项目里 `kind=ui-design-doc`、`mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
|
||||
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
|
||||
|
||||
**设计图**:
|
||||
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
|
||||
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
|
||||
|
||||
**UI 工作流步骤**:
|
||||
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
|
||||
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
|
||||
|
||||
**工作流检查点日志**:
|
||||
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
|
||||
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
|
||||
|
||||
|
||||
## Language
|
||||
|
||||
### Puzzle Clear
|
||||
@@ -175,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**DirectProject 专属聊天模块**:
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||
|
||||
**项目工作台布局**:
|
||||
@@ -190,6 +207,34 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**逻辑回合**:
|
||||
Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
|
||||
_Avoid_: Codex 原生回合、原生日志、进程生命周期
|
||||
|
||||
**待发消息队列**:
|
||||
Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。
|
||||
_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录
|
||||
|
||||
**待发消息**:
|
||||
已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。
|
||||
_Avoid_: 回合、在途回合、草稿
|
||||
|
||||
**入队**:
|
||||
把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。
|
||||
_Avoid_: 发送成功、已经开跑、回合成立
|
||||
|
||||
**入队失败**:
|
||||
入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。
|
||||
_Avoid_: 回合失败、执行失败、失败事件
|
||||
|
||||
**放行**:
|
||||
Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。
|
||||
_Avoid_: 前端放行、定时轮询、放行失败
|
||||
|
||||
**在途回合**:
|
||||
界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。
|
||||
_Avoid_: 运行中回合、乐观锁、前端发送队列
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
@@ -1075,11 +1075,14 @@ export interface AdminRechargeRefundActionResponse {
|
||||
export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
alias: string;
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
agentMode: AdminAgcAgentMode;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AdminPanel } from '@genarrative/shared/components';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -210,9 +211,9 @@ export function AdminApp() {
|
||||
onRouteChange={handleRouteChange}
|
||||
>
|
||||
{activeRouteId === null ? (
|
||||
<section className="admin-panel admin-zero-permission-state">
|
||||
<AdminPanel className="admin-zero-permission-state">
|
||||
<h2>暂无访问权限</h2>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
) : null}
|
||||
{activeRouteId === 'dashboard' ? (
|
||||
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AdminButton } from '@genarrative/shared/components';
|
||||
import {
|
||||
Activity,
|
||||
BadgeDollarSign,
|
||||
@@ -108,15 +109,15 @@ export function AdminShell({
|
||||
<span>{admin.displayName || admin.username}</span>
|
||||
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
|
||||
</div>
|
||||
<button
|
||||
className="admin-icon-button"
|
||||
<AdminButton
|
||||
variant="icon"
|
||||
title="退出登录"
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
<span>退出</span>
|
||||
</button>
|
||||
</AdminButton>
|
||||
</header>
|
||||
|
||||
<main className="admin-content">{children}</main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pause, Play, X } from 'lucide-react';
|
||||
import { AdminDialog } from '@genarrative/shared/components';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -85,35 +86,22 @@ export function AdminEditorAssetPreviewDialog({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
aria-label="素材预览"
|
||||
className="admin-detail-panel admin-asset-query-preview-dialog"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>{entry.label || entry.assetId}</h3>
|
||||
<span>{entry.assetId}</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭素材预览"
|
||||
className="admin-ghost-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<AdminDialog
|
||||
ariaLabel="素材预览"
|
||||
closeLabel="关闭素材预览"
|
||||
description={entry.assetId}
|
||||
panelClassName="admin-asset-query-preview-dialog"
|
||||
title={entry.label || entry.assetId}
|
||||
onClose={onClose}
|
||||
>
|
||||
{sourceEntry ? (
|
||||
<div className="admin-asset-query-source-preview">
|
||||
<h4>原图</h4>
|
||||
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
|
||||
</div>
|
||||
{sourceEntry ? (
|
||||
<div className="admin-asset-query-source-preview">
|
||||
<h4>原图</h4>
|
||||
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
|
||||
</div>
|
||||
) : null}
|
||||
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { AdminButton } from '@genarrative/shared/components';
|
||||
import { UserRoundSearch } from 'lucide-react';
|
||||
import { MouseEvent, useRef, useState } from 'react';
|
||||
|
||||
@@ -42,16 +43,17 @@ export function AdminUserReferenceButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
ref={triggerRef}
|
||||
aria-label="查看用户信息"
|
||||
className="admin-ghost-button admin-user-reference-button"
|
||||
title="查看用户信息"
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="admin-user-reference-button"
|
||||
>
|
||||
<UserRoundSearch size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</AdminButton>
|
||||
{open ? (
|
||||
<AdminUserDetailDialog
|
||||
token={token}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
AdminButton,
|
||||
AdminDialog,
|
||||
AdminInfoList,
|
||||
} from '@genarrative/shared/components';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface AdminWriteConfirmOptions {
|
||||
@@ -44,63 +49,49 @@ export function useAdminWriteConfirm() {
|
||||
}
|
||||
|
||||
cancelButtonRef.current?.focus();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeConfirm(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeConfirm, pendingConfirm]);
|
||||
}, [pendingConfirm]);
|
||||
|
||||
const confirmDialog = pendingConfirm ? (
|
||||
<div
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-write-confirm-title"
|
||||
className="admin-confirm-backdrop"
|
||||
role="dialog"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeConfirm(false);
|
||||
}
|
||||
}}
|
||||
<AdminDialog
|
||||
count={pendingConfirm.action}
|
||||
initialFocusRef={cancelButtonRef}
|
||||
panelClassName="admin-confirm-panel"
|
||||
showClose={false}
|
||||
title="确认操作"
|
||||
titleProps={{ id: 'admin-write-confirm-title' }}
|
||||
onClose={() => closeConfirm(false)}
|
||||
>
|
||||
<section className="admin-confirm-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3 id="admin-write-confirm-title">确认操作</h3>
|
||||
<span>{pendingConfirm.action}</span>
|
||||
</div>
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>操作</dt>
|
||||
<dd>{pendingConfirm.action}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>对象</dt>
|
||||
<dd>{pendingConfirm.target}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="admin-confirm-warning">该操作会立即影响线上数据</div>
|
||||
<div className="admin-confirm-actions">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
ref={cancelButtonRef}
|
||||
type="button"
|
||||
onClick={() => closeConfirm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="admin-danger-button"
|
||||
type="button"
|
||||
onClick={() => closeConfirm(true)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<AdminInfoList
|
||||
items={[
|
||||
{
|
||||
label: <>操作</>,
|
||||
value: <>{pendingConfirm.action}</>,
|
||||
},
|
||||
{
|
||||
label: <>对象</>,
|
||||
value: <>{pendingConfirm.target}</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="admin-confirm-warning">该操作会立即影响线上数据</div>
|
||||
<div className="admin-confirm-actions">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
ref={cancelButtonRef}
|
||||
type="button"
|
||||
onClick={() => closeConfirm(false)}
|
||||
>
|
||||
取消
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="danger"
|
||||
type="button"
|
||||
onClick={() => closeConfirm(true)}
|
||||
>
|
||||
确认
|
||||
</AdminButton>
|
||||
</div>
|
||||
</AdminDialog>
|
||||
) : null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
AdminActionRow,
|
||||
AdminAlert,
|
||||
AdminButton,
|
||||
AdminField,
|
||||
AdminFormRow,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPanel,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Plus, RefreshCcw, Save } from 'lucide-react';
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
@@ -159,117 +170,111 @@ export function AdminAccountsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>账号管理</h2>
|
||||
<p>后台成员</p>
|
||||
</div>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
<span>添加账号</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshAccounts}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
title="账号管理"
|
||||
description="后台成员"
|
||||
actions={
|
||||
<AdminActionRow>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
>
|
||||
<Plus size={17} aria-hidden="true" />
|
||||
<span>添加账号</span>
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshAccounts}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
}
|
||||
/>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
||||
|
||||
<div className="admin-accounts-layout">
|
||||
<section className="admin-panel admin-account-list">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>后台账号</h3>
|
||||
<span>{accounts.length}</span>
|
||||
</div>
|
||||
{accounts.length ? (
|
||||
<div className="admin-account-list-items">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
data-active={account.accountId === selectedAccountId}
|
||||
disabled={account.accountRole === 'owner'}
|
||||
key={account.accountId}
|
||||
title={
|
||||
account.accountRole === 'owner' ? 'owner' : account.username
|
||||
<AdminListPanel
|
||||
busy={isLoading}
|
||||
busyText="加载中"
|
||||
className="admin-account-list"
|
||||
count={accounts.length}
|
||||
emptyText="暂无成员账号"
|
||||
rows={accounts}
|
||||
title="后台账号"
|
||||
>
|
||||
<div className="admin-account-list-items">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
data-active={account.accountId === selectedAccountId}
|
||||
disabled={account.accountRole === 'owner'}
|
||||
key={account.accountId}
|
||||
title={
|
||||
account.accountRole === 'owner' ? 'owner' : account.username
|
||||
}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (account.accountRole === 'member') {
|
||||
fillForm(account);
|
||||
}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (account.accountRole === 'member') {
|
||||
fillForm(account);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{account.displayName || account.username}</strong>
|
||||
<small>{account.username}</small>
|
||||
</span>
|
||||
<small>
|
||||
{account.accountRole === 'owner'
|
||||
? 'owner'
|
||||
: account.enabled
|
||||
? '启用'
|
||||
: '停用'}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '暂无成员账号'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{account.displayName || account.username}</strong>
|
||||
<small>{account.username}</small>
|
||||
</span>
|
||||
<small>
|
||||
{account.accountRole === 'owner'
|
||||
? 'owner'
|
||||
: account.enabled
|
||||
? '启用'
|
||||
: '停用'}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AdminListPanel>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>用户名</span>
|
||||
<AdminPanel
|
||||
as="form"
|
||||
onSubmit={handleSave}
|
||||
title={selectedAccountId ? '编辑账号' : '添加账号'}
|
||||
extra={
|
||||
<>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AdminFormRow>
|
||||
<AdminField label="用户名">
|
||||
<input
|
||||
disabled={Boolean(selectedAccountId)}
|
||||
autoComplete="off"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>显示名称</span>
|
||||
</AdminField>
|
||||
<AdminField label="显示名称">
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>{selectedAccountId ? '新密码' : '密码'}</span>
|
||||
</AdminField>
|
||||
</AdminFormRow>
|
||||
<AdminField label={selectedAccountId ? '新密码' : '密码'}>
|
||||
<input
|
||||
autoComplete="new-password"
|
||||
placeholder={selectedAccountId ? '不修改' : ''}
|
||||
@@ -277,8 +282,7 @@ export function AdminAccountsPage({
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
</AdminField>
|
||||
<fieldset className="admin-permission-fieldset">
|
||||
<legend>Tab 访问权限</legend>
|
||||
<div className="admin-permission-grid">
|
||||
@@ -296,7 +300,6 @@ export function AdminAccountsPage({
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-permission-fieldset">
|
||||
<legend>独立操作权限</legend>
|
||||
<div className="admin-permission-grid">
|
||||
@@ -318,18 +321,13 @@ export function AdminAccountsPage({
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<AdminButton variant="primary" disabled={isSaving} type="submit">
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存'}</span>
|
||||
</button>
|
||||
</form>
|
||||
</AdminButton>
|
||||
</AdminPanel>
|
||||
</div>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,14 +23,23 @@ vi.mock('../components/useAdminWriteConfirm', () => ({
|
||||
confirmDialog: null,
|
||||
}),
|
||||
}));
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'gpt-6-astra',
|
||||
enabled: true,
|
||||
agentMode: 'codex' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
@@ -54,3 +63,44 @@ test('edits alias and upstream model without changing the stable identifier or r
|
||||
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('new models default to Codex and save the selected Agent mode', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'gpt-6-astra',
|
||||
enabled: true,
|
||||
agentMode: 'codex' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('gpt-6-astra');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加模型' }));
|
||||
const agentMode = screen.getByLabelText('模型 2 Agent 模式');
|
||||
expect((agentMode as HTMLSelectElement).value).toBe('codex');
|
||||
fireEvent.change(agentMode, { target: { value: 'cc' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
|
||||
const payload = calls[calls.length - 1]?.[1];
|
||||
expect(payload?.models).toHaveLength(2);
|
||||
expect(payload?.models[1]).toMatchObject({
|
||||
alias: '',
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'cc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
} from '@genarrative/shared/components';
|
||||
import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
@@ -74,16 +79,16 @@ export function AdminAgcModelsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide admin-agc-models">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>AGC 模型</h2>
|
||||
<p>管理客户端可用模型与用户看到的名称</p>
|
||||
</div>
|
||||
<span className="admin-agc-models-revision">
|
||||
版本 v{catalog?.revision ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<AdminPage wide className="admin-agc-models">
|
||||
<AdminPageHeading
|
||||
title="AGC 模型"
|
||||
description="管理客户端可用模型与用户看到的名称"
|
||||
actions={
|
||||
<span className="admin-agc-models-revision">
|
||||
版本 v{catalog?.revision ?? '-'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="admin-agc-models-summary">
|
||||
<div>
|
||||
<span>已启用</span>
|
||||
@@ -106,150 +111,178 @@ export function AdminAgcModelsPage({
|
||||
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<section className="admin-panel admin-agc-models-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>模型目录</h3>
|
||||
<span>客户端仅显示别名,实际模型名仅在这里维护</span>
|
||||
</div>
|
||||
<CircleHelp size={17} aria-label="模型目录帮助" />
|
||||
</div>
|
||||
<div className="admin-agc-models-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
title="重新读取"
|
||||
aria-label="重新读取模型"
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="添加模型"
|
||||
aria-label="添加模型"
|
||||
disabled={busy || !catalog || catalog.models.length >= 32}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: [
|
||||
...current.models,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
alias: '',
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
<AdminListPanel
|
||||
className="admin-agc-models-panel"
|
||||
columns={[
|
||||
{ key: 'alias', label: '别名' },
|
||||
{ key: 'modelId', label: '实际模型名' },
|
||||
{ key: 'agentMode', label: 'Agent 模式' },
|
||||
{ key: 'enabled', label: '启用' },
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
]}
|
||||
rows={catalog?.models ?? []}
|
||||
showEmpty={false}
|
||||
title="模型目录"
|
||||
description="客户端仅显示别名,实际模型名仅在这里维护"
|
||||
extra={<CircleHelp size={17} aria-label="模型目录帮助" />}
|
||||
toolbar={
|
||||
<>
|
||||
<div className="admin-agc-models-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
title="重新读取"
|
||||
aria-label="重新读取模型"
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="添加模型"
|
||||
aria-label="添加模型"
|
||||
disabled={busy || !catalog || catalog.models.length >= 32}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
models: [
|
||||
...current.models,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
alias: '',
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'codex',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !catalog}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Save size={16} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
<div className="admin-table-wrap admin-agc-models-table">
|
||||
<table className="admin-table admin-agc-models-table-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>别名</th>
|
||||
<th>实际模型名</th>
|
||||
<th>启用</th>
|
||||
<th>默认</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog?.models.map((model, index) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { alias: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { modelId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({ ...catalog, defaultModelId: model.id });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !catalog}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
<Save size={16} />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
</>
|
||||
}
|
||||
renderRow={(model, index) => {
|
||||
if (!catalog) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
alias: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
modelId: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} Agent 模式`}
|
||||
value={model.agentMode}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
agentMode: e.target.value as AdminAgcModel['agentMode'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
defaultModelId: model.id,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
tableClassName="admin-agc-models-table-grid"
|
||||
wrapClassName="admin-agc-models-table"
|
||||
/>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +542,37 @@ function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
test('批量上传:表头是真实列头单元格,不是嵌套 thead', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
|
||||
const table = Array.from(dialog.querySelectorAll('table')).find((candidate) =>
|
||||
candidate.textContent?.includes('alpha.zip'),
|
||||
);
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
// 每一列表头都必须是 `th`:把 `<TableHeader>`(渲染 `<thead>`)当单元格用会产出
|
||||
// `<th><tr><thead>`,列头语义与样式一起丢失。
|
||||
expect(
|
||||
within(table as HTMLElement)
|
||||
.getAllByRole('columnheader')
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual([
|
||||
'模板包',
|
||||
'ID',
|
||||
'名称',
|
||||
'版本',
|
||||
'运行时',
|
||||
'entry',
|
||||
'封面',
|
||||
'操作',
|
||||
]);
|
||||
expect(table?.querySelectorAll('thead thead')).toHaveLength(0);
|
||||
expect(table?.querySelectorAll('thead > tr > th')).toHaveLength(8);
|
||||
expect(table?.querySelectorAll('thead > th')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
Button,
|
||||
Modal,
|
||||
SelectField,
|
||||
@@ -294,28 +297,30 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
const writesDisabled = busy || loading || conflict || !snapshot?.writable;
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
|
||||
<div className="admin-page-heading">
|
||||
<h2>模板管理</h2>
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminPage wide className="admin-agc-templates genarrative-ui">
|
||||
<AdminPageHeading
|
||||
title="模板管理"
|
||||
actions={
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
|
||||
) : null}
|
||||
@@ -334,133 +339,155 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
</Button>
|
||||
) : null}
|
||||
{notice ? <Status tone="success">{notice}</Status> : null}
|
||||
<div className="admin-agc-template-filters">
|
||||
<TextField
|
||||
label="搜索模板"
|
||||
placeholder="名称、ID 或标签"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={runtime}
|
||||
onChange={(event) => setRuntime(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部运行时</option>
|
||||
{runtimes.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="上架状态"
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">已上架</option>
|
||||
<option value="disabled">已下架</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
{loading ? <Status>正在加载模板</Status> : null}
|
||||
{snapshot ? (
|
||||
<Table className="admin-table admin-agc-template-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>封面</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>引擎 / 版本</TableHead>
|
||||
<TableHead>包大小</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>
|
||||
<img
|
||||
className="admin-agc-template-cover"
|
||||
src={entry.coverUrl}
|
||||
alt={`${entry.title}封面`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<strong>{entry.title}</strong>
|
||||
<small>{entry.id}</small>
|
||||
<p className="admin-agc-template-summary">{entry.summary}</p>
|
||||
<div className="admin-agc-template-tags">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.engine ||
|
||||
runtimeLabels[entry.runtime] ||
|
||||
entry.runtime}{' '}
|
||||
{entry.engineVersion}
|
||||
<small>模板 {entry.templateVersion}</small>
|
||||
</TableCell>
|
||||
<TableCell>{formatTemplateSize(entry.zipSizeBytes)}</TableCell>
|
||||
<TableCell>{entry.enabled ? '已上架' : '已下架'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="admin-agc-template-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || loading}
|
||||
aria-label={`编辑 ${entry.title}`}
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setEditing({
|
||||
entry,
|
||||
revision: snapshot.revision,
|
||||
key: ++editorSequence.current,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={entry.enabled ? 'danger' : 'secondary'}
|
||||
disabled={writesDisabled}
|
||||
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
|
||||
onClick={() =>
|
||||
void writeTemplate(
|
||||
entry,
|
||||
{
|
||||
expectedRevision: snapshot.revision,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
tags: [...entry.tags],
|
||||
enabled: !entry.enabled,
|
||||
},
|
||||
entry.enabled ? '下架模板' : '上架模板',
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!entries.length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
{snapshot.templates.length
|
||||
? '没有符合筛选条件的模板'
|
||||
: '暂无模板'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
<AdminListPanel
|
||||
busy={loading}
|
||||
busyText="正在加载模板"
|
||||
busyVariant="line"
|
||||
toolbar={
|
||||
<div className="admin-agc-template-filters">
|
||||
<TextField
|
||||
label="搜索模板"
|
||||
placeholder="名称、ID 或标签"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={runtime}
|
||||
onChange={(event) => setRuntime(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部运行时</option>
|
||||
{runtimes.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="上架状态"
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">已上架</option>
|
||||
<option value="disabled">已下架</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
}
|
||||
columns={[
|
||||
{ key: 'cover', label: '封面' },
|
||||
{
|
||||
key: 'title',
|
||||
label: '模板',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.title,
|
||||
},
|
||||
{
|
||||
key: 'engine',
|
||||
label: '引擎 / 版本',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.engine || entry.engineVersion,
|
||||
},
|
||||
{
|
||||
key: 'zipSizeBytes',
|
||||
label: '包大小',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.zipSizeBytes,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: '状态',
|
||||
sortable: true,
|
||||
sortDescription: '上架状态',
|
||||
sortValue: (entry) => (entry.enabled ? 1 : 0),
|
||||
},
|
||||
{ key: 'actions', label: '操作' },
|
||||
]}
|
||||
rows={snapshot ? entries : []}
|
||||
showEmpty={Boolean(snapshot && entries.length === 0)}
|
||||
emptyText={
|
||||
snapshot && snapshot.templates.length
|
||||
? '没有符合筛选条件的模板'
|
||||
: '暂无模板'
|
||||
}
|
||||
emptyVariant="row"
|
||||
renderRow={(entry) => {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<img
|
||||
className="admin-agc-template-cover"
|
||||
src={entry.coverUrl}
|
||||
alt={`${entry.title}封面`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{entry.title}</strong>
|
||||
<small>{entry.id}</small>
|
||||
<p className="admin-agc-template-summary">{entry.summary}</p>
|
||||
<div className="admin-agc-template-tags">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{entry.engine || runtimeLabels[entry.runtime] || entry.runtime}{' '}
|
||||
{entry.engineVersion}
|
||||
<small>模板 {entry.templateVersion}</small>
|
||||
</td>
|
||||
<td>{formatTemplateSize(entry.zipSizeBytes)}</td>
|
||||
<td>{entry.enabled ? '已上架' : '已下架'}</td>
|
||||
<td>
|
||||
<div className="admin-agc-template-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || loading}
|
||||
aria-label={`编辑 ${entry.title}`}
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setEditing({
|
||||
entry,
|
||||
revision: snapshot.revision,
|
||||
key: ++editorSequence.current,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={entry.enabled ? 'danger' : 'secondary'}
|
||||
disabled={writesDisabled}
|
||||
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
|
||||
onClick={() =>
|
||||
void writeTemplate(
|
||||
entry,
|
||||
{
|
||||
expectedRevision: snapshot.revision,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
tags: [...entry.tags],
|
||||
enabled: !entry.enabled,
|
||||
},
|
||||
entry.enabled ? '下架模板' : '上架模板',
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
tableClassName="admin-agc-template-table"
|
||||
/>
|
||||
{editing ? (
|
||||
<Modal
|
||||
open
|
||||
@@ -534,7 +561,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
{confirmDialog}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -880,18 +907,18 @@ function TemplateUploadDialog({
|
||||
) : (
|
||||
<div className="admin-agc-template-upload-scroll">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeader>模板包</TableHeader>
|
||||
<TableHeader>ID</TableHeader>
|
||||
<TableHeader>名称</TableHeader>
|
||||
<TableHeader>版本</TableHeader>
|
||||
<TableHeader>运行时</TableHeader>
|
||||
<TableHeader>entry</TableHeader>
|
||||
<TableHeader>封面</TableHeader>
|
||||
<TableHeader>操作</TableHeader>
|
||||
<TableHead>模板包</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>运行时</TableHead>
|
||||
<TableHead>entry</TableHead>
|
||||
<TableHead>封面</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.rows.map((row) => {
|
||||
const rowError = state.errors[row.key];
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Modal } from '@genarrative/shared/components';
|
||||
import {
|
||||
AdminActionRow,
|
||||
AdminButton,
|
||||
AdminField,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPagination,
|
||||
AdminPanel,
|
||||
formatAdminDateTime,
|
||||
Modal,
|
||||
} from '@genarrative/shared/components';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
||||
@@ -45,11 +56,6 @@ const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
||||
receivedAt: '入库时间',
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function AdminAgcTrackingPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
@@ -86,8 +92,8 @@ export function AdminAgcTrackingPage({
|
||||
useEffect(() => {
|
||||
const saved = firstPage.current;
|
||||
if (!cursor && saved?.token === token && saved.query === query) {
|
||||
setEntries(saved.response.entries);
|
||||
setNextCursor(saved.response.nextCursor);
|
||||
setEntries(saved.response.entries ?? []);
|
||||
setNextCursor(saved.response.nextCursor ?? null);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -101,8 +107,9 @@ export function AdminAgcTrackingPage({
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
if (!cursor) firstPage.current = { token, query, response };
|
||||
setEntries(response.entries);
|
||||
setNextCursor(response.nextCursor);
|
||||
// 接口形状异常时退回空列表,避免整页渲染因 undefined.length 崩掉。
|
||||
setEntries(response.entries ?? []);
|
||||
setNextCursor(response.nextCursor ?? null);
|
||||
})
|
||||
.catch((failure: unknown) => {
|
||||
if (active) handlePageError(failure, onUnauthorized, setError);
|
||||
@@ -162,43 +169,47 @@ export function AdminAgcTrackingPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>客户端埋点</h2>
|
||||
<p>按发生时间倒序展示</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
resetPages();
|
||||
setRefresh((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<form className="admin-panel admin-form" onSubmit={search}>
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
title="客户端埋点"
|
||||
description="按发生时间倒序展示"
|
||||
actions={
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
resetPages();
|
||||
setRefresh((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</AdminButton>
|
||||
}
|
||||
/>
|
||||
<AdminPanel as="form" onSubmit={search}>
|
||||
<div className="admin-filter-grid">
|
||||
{(['userId', 'projectId'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>{fieldLabels[key]}</span>
|
||||
<AdminField label={fieldLabels[key]} key={key}>
|
||||
<input
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
setFilters({
|
||||
...filters,
|
||||
[key]: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</AdminField>
|
||||
))}
|
||||
<label className="admin-field">
|
||||
<span>事件类型</span>
|
||||
<AdminField label="事件类型">
|
||||
<select
|
||||
value={filters.eventName}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, eventName: event.target.value })
|
||||
setFilters({
|
||||
...filters,
|
||||
eventName: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
@@ -208,33 +219,34 @@ export function AdminAgcTrackingPage({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</AdminField>
|
||||
{(['startTime', 'endTime'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>
|
||||
{key === 'startTime'
|
||||
<AdminField
|
||||
label={
|
||||
key === 'startTime'
|
||||
? '发生时间起点(含)'
|
||||
: '发生时间终点(不含)'}
|
||||
</span>
|
||||
: '发生时间终点(不含)'
|
||||
}
|
||||
key={key}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
setFilters({
|
||||
...filters,
|
||||
[key]: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</AdminField>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-primary-button"
|
||||
disabled={loading}
|
||||
>
|
||||
<AdminActionRow>
|
||||
<AdminButton variant="primary" type="submit" disabled={loading}>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</AdminButton>
|
||||
</AdminActionRow>
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||||
.filter((key) => query[key])
|
||||
.map((key) => (
|
||||
@@ -242,93 +254,81 @@ export function AdminAgcTrackingPage({
|
||||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</AdminPanel>
|
||||
{error ? (
|
||||
<p role="alert" className="admin-error-message">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'入库时间',
|
||||
'发生时间',
|
||||
'用户',
|
||||
'事件名称',
|
||||
'项目',
|
||||
'来源',
|
||||
'结果',
|
||||
'客户端版本',
|
||||
'详情',
|
||||
].map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.eventId}>
|
||||
<td>{formatTime(entry.receivedAt)}</td>
|
||||
<td>{formatTime(entry.eventTime)}</td>
|
||||
<td>
|
||||
{entry.userId}
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</td>
|
||||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||||
<td>{entry.projectId ?? '—'}</td>
|
||||
<td>{entry.source}</td>
|
||||
<td>{entry.status ?? '—'}</td>
|
||||
<td>{entry.clientVersion}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-ghost-button"
|
||||
onClick={() => setDetail(entry)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status">加载中…</p>
|
||||
) : !error && entries.length === 0 ? (
|
||||
<p>暂无客户端埋点数据</p>
|
||||
) : null}
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || page === 0}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {page + 1} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || !nextCursor}
|
||||
onClick={() => {
|
||||
if (!nextCursor) return;
|
||||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||||
setPage(page + 1);
|
||||
<AdminListPanel
|
||||
busy={loading}
|
||||
busyRole="status"
|
||||
busyText="加载中…"
|
||||
busyVariant="line"
|
||||
emptyText="暂无客户端埋点数据"
|
||||
emptyVariant="text"
|
||||
rows={entries}
|
||||
showEmpty={!loading && !error && entries.length === 0}
|
||||
pagination={
|
||||
<AdminPagination
|
||||
ariaLabel="客户端埋点分页"
|
||||
info={<>第 {page + 1} 页</>}
|
||||
busy={loading}
|
||||
previous={{
|
||||
disabled: page === 0,
|
||||
onClick: () => setPage((value) => value - 1),
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
next={{
|
||||
disabled: !nextCursor,
|
||||
onClick: () => {
|
||||
if (!nextCursor) return;
|
||||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||||
setPage(page + 1);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{ key: 'receivedAt', label: '入库时间' },
|
||||
{ key: 'eventTime', label: '发生时间' },
|
||||
{ key: 'userId', label: '用户' },
|
||||
{ key: 'eventName', label: '事件名称' },
|
||||
{ key: 'projectId', label: '项目' },
|
||||
{ key: 'source', label: '来源' },
|
||||
{ key: 'status', label: '结果' },
|
||||
{ key: 'clientVersion', label: '客户端版本' },
|
||||
{ key: 'actions', label: '详情' },
|
||||
]}
|
||||
renderRow={(entry) => (
|
||||
<tr key={entry.eventId}>
|
||||
<td>{formatAdminDateTime(entry.receivedAt)}</td>
|
||||
<td>{formatAdminDateTime(entry.eventTime)}</td>
|
||||
<td>
|
||||
{entry.userId}
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</td>
|
||||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||||
<td>{entry.projectId ?? '—'}</td>
|
||||
<td>{entry.source}</td>
|
||||
<td>{entry.status ?? '—'}</td>
|
||||
<td>{entry.clientVersion}</td>
|
||||
<td>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setDetail(entry)}
|
||||
>
|
||||
查看详情
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
size="wide"
|
||||
/>
|
||||
{detail ? (
|
||||
<Modal
|
||||
open
|
||||
@@ -347,33 +347,42 @@ export function AdminAgcTrackingPage({
|
||||
.map((key) => (
|
||||
<div key={key}>
|
||||
<dt>{fieldLabels[key]}</dt>
|
||||
<dd style={{ overflowWrap: 'anywhere' }}>
|
||||
<dd
|
||||
style={{
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{detail[key] == null ? '—' : String(detail[key])}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="admin-action-row">
|
||||
<AdminActionRow>
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||||
(key) =>
|
||||
detail[key] ? (
|
||||
<button
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
key={key}
|
||||
className="admin-secondary-button"
|
||||
onClick={() => related(key, detail[key]!)}
|
||||
>
|
||||
查询同一{fieldLabels[key]}
|
||||
</button>
|
||||
</AdminButton>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</AdminActionRow>
|
||||
<h3>事件属性</h3>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(detail.properties, null, 2)}
|
||||
</pre>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
AdminAlert,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminField,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPanel,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -157,64 +166,68 @@ export function AdminDashboardPage({
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading admin-dashboard-heading">
|
||||
<div>
|
||||
<h2>Dashboard</h2>
|
||||
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
|
||||
</div>
|
||||
<div className="admin-dashboard-actions">
|
||||
<div className="admin-dashboard-period-fields">
|
||||
<label className="admin-field admin-dashboard-date-field">
|
||||
<span>起始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.startDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('startDate', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field admin-dashboard-date-field">
|
||||
<span>终止日期</span>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.endDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('endDate', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="admin-segmented-control admin-dashboard-granularity"
|
||||
role="tablist"
|
||||
aria-label="统计周期"
|
||||
>
|
||||
{granularityOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-active={option.id === granularity}
|
||||
onClick={() => handleGranularityChange(option.id)}
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
title="Dashboard"
|
||||
description={dashboard?.range.periodLabel ?? '运营指标'}
|
||||
actions={
|
||||
<div className="admin-dashboard-actions">
|
||||
<div className="admin-dashboard-period-fields">
|
||||
<AdminField
|
||||
label="起始日期"
|
||||
className="admin-dashboard-date-field"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.startDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('startDate', event.target.value)
|
||||
}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField
|
||||
label="终止日期"
|
||||
className="admin-dashboard-date-field"
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
max={today}
|
||||
value={dateRange.endDate}
|
||||
onChange={(event) =>
|
||||
handleDateRangeChange('endDate', event.target.value)
|
||||
}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<div
|
||||
className="admin-segmented-control admin-dashboard-granularity"
|
||||
role="tablist"
|
||||
aria-label="统计周期"
|
||||
>
|
||||
{granularityOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
data-active={option.id === granularity}
|
||||
onClick={() => handleGranularityChange(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadDashboard()}
|
||||
>
|
||||
<RefreshCw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</AdminButton>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadDashboard()}
|
||||
>
|
||||
<RefreshCw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="admin-dashboard-tabs"
|
||||
@@ -237,33 +250,25 @@ export function AdminDashboardPage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
||||
|
||||
{dashboard?.warnings.length ? (
|
||||
<section className="admin-panel admin-panel-warning">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>读取告警</h3>
|
||||
<span>{dashboard.warnings.length}</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
tone="warning"
|
||||
title="读取告警"
|
||||
count={dashboard.warnings.length}
|
||||
>
|
||||
<ul className="admin-error-list">
|
||||
{dashboard.warnings.map((message) => (
|
||||
<li key={message}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'metrics' ? (
|
||||
<>
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>总计数据</h3>
|
||||
<span>全站累计</span>
|
||||
</div>
|
||||
<AdminPanel title="总计数据" count="全站累计">
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{totalMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
@@ -275,13 +280,12 @@ export function AdminDashboardPage({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>时段数据</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
title="时段数据"
|
||||
count={dashboard?.range.periodLabel ?? '-'}
|
||||
>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{periodMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
@@ -293,13 +297,12 @@ export function AdminDashboardPage({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>新增用户转化与留存</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
title="新增用户转化与留存"
|
||||
count={dashboard?.range.periodLabel ?? '-'}
|
||||
>
|
||||
<div className="admin-dashboard-retention-grid">
|
||||
<RateCard
|
||||
label={`${rangePrefix(granularity)}新增用户付费率`}
|
||||
@@ -330,7 +333,7 @@ export function AdminDashboardPage({
|
||||
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
|
||||
<div className="admin-dashboard-chart-grid">
|
||||
{(dashboard?.charts ?? []).map((chart) => (
|
||||
@@ -342,17 +345,16 @@ export function AdminDashboardPage({
|
||||
/>
|
||||
))}
|
||||
{dashboard && dashboard.charts.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无图表</div>
|
||||
<AdminEmptyState>暂无图表</AdminEmptyState>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="admin-dashboard-operations">
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>运营指标</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
title="运营指标"
|
||||
count={dashboard?.range.periodLabel ?? '-'}
|
||||
>
|
||||
<div className="admin-dashboard-operation-grid">
|
||||
{(dashboard?.operations.cards ?? []).map((card) => (
|
||||
<MetricCard
|
||||
@@ -364,7 +366,7 @@ export function AdminDashboardPage({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
|
||||
<BreakdownPanel
|
||||
title="素材类型分布"
|
||||
@@ -378,7 +380,7 @@ export function AdminDashboardPage({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
|
||||
function handleGranularityChange(
|
||||
@@ -426,14 +428,11 @@ function MetricCard({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="admin-panel admin-dashboard-metric-card"
|
||||
data-compact={compact}
|
||||
>
|
||||
<AdminPanel data-compact={compact} className="admin-dashboard-metric-card">
|
||||
<span>{label}</span>
|
||||
<strong>{formatNumber(value)}</strong>
|
||||
<small>{unit}</small>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -498,14 +497,16 @@ function ChartPanel({
|
||||
}, [chart.buckets.length, scrollLeft]);
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-dashboard-chart-card">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{chart.title}</h3>
|
||||
<span>
|
||||
<AdminPanel
|
||||
title={chart.title}
|
||||
count={
|
||||
<>
|
||||
{chart.id === 'visit-users' ? '时段去重 ' : ''}
|
||||
{formatNumber(chart.total)} {chart.unit}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
className="admin-dashboard-chart-card"
|
||||
>
|
||||
<div
|
||||
ref={barsRef}
|
||||
className="admin-dashboard-bars"
|
||||
@@ -535,7 +536,7 @@ function ChartPanel({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -550,11 +551,11 @@ function BreakdownPanel({
|
||||
}) {
|
||||
const maxValue = Math.max(1, ...rows.map((row) => row.value));
|
||||
return (
|
||||
<section className="admin-panel admin-dashboard-breakdown-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{title}</h3>
|
||||
<span>{rows.length}</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
title={title}
|
||||
count={rows.length}
|
||||
className="admin-dashboard-breakdown-panel"
|
||||
>
|
||||
<div className="admin-dashboard-breakdown-list">
|
||||
{rows.map((row) => (
|
||||
<div className="admin-dashboard-breakdown-row" key={row.key}>
|
||||
@@ -565,15 +566,17 @@ function BreakdownPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-breakdown-track">
|
||||
<span style={{ width: `${(row.value / maxValue) * 100}%` }} />
|
||||
<span
|
||||
style={{
|
||||
width: `${(row.value / maxValue) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无数据</div>
|
||||
) : null}
|
||||
{rows.length === 0 ? <AdminEmptyState>暂无数据</AdminEmptyState> : null}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
AdminAlert,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminField,
|
||||
AdminFormRow,
|
||||
AdminInfoList,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminPanel,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Plus, Send, Trash2 } from 'lucide-react';
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
|
||||
@@ -75,19 +86,13 @@ export function AdminDebugHttpPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>API 调试</h2>
|
||||
<p>受控同源请求</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdminPage>
|
||||
<AdminPageHeading title="API 调试" description="受控同源请求" />
|
||||
|
||||
<div className="admin-two-column">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSubmit}>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>Method</span>
|
||||
<AdminPanel as="form" onSubmit={handleSubmit}>
|
||||
<AdminFormRow>
|
||||
<AdminField label="Method" compact>
|
||||
<select
|
||||
value={method}
|
||||
onChange={(event) =>
|
||||
@@ -100,28 +105,33 @@ export function AdminDebugHttpPage({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>Path</span>
|
||||
</AdminField>
|
||||
<AdminField label="Path" fill>
|
||||
<input
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</AdminField>
|
||||
</AdminFormRow>
|
||||
|
||||
<section className="admin-subsection">
|
||||
<div className="admin-subsection-heading">
|
||||
<span>Headers</span>
|
||||
<button
|
||||
className="admin-ghost-button"
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setHeaders((current) => [...current, { name: '', value: '' }])
|
||||
setHeaders((current) => [
|
||||
...current,
|
||||
{
|
||||
name: '',
|
||||
value: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
<div className="admin-header-editor">
|
||||
{headers.map((header, index) => (
|
||||
@@ -132,7 +142,10 @@ export function AdminDebugHttpPage({
|
||||
setHeaders((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, name: event.target.value }
|
||||
? {
|
||||
...item,
|
||||
name: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
@@ -144,14 +157,17 @@ export function AdminDebugHttpPage({
|
||||
setHeaders((current) =>
|
||||
current.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, value: event.target.value }
|
||||
? {
|
||||
...item,
|
||||
value: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="admin-ghost-button"
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
title="移除"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -161,66 +177,61 @@ export function AdminDebugHttpPage({
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>Body</span>
|
||||
<AdminField label="Body">
|
||||
<textarea
|
||||
rows={9}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</AdminField>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !path.trim().startsWith('/')}
|
||||
type="submit"
|
||||
>
|
||||
<Send size={17} aria-hidden="true" />
|
||||
<span>{isSubmitting ? '发送中' : '发送'}</span>
|
||||
</button>
|
||||
</form>
|
||||
</AdminButton>
|
||||
</AdminPanel>
|
||||
|
||||
<section className="admin-panel admin-result-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>结果</h3>
|
||||
<span>
|
||||
{result ? `${result.status} ${result.statusText}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<AdminPanel
|
||||
title="结果"
|
||||
count={result ? `${result.status} ${result.statusText}` : '-'}
|
||||
className="admin-result-panel"
|
||||
>
|
||||
{result ? (
|
||||
<>
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{result.status}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Headers</dt>
|
||||
<dd>{result.headers.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<AdminInfoList
|
||||
items={[
|
||||
{
|
||||
label: <>Status</>,
|
||||
value: <>{result.status}</>,
|
||||
},
|
||||
{
|
||||
label: <>Headers</>,
|
||||
value: <>{result.headers.length}</>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<pre className="admin-code-block">
|
||||
{jsonPreview || result.bodyText || '(empty)'}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无结果</div>
|
||||
<AdminEmptyState>暂无结果</AdminEmptyState>
|
||||
)}
|
||||
</section>
|
||||
</AdminPanel>
|
||||
</div>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user