Merge remote-tracking branch 'origin/master' into fix/multi-select
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
|
||||
| Intent | Method and path | Required fields |
|
||||
| --- | --- | --- |
|
||||
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
|
||||
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
|
||||
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
|
||||
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
|
||||
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
|
||||
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
|
||||
@@ -115,6 +115,8 @@ python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_extern
|
||||
|
||||
## Request Patterns
|
||||
|
||||
For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`.
|
||||
|
||||
For Python callers, prefer:
|
||||
|
||||
```python
|
||||
@@ -142,6 +144,20 @@ client.generate_image(
|
||||
)
|
||||
```
|
||||
|
||||
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
|
||||
|
||||
```python
|
||||
client.generate_icon_spritesheet(
|
||||
"editor-resource-current-art-spec",
|
||||
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
|
||||
canvasSession=session,
|
||||
assetLabel="贪吃蛇透明图集",
|
||||
screenColor="auto",
|
||||
)
|
||||
```
|
||||
|
||||
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
|
||||
|
||||
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
|
||||
|
||||
Use this shared base:
|
||||
@@ -324,7 +340,19 @@ Character image generation (including character redraw through `kind: "character
|
||||
|
||||
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
|
||||
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
|
||||
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning.
|
||||
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
|
||||
|
||||
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
|
||||
|
||||
## AI Game Creator Canonical Visual DAG
|
||||
|
||||
The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request:
|
||||
|
||||
1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image.
|
||||
2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`.
|
||||
3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`.
|
||||
|
||||
Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG.
|
||||
|
||||
## Guardrails
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener
|
||||
|
||||
Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful.
|
||||
|
||||
For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain:
|
||||
|
||||
1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`.
|
||||
2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
||||
3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`.
|
||||
|
||||
Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG.
|
||||
|
||||
## Intent Routing
|
||||
|
||||
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
|
||||
@@ -53,7 +61,7 @@ Ask a follow-up only when two routes could both be correct and produce different
|
||||
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
|
||||
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
|
||||
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
|
||||
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
|
||||
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
|
||||
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
|
||||
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
|
||||
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
|
||||
@@ -67,15 +75,44 @@ Ask a follow-up only when two routes could both be correct and produce different
|
||||
|
||||
| User intent | Endpoint | Required fields | Common optional fields |
|
||||
| --- | --- | --- | --- |
|
||||
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
|
||||
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
|
||||
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
||||
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
||||
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
|
||||
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
## Image Post-processing Style
|
||||
|
||||
The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting.
|
||||
|
||||
- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning.
|
||||
- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation.
|
||||
- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`.
|
||||
- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum.
|
||||
|
||||
Image or character generation with pixel-art snapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "生成一个正面站立的像素风冒险者角色",
|
||||
"kind": "character",
|
||||
"style": "pixelArt"
|
||||
}
|
||||
```
|
||||
|
||||
Icon spritesheet generation with pixel-art snapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png",
|
||||
"iconDescriptions": ["木剑", "圆盾", "红色药水"],
|
||||
"style": "pixelArt"
|
||||
}
|
||||
```
|
||||
|
||||
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
|
||||
|
||||
## HTTP 2xx Warning Handling
|
||||
@@ -84,7 +121,7 @@ Character image generation (including character redraw through `kind: "character
|
||||
|
||||
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
|
||||
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
|
||||
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively.
|
||||
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
|
||||
|
||||
## Reference Image Upload
|
||||
|
||||
|
||||
@@ -270,11 +270,21 @@ class GenarrativeExternalClient:
|
||||
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
|
||||
return fields
|
||||
|
||||
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
|
||||
def save_canvas(
|
||||
self,
|
||||
project_id: str,
|
||||
viewport: dict[str, Any],
|
||||
layers: dict[str, Any],
|
||||
expected_revision: int,
|
||||
) -> Any:
|
||||
return self.request_json(
|
||||
"PATCH",
|
||||
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
|
||||
{"viewport": viewport, "layers": layers},
|
||||
{
|
||||
"viewport": viewport,
|
||||
"layers": layers,
|
||||
"expectedRevision": expected_revision,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
|
||||
@@ -450,6 +460,29 @@ class GenarrativeExternalClient:
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def generate_icon_spritesheet(
|
||||
self,
|
||||
reference_image_src: str,
|
||||
icon_descriptions: list[str],
|
||||
**fields: Any,
|
||||
) -> Any:
|
||||
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
|
||||
if not descriptions:
|
||||
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
|
||||
label = fields.get("assetLabel", "图标图集")
|
||||
self._apply_canvas_session_fields(fields, label, 1024, 1024)
|
||||
fields.setdefault("screenColor", "auto")
|
||||
return self.request_json(
|
||||
"POST",
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
{
|
||||
"referenceImageSrc": reference_image_src,
|
||||
"iconDescriptions": descriptions,
|
||||
**fields,
|
||||
},
|
||||
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
|
||||
fields.pop("aspectRatio", None)
|
||||
self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel")
|
||||
@@ -619,6 +652,17 @@ def _self_test() -> None:
|
||||
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
|
||||
assert calls[1]["path"] == "/api/external/v1/editor/assets"
|
||||
assert result["asset"]["assetId"] == "editor-asset-demo"
|
||||
calls.clear()
|
||||
client.generate_icon_spritesheet(
|
||||
"editor-resource-spec",
|
||||
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
|
||||
canvasSession=session,
|
||||
assetLabel="贪吃蛇透明图集",
|
||||
)
|
||||
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
|
||||
assert calls[0]["body"]["screenColor"] == "auto"
|
||||
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
|
||||
print("self-test ok")
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Run frontend and script tests
|
||||
run: npm run test
|
||||
|
||||
@@ -161,6 +164,23 @@ jobs:
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Prepare server-rs Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path server-rs/Cargo.toml; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
@@ -186,8 +206,32 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install AI game creator dependencies
|
||||
run: npm ci --prefix apps/ai-game-creator-shell
|
||||
|
||||
- name: Prepare native Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for manifest_path in \
|
||||
apps/desktop-shell/src-tauri/Cargo.toml \
|
||||
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
|
||||
for attempt in $(seq 1 5); do
|
||||
if cargo fetch --locked \
|
||||
--target x86_64-unknown-linux-gnu \
|
||||
--manifest-path "${manifest_path}"; then
|
||||
break
|
||||
fi
|
||||
if [[ "${attempt}" -eq 5 ]]; then
|
||||
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
done
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells
|
||||
|
||||
- name: Ensure native lockfile is unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock
|
||||
- name: Ensure native lockfiles are unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||
|
||||
@@ -31,6 +31,12 @@ temp*build*/
|
||||
/apps/desktop-shell/src-tauri/target/
|
||||
/apps/desktop-shell/src-tauri/gen/
|
||||
/apps/desktop-shell/src-tauri/permissions/autogenerated/
|
||||
/apps/ai-game-creator-shell/src-tauri/target/
|
||||
/apps/ai-game-creator-shell/src-tauri/gen/
|
||||
/apps/ai-game-creator-shell/src-tauri/logs/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
/apps/mobile-shell/.expo-export-smoke/
|
||||
/server-rs/.spacetimedb/
|
||||
@@ -47,6 +53,7 @@ temp*build*/
|
||||
/.playwright-cli/
|
||||
**/.playwright-cli/
|
||||
/output/playwright/
|
||||
/output/external-api-smoke/
|
||||
/server-rs/crates/*/logs/
|
||||
.worktrees/
|
||||
.rag/
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||
- 需要仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md)。
|
||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
|
||||
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||
- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。
|
||||
- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,必须读取并按 [`spacetimedb-cli`](.codex/skills/spacetimedb-cli/SKILL.md)、[`spacetimedb-rust`](.codex/skills/spacetimedb-rust/SKILL.md)、[`spacetimedb-concepts`](.codex/skills/spacetimedb-concepts/SKILL.md) 中相关 skill 执行。
|
||||
|
||||
@@ -16,6 +16,10 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
|
||||
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
|
||||
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
|
||||
|
||||
**项目开发画布**:
|
||||
GameAgent 独立客户端中某个本地游戏项目的开发工作区概念,用于承载项目名、路径、首条需求、附件导入结果、最近 run 状态,以及后续真正的项目开发画布与 Agent 协作界面;当前首页改造阶段先落占位页。它属于 AI 游戏创作本地项目域,不等同于 `/editor` 的图片画布工程。
|
||||
_Avoid_: `/editor` 图片画布工程、画布资源 / 图层布局、启动器 / 主窗口切换概念、只用于首页输入的临时草稿
|
||||
|
||||
**画布Agent对话**:
|
||||
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
|
||||
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
|
||||
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
} from './adminApiClient';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
|
||||
@@ -31,11 +33,13 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
|
||||
displayName: '运营',
|
||||
password: 'secret123',
|
||||
tabPermissions: ['dashboard', 'tracking'],
|
||||
actionPermissions: ['profile-wallet-consumption-reconcile'],
|
||||
enabled: true,
|
||||
});
|
||||
await updateAdminAccount('owner-token', 'member/1', {
|
||||
displayName: '运营二组',
|
||||
tabPermissions: ['tracking'],
|
||||
actionPermissions: [],
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
@@ -44,6 +48,14 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
|
||||
body: JSON.stringify({
|
||||
username: 'operator',
|
||||
displayName: '运营',
|
||||
password: 'secret123',
|
||||
tabPermissions: ['dashboard', 'tracking'],
|
||||
actionPermissions: ['profile-wallet-consumption-reconcile'],
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
|
||||
@@ -53,12 +65,40 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
|
||||
body: JSON.stringify({
|
||||
displayName: '运营二组',
|
||||
tabPermissions: ['tracking'],
|
||||
actionPermissions: [],
|
||||
enabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('账号配置一次提交初始和每日免费泥点', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({configId: 'profile_wallet'}), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await upsertProfileWalletConfig('owner-token', {
|
||||
initialMudPoints: 100,
|
||||
dailyFreePointsPerDay: 35,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/profile/wallet-config',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
|
||||
body: JSON.stringify({
|
||||
initialMudPoints: 100,
|
||||
dailyFreePointsPerDay: 35,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
@@ -238,6 +278,33 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('历史花费手动对账使用独立管理员写接口', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
userId: 'user-1',
|
||||
historicalConsumedPoints: 1300,
|
||||
changed: true,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await reconcileAdminUserConsumption('token-1', { userId: 'user-1' });
|
||||
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
||||
'/admin/api/profile/users/reconcile-consumption',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer token-1' }),
|
||||
body: JSON.stringify({ userId: 'user-1' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
AdminUpsertProfileWalletConfigRequest,
|
||||
AdminUserConsumptionReconcileRequest,
|
||||
AdminUserConsumptionReconcileResponse,
|
||||
AdminUserDetailQuery,
|
||||
AdminUserDetailResponse,
|
||||
AdminWalletRestrictionRequest,
|
||||
@@ -577,6 +579,16 @@ export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcileAdminUserConsumption(
|
||||
token: string,
|
||||
payload: AdminUserConsumptionReconcileRequest,
|
||||
) {
|
||||
return request<AdminUserConsumptionReconcileResponse>(
|
||||
'/admin/api/profile/users/reconcile-consumption',
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
export function previewAdminRechargeRefund(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundPreviewRequest,
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface AdminSessionPayload {
|
||||
roles: string[];
|
||||
accountRole: 'owner' | 'member';
|
||||
tabPermissions: string[];
|
||||
actionPermissions: string[];
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
@@ -48,6 +49,7 @@ export interface AdminAccountPayload {
|
||||
displayName: string;
|
||||
accountRole: 'owner' | 'member';
|
||||
tabPermissions: string[];
|
||||
actionPermissions: string[];
|
||||
enabled: boolean;
|
||||
tokenVersion: number;
|
||||
createdBy: string;
|
||||
@@ -65,6 +67,7 @@ export interface AdminCreateAccountRequest {
|
||||
displayName: string;
|
||||
password: string;
|
||||
tabPermissions: string[];
|
||||
actionPermissions: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -76,6 +79,7 @@ export interface AdminUpdateAccountRequest {
|
||||
displayName: string;
|
||||
password?: string;
|
||||
tabPermissions: string[];
|
||||
actionPermissions: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -556,6 +560,7 @@ export interface AdminUpsertProfileRechargeProductRequest {
|
||||
|
||||
export interface AdminUpsertProfileWalletConfigRequest {
|
||||
initialMudPoints: number;
|
||||
dailyFreePointsPerDay: number;
|
||||
}
|
||||
|
||||
export interface ProfileRedeemCodeAdminResponse {
|
||||
@@ -657,6 +662,7 @@ export interface ProfileRechargeProductConfigAdminListResponse {
|
||||
export interface ProfileWalletConfigAdminResponse {
|
||||
configId: string;
|
||||
initialMudPoints: number;
|
||||
dailyFreePointsPerDay: number;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string;
|
||||
createdAt: string;
|
||||
@@ -819,10 +825,24 @@ export interface AdminUserDetailResponse {
|
||||
bindingStatus: string;
|
||||
phoneBound: boolean;
|
||||
wechatBound: boolean;
|
||||
historicalConsumedPoints: number;
|
||||
canReconcileConsumption: boolean;
|
||||
wallet: AdminProfileWalletPayload;
|
||||
rechargeOrders: AdminRechargeOrderEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminUserConsumptionReconcileRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AdminUserConsumptionReconcileResponse {
|
||||
userId: string;
|
||||
previousHistoricalConsumedPoints?: number | null;
|
||||
historicalConsumedPoints: number;
|
||||
changed: boolean;
|
||||
reconciledAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPreviewRequest {
|
||||
orderId: string;
|
||||
refundAmountCents: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminUserDetail,
|
||||
reconcileAdminUserConsumption,
|
||||
updateAdminWalletRestriction,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
),
|
||||
getAdminUserDetail: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
reconcileAdminUserConsumption: vi.fn(),
|
||||
updateAdminWalletRestriction: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -48,6 +50,8 @@ const detail: AdminUserDetailResponse = {
|
||||
bindingStatus: 'bound',
|
||||
phoneBound: true,
|
||||
wechatBound: true,
|
||||
historicalConsumedPoints: 1234,
|
||||
canReconcileConsumption: true,
|
||||
wallet,
|
||||
rechargeOrders: [
|
||||
{
|
||||
@@ -82,6 +86,13 @@ const detail: AdminUserDetailResponse = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
|
||||
vi.mocked(reconcileAdminUserConsumption).mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
previousHistoricalConsumedPoints: 1234,
|
||||
historicalConsumedPoints: 1300,
|
||||
changed: true,
|
||||
reconciledAtMicros: 1_720_000_000_000_000,
|
||||
});
|
||||
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
|
||||
});
|
||||
|
||||
@@ -111,6 +122,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
|
||||
expect(screen.getByText('138****5678')).toBeTruthy();
|
||||
expect(screen.getByText('退款欠账限制')).toBeTruthy();
|
||||
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
|
||||
expect(screen.getByText('历史花费')).toBeTruthy();
|
||||
expect(screen.getByText('1234', {selector: 'strong'})).toBeTruthy();
|
||||
expect(screen.getByText('order-1')).toBeTruthy();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
@@ -136,6 +149,50 @@ test('只有陶泥号时按 publicUserCode 查询用户', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('历史花费支持手动对账并用权威结果校准展示', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
|
||||
await screen.findByText('陶泥用户');
|
||||
await user.click(screen.getByRole('button', {name: '手动对账历史花费'}));
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', {
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy();
|
||||
expect(screen.getByText('1300', {selector: 'strong'})).toBeTruthy();
|
||||
});
|
||||
|
||||
test('没有独立操作权限时不显示历史花费对账按钮', async () => {
|
||||
vi.mocked(getAdminUserDetail).mockResolvedValue({
|
||||
...detail,
|
||||
canReconcileConsumption: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
|
||||
await screen.findByText('陶泥用户');
|
||||
|
||||
expect(screen.queryByRole('button', {name: '手动对账历史花费'})).toBeNull();
|
||||
});
|
||||
|
||||
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
|
||||
const user = userEvent.setup();
|
||||
const manuallyFrozenWallet: AdminProfileWalletPayload = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatAdminApiError,
|
||||
getAdminUserDetail,
|
||||
isAdminApiError,
|
||||
reconcileAdminUserConsumption,
|
||||
updateAdminWalletRestriction,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -34,6 +35,8 @@ export function AdminUserDetailDialog({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [restrictionReason, setRestrictionReason] = useState('');
|
||||
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
|
||||
const [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false);
|
||||
const [reconcileMessage, setReconcileMessage] = useState('');
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const requestVersionRef = useRef(0);
|
||||
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
|
||||
@@ -60,7 +63,12 @@ export function AdminUserDetailDialog({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!isSavingRestriction &&
|
||||
!isReconcilingConsumption &&
|
||||
!isConfirming
|
||||
) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
@@ -69,13 +77,14 @@ export function AdminUserDetailDialog({
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isConfirming, isSavingRestriction, onClose]);
|
||||
}, [isConfirming, isReconcilingConsumption, isSavingRestriction, onClose]);
|
||||
|
||||
async function loadDetail() {
|
||||
const requestVersion = requestVersionRef.current + 1;
|
||||
requestVersionRef.current = requestVersion;
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
setReconcileMessage('');
|
||||
try {
|
||||
const response = await getAdminUserDetail(token, {
|
||||
userId: userId?.trim() || undefined,
|
||||
@@ -144,6 +153,47 @@ export function AdminUserDetailDialog({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConsumptionReconcile() {
|
||||
if (!detail || isReconcilingConsumption) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmWrite({
|
||||
action: '手动对账历史花费',
|
||||
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReconcilingConsumption(true);
|
||||
setErrorMessage('');
|
||||
setReconcileMessage('');
|
||||
try {
|
||||
const response = await reconcileAdminUserConsumption(token, {
|
||||
userId: detail.userId,
|
||||
});
|
||||
setDetail((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
historicalConsumedPoints: response.historicalConsumedPoints,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setReconcileMessage(
|
||||
response.changed ? '对账完成,历史花费已校准' : '对账完成,数据一致',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
onUnauthorized('登录状态已失效');
|
||||
} else {
|
||||
setErrorMessage(formatAdminApiError(error));
|
||||
}
|
||||
} finally {
|
||||
setIsReconcilingConsumption(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -158,6 +208,7 @@ export function AdminUserDetailDialog({
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
!isSavingRestriction &&
|
||||
!isReconcilingConsumption &&
|
||||
!isConfirming
|
||||
) {
|
||||
onClose();
|
||||
@@ -174,7 +225,7 @@ export function AdminUserDetailDialog({
|
||||
<button
|
||||
aria-label="刷新用户信息"
|
||||
className="admin-ghost-button"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isReconcilingConsumption}
|
||||
title="刷新"
|
||||
type="button"
|
||||
onClick={() => void loadDetail()}
|
||||
@@ -185,7 +236,7 @@ export function AdminUserDetailDialog({
|
||||
ref={closeButtonRef}
|
||||
aria-label="关闭用户详情"
|
||||
className="admin-ghost-button"
|
||||
disabled={isSavingRestriction}
|
||||
disabled={isSavingRestriction || isReconcilingConsumption}
|
||||
title="关闭"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -222,7 +273,15 @@ export function AdminUserDetailDialog({
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<WalletSection wallet={detail.wallet} />
|
||||
<WalletSection
|
||||
wallet={detail.wallet}
|
||||
historicalConsumedPoints={detail.historicalConsumedPoints}
|
||||
canReconcileConsumption={detail.canReconcileConsumption}
|
||||
isBusy={isReconcilingConsumption || isSavingRestriction}
|
||||
isReconciling={isReconcilingConsumption}
|
||||
reconcileMessage={reconcileMessage}
|
||||
onReconcile={() => void handleConsumptionReconcile()}
|
||||
/>
|
||||
|
||||
<section className="admin-user-restriction-section">
|
||||
<div className="admin-panel-heading">
|
||||
@@ -251,7 +310,7 @@ export function AdminUserDetailDialog({
|
||||
<span>操作原因</span>
|
||||
<input
|
||||
aria-label="人工冻结操作原因"
|
||||
disabled={isSavingRestriction}
|
||||
disabled={isSavingRestriction || isReconcilingConsumption}
|
||||
value={restrictionReason}
|
||||
onChange={(event) => setRestrictionReason(event.target.value)}
|
||||
/>
|
||||
@@ -262,7 +321,11 @@ export function AdminUserDetailDialog({
|
||||
? 'admin-secondary-button'
|
||||
: 'admin-danger-button'
|
||||
}
|
||||
disabled={isSavingRestriction || !restrictionReason.trim()}
|
||||
disabled={
|
||||
isSavingRestriction ||
|
||||
isReconcilingConsumption ||
|
||||
!restrictionReason.trim()
|
||||
}
|
||||
type="button"
|
||||
onClick={() => void handleRestrictionChange()}
|
||||
>
|
||||
@@ -369,7 +432,23 @@ function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
|
||||
);
|
||||
}
|
||||
|
||||
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
|
||||
function WalletSection({
|
||||
wallet,
|
||||
historicalConsumedPoints,
|
||||
canReconcileConsumption,
|
||||
isBusy,
|
||||
isReconciling,
|
||||
reconcileMessage,
|
||||
onReconcile,
|
||||
}: {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
historicalConsumedPoints: number;
|
||||
canReconcileConsumption: boolean;
|
||||
isBusy: boolean;
|
||||
isReconciling: boolean;
|
||||
reconcileMessage: string;
|
||||
onReconcile: () => void;
|
||||
}) {
|
||||
const metrics = [
|
||||
['总余额', wallet.totalBalance],
|
||||
['可消费', wallet.spendableBalance],
|
||||
@@ -378,19 +457,41 @@ function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
|
||||
['会员限时', wallet.membershipLimitedPoints],
|
||||
['退款占用', wallet.heldPoints],
|
||||
['退款欠账', wallet.refundDebtPoints],
|
||||
['历史花费', historicalConsumedPoints],
|
||||
] as const;
|
||||
return (
|
||||
<section className="admin-user-wallet-section">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>钱包</h3>
|
||||
<div className="admin-tag-list">
|
||||
{wallet.manualFrozen ? <span className="admin-tag">人工冻结</span> : null}
|
||||
{wallet.refundDebtFrozen ? (
|
||||
<span className="admin-tag">退款欠账限制</span>
|
||||
<div className="admin-detail-actions">
|
||||
{canReconcileConsumption ? (
|
||||
<button
|
||||
aria-label="手动对账历史花费"
|
||||
className="admin-ghost-button admin-user-wallet-reconcile-button"
|
||||
disabled={isBusy}
|
||||
type="button"
|
||||
onClick={onReconcile}
|
||||
>
|
||||
<RefreshCcw size={15} aria-hidden="true" />
|
||||
<span>{isReconciling ? '对账中' : '手动对账'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok">正常</span> : null}
|
||||
<div className="admin-tag-list">
|
||||
{wallet.manualFrozen ? <span className="admin-tag">人工冻结</span> : null}
|
||||
{wallet.refundDebtFrozen ? (
|
||||
<span className="admin-tag">退款欠账限制</span>
|
||||
) : null}
|
||||
{!wallet.walletFrozen ? (
|
||||
<span className="admin-status admin-status-ok">正常</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{reconcileMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{reconcileMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-user-wallet-grid">
|
||||
{metrics.map(([label, value]) => (
|
||||
<div className="admin-recharge-metric" key={label}>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface AdminAccountsPageProps {
|
||||
}
|
||||
|
||||
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
|
||||
const consumptionReconcilePermission = 'profile-wallet-consumption-reconcile';
|
||||
|
||||
export function AdminAccountsPage({
|
||||
token,
|
||||
@@ -29,6 +30,7 @@ export function AdminAccountsPage({
|
||||
const [password, setPassword] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
|
||||
const [actionPermissions, setActionPermissions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
@@ -65,6 +67,7 @@ export function AdminAccountsPage({
|
||||
setPassword('');
|
||||
setEnabled(true);
|
||||
setTabPermissions([]);
|
||||
setActionPermissions([]);
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
@@ -75,6 +78,7 @@ export function AdminAccountsPage({
|
||||
setPassword('');
|
||||
setEnabled(account.enabled);
|
||||
setTabPermissions(account.tabPermissions);
|
||||
setActionPermissions(account.actionPermissions ?? []);
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
@@ -127,6 +131,7 @@ export function AdminAccountsPage({
|
||||
displayName: normalizedDisplayName,
|
||||
...(password ? {password} : {}),
|
||||
tabPermissions,
|
||||
actionPermissions,
|
||||
enabled,
|
||||
})
|
||||
: await createAdminAccount(token, {
|
||||
@@ -134,6 +139,7 @@ export function AdminAccountsPage({
|
||||
displayName: normalizedDisplayName,
|
||||
password,
|
||||
tabPermissions,
|
||||
actionPermissions,
|
||||
enabled,
|
||||
});
|
||||
setAccounts((current) => {
|
||||
@@ -289,6 +295,28 @@ export function AdminAccountsPage({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-permission-fieldset">
|
||||
<legend>独立操作权限</legend>
|
||||
<div className="admin-permission-grid">
|
||||
<label>
|
||||
<input
|
||||
checked={actionPermissions.includes(
|
||||
consumptionReconcilePermission,
|
||||
)}
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
setActionPermissions(
|
||||
event.target.checked
|
||||
? [consumptionReconcilePermission]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>手动对账用户历史花费</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {getProfileWalletConfig, upsertProfileWalletConfig} from '../api/adminApiClient';
|
||||
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
|
||||
import {AdminProfileWalletConfigPage} from './AdminProfileWalletConfigPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败'),
|
||||
getProfileWalletConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertProfileWalletConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const configResponse: ProfileWalletConfigAdminResponse = {
|
||||
configId: 'profile_wallet', initialMudPoints: 100, dailyFreePointsPerDay: 20,
|
||||
createdBy: 'owner-1', createdByDisplayName: '管理员',
|
||||
createdAt: '2026-07-31T01:00:00Z', updatedBy: 'owner-1',
|
||||
updatedByDisplayName: '管理员', updatedAt: '2026-07-31T01:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getProfileWalletConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({...configResponse, initialMudPoints: 120, dailyFreePointsPerDay: 35});
|
||||
});
|
||||
|
||||
test('账号配置页加载并展示每日免费泥点', async () => {
|
||||
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
|
||||
expect((await screen.findByLabelText('每日免费泥点数') as HTMLInputElement).value).toBe('20');
|
||||
expect(getProfileWalletConfig).toHaveBeenCalledWith('admin-token');
|
||||
expect(screen.getByText('每日免费泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('账号配置页一次保存初始和每日免费泥点', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResultChange = vi.fn();
|
||||
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={onResultChange} />);
|
||||
await screen.findByLabelText('每日免费泥点数');
|
||||
fireEvent.change(screen.getByLabelText('账号初始泥点数'), {target: {value: '120'}});
|
||||
fireEvent.change(screen.getByLabelText('每日免费泥点数'), {target: {value: '35'}});
|
||||
await user.click(screen.getByRole('button', {name: '保存'}));
|
||||
expect(screen.getByText('初始 120 泥点,每日免费 35 泥点')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', {name: '确认'}));
|
||||
await waitFor(() => expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {initialMudPoints: 120, dailyFreePointsPerDay: 35}));
|
||||
expect(onResultChange).toHaveBeenLastCalledWith(expect.objectContaining({initialMudPoints: 120, dailyFreePointsPerDay: 35}));
|
||||
});
|
||||
|
||||
test('账号配置页拒绝非正整数每日免费额度', async () => {
|
||||
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
|
||||
const input = await screen.findByLabelText('每日免费泥点数');
|
||||
fireEvent.change(input, {target: {value: '1.5'}});
|
||||
expect((screen.getByRole('button', {name: '保存'}) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export function AdminProfileWalletConfigPage({
|
||||
onResultChange,
|
||||
}: AdminProfileWalletConfigPageProps) {
|
||||
const [initialMudPoints, setInitialMudPoints] = useState('100');
|
||||
const [dailyFreePointsPerDay, setDailyFreePointsPerDay] = useState('20');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [loadErrorMessage, setLoadErrorMessage] = useState('');
|
||||
@@ -41,6 +42,7 @@ export function AdminProfileWalletConfigPage({
|
||||
const response = await getProfileWalletConfig(token);
|
||||
onResultChange(response);
|
||||
setInitialMudPoints(String(response.initialMudPoints));
|
||||
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setLoadErrorMessage);
|
||||
} finally {
|
||||
@@ -53,6 +55,13 @@ export function AdminProfileWalletConfigPage({
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
|
||||
dailyFreePointsPerDay,
|
||||
);
|
||||
if (!normalizedDailyFreePointsPerDay) {
|
||||
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints);
|
||||
if (!normalizedInitialMudPoints) {
|
||||
@@ -63,7 +72,7 @@ export function AdminProfileWalletConfigPage({
|
||||
setErrorMessage('');
|
||||
const confirmed = await confirmWrite({
|
||||
action: '保存账号配置',
|
||||
target: `${normalizedInitialMudPoints}泥点`,
|
||||
target: `初始 ${normalizedInitialMudPoints} 泥点,每日免费 ${normalizedDailyFreePointsPerDay} 泥点`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
@@ -73,9 +82,11 @@ export function AdminProfileWalletConfigPage({
|
||||
try {
|
||||
const response = await upsertProfileWalletConfig(token, {
|
||||
initialMudPoints: normalizedInitialMudPoints,
|
||||
dailyFreePointsPerDay: normalizedDailyFreePointsPerDay,
|
||||
});
|
||||
onResultChange(response);
|
||||
setInitialMudPoints(String(response.initialMudPoints));
|
||||
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -120,6 +131,17 @@ export function AdminProfileWalletConfigPage({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>每日免费泥点数</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={dailyFreePointsPerDay}
|
||||
onChange={(event) => setDailyFreePointsPerDay(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
@@ -128,7 +150,11 @@ export function AdminProfileWalletConfigPage({
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving || !parsePositiveInteger(initialMudPoints)}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!parsePositiveInteger(initialMudPoints) ||
|
||||
!parsePositiveInteger(dailyFreePointsPerDay)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
@@ -147,6 +173,10 @@ export function AdminProfileWalletConfigPage({
|
||||
<dt>初始泥点</dt>
|
||||
<dd>{result.initialMudPoints}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>每日免费泥点</dt>
|
||||
<dd>{result.dailyFreePointsPerDay}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新人</dt>
|
||||
<dd>{result.updatedByDisplayName || '-'}</dd>
|
||||
@@ -169,6 +199,6 @@ export function AdminProfileWalletConfigPage({
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -1270,6 +1270,13 @@ button:disabled {
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.admin-ghost-button.admin-user-wallet-reconcile-button {
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
padding: 0 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-ghost-button.admin-query-reset-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"llm": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"model": "gpt-4.1",
|
||||
"apiKind": "openai_responses",
|
||||
"reasoningEffort": "high",
|
||||
"stream": false,
|
||||
"webSearchEnabled": false,
|
||||
"contextWindowTokens": 128000,
|
||||
"autoCompactTokenLimit": 64000,
|
||||
"toolOutputTokenLimit": 12000,
|
||||
"requestTimeoutMs": 180000,
|
||||
"maxRetries": 0,
|
||||
"retryBackoffMs": 500
|
||||
},
|
||||
"agentLlm": {},
|
||||
"editorApi": {
|
||||
"baseUrl": "http://127.0.0.1:8082",
|
||||
"apiKey": ""
|
||||
},
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>AI 游戏创作</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4771
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm --prefix ../.. exec tauri -- dev",
|
||||
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
|
||||
"dev-server": "node scripts/start-dev-server.mjs",
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "npm --prefix ../.. exec tauri -- build",
|
||||
"build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
|
||||
"swarm": "node scripts/run-cli-with-config.mjs --swarm-chat",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
|
||||
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
|
||||
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
|
||||
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
|
||||
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
|
||||
"agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery",
|
||||
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
|
||||
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
|
||||
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
|
||||
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
|
||||
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
|
||||
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
|
||||
"agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry",
|
||||
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/react": "^0.47.0",
|
||||
"@lexical/utils": "^0.47.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lexical": "^0.47.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
import './agent-runtime-real-e2e/entry.mjs';
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createHash } from '../dependencies.mjs';
|
||||
import { isPlainObject } from '../harness/config.mjs';
|
||||
import {
|
||||
interactiveCliOutput,
|
||||
writeInteractiveCliLine,
|
||||
} from '../harness/process.mjs';
|
||||
import {
|
||||
shutdownWaiters,
|
||||
state,
|
||||
userInputAnswerText,
|
||||
} from '../runtime-state.mjs';
|
||||
import { disposableProjectPathVariants } from './runtime.mjs';
|
||||
|
||||
export async function answerRemainingInteractiveQuestions(session) {
|
||||
let answeredPromptCount = 1;
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
const output = interactiveCliOutput(session);
|
||||
if (output.includes(`[\u5df2\u56de\u7b54] ${state.userInput.requestId}`))
|
||||
return;
|
||||
const promptCount = output.split('或直接输入其他答案:').length - 1;
|
||||
while (answeredPromptCount < promptCount && answeredPromptCount < 3) {
|
||||
writeInteractiveCliLine(session, userInputAnswerText);
|
||||
answeredPromptCount += 1;
|
||||
}
|
||||
if (output.includes('[待确认]')) {
|
||||
throw codedError('user-input-unexpected-tool-confirmation');
|
||||
}
|
||||
if (session.closed) throw codedError('user-input-cli-closed-before-answer');
|
||||
await sleep(100);
|
||||
}
|
||||
throw codedError('user-input-answer-timeout');
|
||||
}
|
||||
|
||||
export function parseSingleSwarmTurnReport(output, codePrefix) {
|
||||
const reportLines = output
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.startsWith('[turn.report] '));
|
||||
assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`);
|
||||
const report = JSON.parse(reportLines[0].slice('[turn.report] '.length));
|
||||
assert(
|
||||
isPlainObject(report) &&
|
||||
JSON.stringify(Object.keys(report).sort()) ===
|
||||
JSON.stringify(
|
||||
[
|
||||
'schemaVersion',
|
||||
'outcome',
|
||||
'parentAgentId',
|
||||
'sessionId',
|
||||
'parentRunId',
|
||||
'runtimeCount',
|
||||
'busyRuntimeCount',
|
||||
'pendingTaskCount',
|
||||
'runningTaskCount',
|
||||
'waitingForConfirmationCount',
|
||||
'waitingForUserInputCount',
|
||||
'newAssistantMessageCount',
|
||||
'finalReplyChars',
|
||||
'reconciliationAgentCount',
|
||||
].sort(),
|
||||
),
|
||||
`${codePrefix}-turn-report-shape-invalid`,
|
||||
);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function isFailedTask(task) {
|
||||
return (
|
||||
['failed', 'cancelled', 'budget-exhausted'].includes(task.status) ||
|
||||
['failed', 'cancelled', 'budget-exhausted'].includes(task.phase)
|
||||
);
|
||||
}
|
||||
|
||||
export function prerequisiteLabel(name) {
|
||||
return {
|
||||
llmConfigured: 'LLM',
|
||||
chromeAvailable: 'Chrome/Chromium/Edge',
|
||||
editorApiConfigured: 'editorApi',
|
||||
}[name];
|
||||
}
|
||||
|
||||
export function recordError(code, error) {
|
||||
const detail =
|
||||
error instanceof Error
|
||||
? `${error.name}:${error.message}`
|
||||
: String(error ?? code);
|
||||
state.errors.push({
|
||||
code,
|
||||
detailHash: hashValue(redactSecrets(detail)),
|
||||
safeFailureDiagnostic: error?.safeFailureDiagnostic ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizeRecordedError(error) {
|
||||
const diagnostic = error.safeFailureDiagnostic;
|
||||
return {
|
||||
code: error.code,
|
||||
detailHash: error.detailHash,
|
||||
...(diagnostic
|
||||
? {
|
||||
failureKind: diagnostic.failureKind,
|
||||
exitCode: diagnostic.exitCode,
|
||||
signal: diagnostic.signal,
|
||||
processErrorCode: diagnostic.processErrorCode,
|
||||
stderrChars: diagnostic.stderrChars,
|
||||
stderrSha256: diagnostic.stderrSha256,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function redactSecrets(value) {
|
||||
let result = value;
|
||||
for (const secret of state.secrets)
|
||||
result = result.split(secret).join('[REDACTED]');
|
||||
if (state.options?.configDir)
|
||||
result = result.split(state.options.configDir).join('[CONFIG_DIR]');
|
||||
for (const projectPath of disposableProjectPathVariants()) {
|
||||
result = result.split(projectPath).join('[PROJECT]');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function hashValue(value) {
|
||||
if (!value) return null;
|
||||
return createHash('sha256')
|
||||
.update(Buffer.isBuffer(value) ? value : String(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function canonicalJsonValue(value) {
|
||||
if (Array.isArray(value)) return value.map(canonicalJsonValue);
|
||||
if (!isPlainObject(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJsonValue(value[key])]),
|
||||
);
|
||||
}
|
||||
|
||||
export function hashJsonValue(value) {
|
||||
return hashValue(JSON.stringify(canonicalJsonValue(value)));
|
||||
}
|
||||
|
||||
export function codedError(code, cause) {
|
||||
const error = new Error(code, cause ? { cause } : undefined);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function assert(condition, code) {
|
||||
if (!condition) throw codedError(code);
|
||||
}
|
||||
|
||||
export function throwIfShutdownRequested() {
|
||||
if (state.shutdownSignal && !state.cleanupInProgress) {
|
||||
throw codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(milliseconds) {
|
||||
throwIfShutdownRequested();
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = () => {
|
||||
shutdownWaiters.delete(interrupt);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, milliseconds);
|
||||
const interrupt = () => {
|
||||
clearTimeout(timer);
|
||||
shutdownWaiters.delete(interrupt);
|
||||
reject(codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`));
|
||||
};
|
||||
shutdownWaiters.add(interrupt);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
export { startLlmTransientFaultProxy } from '../llm-transient-fault-proxy.mjs';
|
||||
export { withLoopbackNoProxy } from '../llm-transient-fault-proxy.mjs';
|
||||
export { buildProcessSessionFixtureSource } from '../process-session-real-e2e-fixture.mjs';
|
||||
export { spawn } from 'node:child_process';
|
||||
export { createHash } from 'node:crypto';
|
||||
export { randomUUID } from 'node:crypto';
|
||||
export { constants as fsConstants } from 'node:fs';
|
||||
export { createReadStream } from 'node:fs';
|
||||
export { readFileSync } from 'node:fs';
|
||||
export { watch as watchFileSystem } from 'node:fs';
|
||||
export { default as fs } from 'node:fs/promises';
|
||||
export { default as os } from 'node:os';
|
||||
export { default as path } from 'node:path';
|
||||
export { fileURLToPath } from 'node:url';
|
||||
export { TextDecoder } from 'node:util';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user