Compare commits
64 Commits
e226c39b2c
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
| ea0b67a951 | |||
| 54968701f0 | |||
| 7cea41c911 | |||
| 928acb4302 | |||
| fda996031f | |||
| 10ed4fa051 | |||
| ac2cf78ffa | |||
| 5edfb756c7 | |||
| 0c9254502c | |||
| 0461c0ee41 | |||
| 81f57ea5ce | |||
| d23cf3807d | |||
| 6c1579a786 | |||
| 793d82cccd | |||
| 5cc8293380 | |||
| 85ed8ca90c | |||
| d0a9348e72 | |||
| 192accd796 | |||
| 54c2d6de47 | |||
| 7f2461313e | |||
| f74717c415 | |||
| 75bca28191 | |||
| 46a254f142 | |||
| 86fc382413 | |||
| 643161a168 | |||
| d6219f1a0c | |||
| 35d63f5b2e | |||
| 1c16152708 | |||
| f6084d0910 | |||
| 6ed6859855 | |||
|
|
9b39a52049 | ||
|
|
fc54bff62f | ||
| 1767bed609 | |||
| dada5a4797 | |||
| 7e608d4230 | |||
| 3ad1075227 | |||
| 32a1530ab1 | |||
| 7c8aa1e124 | |||
| 641d91cf11 | |||
| 052dbc248b | |||
| bc704d0c22 | |||
| a0ed128bde | |||
| 80a4183b45 | |||
| 8669a996ca | |||
| 9ca66715a4 | |||
| e390b72a0c | |||
| cf9fb5ac40 | |||
| a1e5c2150c | |||
| 23ba2703b4 | |||
| 96df12cd15 | |||
| 65c2b8cd79 | |||
| 199b44c18c | |||
| e410f7974e | |||
| 94975e4735 | |||
| b2ac92e0fc | |||
| edcdc01e43 | |||
| 621bf6506c | |||
| 26a3c89d1d | |||
| 7e35231dfe | |||
| 3efc646868 | |||
| 747ef790ac | |||
| b995809f75 | |||
| 72fce47187 | |||
| abf1f1ebea |
1
.codex/skills/behavior-driven-development
Symbolic link
1
.codex/skills/behavior-driven-development
Symbolic link
@@ -0,0 +1 @@
|
||||
C:/proj/Genarrative/.hermes/skills/behavior-driven-development
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's APIMart OpenAI-compatible gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug APIMART_BASE_URL / APIMART_API_KEY image-generation configuration without exposing secrets.
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow. Use when Codex needs to create puzzle template sample images, reproduce the server-rs gpt-image-2 request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
---
|
||||
|
||||
# gpt-image-2 APIMart
|
||||
# gpt-image-2 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` APIMart `gpt-image-2` path.
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -24,15 +24,15 @@ Use this skill for project-local image asset generation that must match the repo
|
||||
```
|
||||
|
||||
5. Save final project assets under `public/` or another explicitly requested workspace path.
|
||||
6. Never print `APIMART_API_KEY`. Report only whether configuration exists.
|
||||
6. Never print `VECTOR_ENGINE_API_KEY`. Report only whether configuration exists.
|
||||
|
||||
## Request Contract
|
||||
|
||||
The repository image path uses:
|
||||
|
||||
```text
|
||||
POST {APIMART_BASE_URL}/images/generations
|
||||
Authorization: Bearer {APIMART_API_KEY}
|
||||
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
@@ -40,10 +40,10 @@ Default body:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2",
|
||||
"model": "gpt-image-2-all",
|
||||
"prompt": "<prompt>",
|
||||
"n": 1,
|
||||
"size": "1:1"
|
||||
"size": "1024x1024"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -51,17 +51,11 @@ For a reference image, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"image_urls": ["data:image/png;base64,..."]
|
||||
"image": ["data:image/png;base64,..."]
|
||||
}
|
||||
```
|
||||
|
||||
Poll async responses with:
|
||||
|
||||
```text
|
||||
GET {APIMART_BASE_URL}/tasks/{task_id}
|
||||
```
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, direct nested `url` fields, or async task results.
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -69,12 +63,12 @@ Load environment values from process env first, then `.env.secrets.local`, `.env
|
||||
|
||||
Required for live generation:
|
||||
|
||||
- `APIMART_BASE_URL`
|
||||
- `APIMART_API_KEY`
|
||||
- `VECTOR_ENGINE_BASE_URL`
|
||||
- `VECTOR_ENGINE_API_KEY`
|
||||
|
||||
Optional:
|
||||
|
||||
- `APIMART_IMAGE_REQUEST_TIMEOUT_MS`
|
||||
- `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`
|
||||
|
||||
If the key or base URL is missing, stop after dry-run or explain the missing configuration. Do not ask the user to paste the key in chat.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface:
|
||||
display_name: "GPT Image 2 APIMart"
|
||||
short_description: "Generate project thumbnails through APIMart"
|
||||
display_name: "GPT Image 2 VectorEngine"
|
||||
short_description: "Generate project thumbnails through VectorEngine"
|
||||
brand_color: "#10B981"
|
||||
default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through APIMart."
|
||||
default_prompt: "Use $gpt-image-2-apimart to dry-run or generate puzzle template thumbnails through VectorEngine."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -14,7 +14,6 @@ const promptsPath = path.join(
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 180000;
|
||||
const pollDelayMs = 3000;
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
@@ -66,15 +65,23 @@ function resolveEnv() {
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.APIMART_BASE_URL || '').trim().replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.APIMART_API_KEY || '').trim(),
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.APIMART_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||
return baseUrl.endsWith('/v1')
|
||||
? `${baseUrl}/images/generations`
|
||||
: `${baseUrl}/v1/images/generations`;
|
||||
}
|
||||
|
||||
function buildPrompt(template) {
|
||||
return [
|
||||
'请生成一张高清 1:1 方形插画,用作拼图创作模板样例图。',
|
||||
@@ -124,14 +131,6 @@ function extractBase64Images(payload) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function extractTaskId(payload) {
|
||||
const ids = [];
|
||||
collectStringsByKey(payload, 'task_id', ids);
|
||||
collectStringsByKey(payload, 'taskId', ids);
|
||||
collectStringsByKey(payload, 'id', ids);
|
||||
return ids[0] || null;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
@@ -172,9 +171,14 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`APIMart ${response.status}: ${text.slice(0, 600)}`);
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -195,56 +199,30 @@ async function downloadUrl(url, timeoutMs) {
|
||||
response.headers.get('content-type') || 'image/jpeg',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTask(env, taskId) {
|
||||
const deadline = Date.now() + env.timeoutMs;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const payload = await fetchJson(
|
||||
`${env.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
},
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
const statuses = [];
|
||||
collectStringsByKey(payload, 'status', statuses);
|
||||
collectStringsByKey(payload, 'task_status', statuses);
|
||||
const status = String(statuses[0] || '').trim().toLowerCase();
|
||||
|
||||
if (['completed', 'succeeded', 'success'].includes(status)) {
|
||||
return payload;
|
||||
}
|
||||
if (['failed', 'error', 'canceled', 'cancelled', 'unknown'].includes(status)) {
|
||||
throw new Error(`APIMart task ${taskId} failed: ${JSON.stringify(payload).slice(0, 600)}`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
|
||||
}
|
||||
|
||||
throw new Error(`APIMart task ${taskId} timed out`);
|
||||
}
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1:1',
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
`${env.baseUrl}/images/generations`,
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -252,12 +230,8 @@ async function generateOne(env, template, outDir) {
|
||||
env.timeoutMs,
|
||||
);
|
||||
|
||||
const resolvedPayload =
|
||||
extractImageUrls(payload).length || extractBase64Images(payload).length
|
||||
? payload
|
||||
: await waitForTask(env, extractTaskId(payload));
|
||||
const urls = extractImageUrls(resolvedPayload);
|
||||
const b64Images = extractBase64Images(resolvedPayload);
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
|
||||
let image;
|
||||
if (urls[0]) {
|
||||
@@ -269,7 +243,7 @@ async function generateOne(env, template, outDir) {
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`APIMart returned no image for ${template.id}`);
|
||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
@@ -301,10 +275,10 @@ if (dryRun) {
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1:1',
|
||||
size: '1024x1024',
|
||||
},
|
||||
})),
|
||||
},
|
||||
@@ -320,7 +294,7 @@ if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing APIMART_BASE_URL or APIMART_API_KEY',
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
|
||||
@@ -173,6 +173,10 @@ VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS="150000"
|
||||
# Keep this off by default for cleaner logs.
|
||||
VITE_LLM_DEBUG_LOG="false"
|
||||
|
||||
# Optional: global frontend debug mode. When empty, it follows Vite dev mode.
|
||||
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
|
||||
VITE_DEBUG_MODE=""
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -60,8 +60,9 @@ ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f"
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
|
||||
|
||||
# Local Rust backend target for Vite dev proxy.
|
||||
RUST_SERVER_TARGET="http://127.0.0.1:3100"
|
||||
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
||||
RUST_SERVER_TARGET="http://127.0.0.1:8082"
|
||||
GENARRATIVE_API_TARGET="http://127.0.0.1:8082"
|
||||
GENARRATIVE_API_PORT="8082"
|
||||
|
||||
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
|
||||
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
|
||||
@@ -70,4 +71,4 @@ GENARRATIVE_SPACETIME_TOKEN=""
|
||||
# admin
|
||||
GENARRATIVE_ADMIN_USERNAME=admin
|
||||
GENARRATIVE_ADMIN_PASSWORD=123456
|
||||
ADMIN_API_TARGET=http://127.0.0.1:8082
|
||||
ADMIN_API_TARGET=http://127.0.0.1:3100
|
||||
|
||||
@@ -24,9 +24,47 @@
|
||||
│ ├─ pitfalls.md # 踩坑与排障记录
|
||||
│ └─ handoff-template.md # 任务交接模板
|
||||
├─ plans/ # 阶段性计划与实施方案
|
||||
└─ skills/ # 未来可沉淀的仓库级 Hermes skills
|
||||
├─ skills/ # 仓库级 Hermes skills
|
||||
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
||||
```
|
||||
|
||||
## 仓库级 Plugins
|
||||
|
||||
本仓库可共享的 Hermes plugin 放在 `.hermes/plugins/<plugin-name>/`。当前已包含:
|
||||
|
||||
- `.hermes/plugins/game-studio/`:浏览器游戏设计、原型、2D/3D 技术栈、素材管线与 playtest 相关工作流。
|
||||
|
||||
Hermes 的项目级 plugin 默认不会自动加载。团队成员拉取仓库后,如需使用本仓库内 plugin,请在仓库根目录启动 Hermes 前设置:
|
||||
|
||||
```bash
|
||||
export HERMES_ENABLE_PROJECT_PLUGINS=1
|
||||
```
|
||||
|
||||
然后确认当前 Hermes 配置的 `plugins.enabled` 中包含 `game-studio`。如果成员本机尚未启用过该 plugin,当前 Hermes 的 `hermes plugins enable` 只识别用户级或内置 plugin,可能不会识别项目级 plugin;可用以下命令写入个人配置:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
from hermes_cli.config import load_config, save_config
|
||||
config = load_config()
|
||||
plugins = config.setdefault('plugins', {})
|
||||
enabled = set(plugins.get('enabled') or [])
|
||||
disabled = set(plugins.get('disabled') or [])
|
||||
enabled.add('game-studio')
|
||||
disabled.discard('game-studio')
|
||||
plugins['enabled'] = sorted(enabled)
|
||||
plugins['disabled'] = sorted(disabled)
|
||||
save_config(config)
|
||||
PY
|
||||
```
|
||||
|
||||
启用后重新进入一个新 Hermes 会话。`hermes plugins list` 当前主要展示内置和用户级 plugin,未必列出项目级 plugin;如需验证项目级扫描,可在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 game-studio:game-studio skill 并概括它的用途"
|
||||
```
|
||||
|
||||
该 plugin 注册的是带命名空间的 plugin skills,可用类似 `game-studio:phaser-2d-game` 的名称显式加载。
|
||||
|
||||
## 推荐给 Hermes 的启动提示
|
||||
|
||||
在本仓库中开始复杂任务时,可以先对 Hermes 说:
|
||||
@@ -51,3 +89,4 @@
|
||||
- 大段临时聊天记录
|
||||
- 尚未确认的一次性猜测
|
||||
- 构建产物、日志、缓存、数据库 dump
|
||||
|
||||
|
||||
403
.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md
Normal file
403
.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# 当前项目安全漏洞检查计划
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill only if the user later asks to execute this plan. 本计划当前仅用于规划,不实施代码修改。
|
||||
|
||||
**Goal:** 对 Genarrative 当前工作区做一次可复现的安全漏洞基线检查,覆盖依赖漏洞、密钥泄露、常见高风险代码模式、后端 Rust crate 风险和前端/Node 供应链风险,并输出可落地的整改清单。
|
||||
|
||||
**Architecture:** 采用“只读扫描 → 结果归档 → 人工分级 → 最小修复建议”的方式推进。先不直接升级依赖或改代码,避免安全扫描引入不可控 breaking change;执行阶段只在用户确认后运行扫描命令,并把报告保存到 `docs/audits/` 或 `.hermes/plans/` 附件中。
|
||||
|
||||
**Tech Stack:** Node/Vite/React/TypeScript、Rust workspace/Axum/SpacetimeDB、npm lockfile、Cargo.lock、Git worktree。
|
||||
|
||||
---
|
||||
|
||||
## 当前上下文 / 假设
|
||||
|
||||
- 当前有效工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`。
|
||||
- 本次用户以 `/plan` 模式要求“检查一下当前项目的安全漏洞”,因此本轮只制定计划,不执行会产生报告、安装工具、修改依赖、提交或推送的操作。
|
||||
- 已确认项目包含:
|
||||
- 根 `package.json`,脚本包括 `npm run lint`、`npm run test`、`npm run build`、`npm run check:encoding`。
|
||||
- 根 `package-lock.json`。
|
||||
- `server-rs/Cargo.toml` 和 `server-rs/Cargo.lock`。
|
||||
- `apps/admin-web/package.json`、`packages/shared/package.json`。
|
||||
- `.hermes/shared-memory/development-workflow.md` 要求开发前读取共享记忆,并以当前代码、`docs/`、`AGENTS.md` 为准。
|
||||
- 安全扫描不应把真实密钥写入仓库;发现疑似密钥时只记录文件位置、变量名、脱敏片段和处置建议。
|
||||
|
||||
## 总体策略
|
||||
|
||||
1. 先做仓库状态和范围确认,避免扫描其他 worktree 或错误路径。
|
||||
2. 优先运行不会修改文件的安全检查:`npm audit --json`、`cargo audit`、密钥扫描、危险代码模式扫描。
|
||||
3. 分前端供应链、后端供应链、源码安全、配置/脚本安全四类归档。
|
||||
4. 对结果按严重级别分层:Critical / High / Medium / Low / Informational。
|
||||
5. 对每个真实问题给出:影响范围、证据、可行修复、验证命令、是否需要业务回归。
|
||||
6. 只有在用户确认进入执行/修复阶段后,才做依赖升级、代码修复、文档更新、测试和提交。
|
||||
|
||||
---
|
||||
|
||||
## Step-by-step Plan
|
||||
|
||||
### Task 1: 确认扫描工作区和基线状态
|
||||
|
||||
**Objective:** 确保后续扫描针对当前 worktree,且不会误把既有未提交变更当成安全修复结果。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `AGENTS.md`
|
||||
- Read-only: `.hermes/README.md`
|
||||
- Read-only: `.hermes/shared-memory/development-workflow.md`
|
||||
- Read-only: `package.json`
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git status --short
|
||||
git branch --show-current
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- `pwd` / `git rev-parse --show-toplevel` 指向 `C:/proj/Genarrative/.worktrees/hermes-3337436a` 对应路径。
|
||||
- 分支为当前隔离 worktree 分支。
|
||||
- 记录是否已有未提交变更;如存在,扫描报告需标注“基于含未提交变更的工作区”。
|
||||
|
||||
**Validation:**
|
||||
- 不修改任何项目文件。
|
||||
- 如发现路径不是当前 worktree,停止并重新确认路径。
|
||||
|
||||
### Task 2: 生成依赖清单和锁文件基线
|
||||
|
||||
**Objective:** 明确 Node 与 Rust 依赖入口,避免漏扫子包或 admin web。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `package.json`
|
||||
- Read-only: `package-lock.json`
|
||||
- Read-only: `apps/admin-web/package.json`
|
||||
- Read-only: `packages/shared/package.json`
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
- Read-only: `server-rs/Cargo.lock`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
npm --version
|
||||
node --version
|
||||
cargo --version
|
||||
rustc --version
|
||||
```
|
||||
|
||||
可选只读清单:
|
||||
|
||||
```bash
|
||||
npm ls --all --json > /tmp/genarrative-npm-ls.json
|
||||
cargo metadata --manifest-path server-rs/Cargo.toml --format-version 1 > /tmp/genarrative-cargo-metadata.json
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 明确 npm / Node / Rust / Cargo 版本。
|
||||
- 若 `npm ls` 因 peer dependency 或历史依赖问题非 0,保留输出并继续 audit。
|
||||
|
||||
**Validation:**
|
||||
- `/tmp` 输出不进入 Git。
|
||||
- 不运行 `npm install`、`npm update`、`cargo update`。
|
||||
|
||||
### Task 3: Node 供应链漏洞扫描
|
||||
|
||||
**Objective:** 检查根 lockfile 覆盖的前端、脚本和 admin web 依赖漏洞。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `package-lock.json`
|
||||
- Read-only: `package.json`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
npm audit --json > /tmp/genarrative-npm-audit.json
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- `npm audit --json` 生成机器可读结果。
|
||||
- 第二条命令给出人类可读摘要;如返回非 0,按漏洞严重度记录,不直接执行 `npm audit fix`。
|
||||
|
||||
**Result fields to extract:**
|
||||
- package name
|
||||
- vulnerable versions
|
||||
- installed version
|
||||
- severity
|
||||
- CVE / GHSA
|
||||
- via chain
|
||||
- fixAvailable 是否为 major/breaking
|
||||
- affected direct dependency or transitive dependency
|
||||
|
||||
**Validation:**
|
||||
- 不执行 `npm audit fix`。
|
||||
- 如 npm registry 网络不可用,记录阻塞原因和可重试命令。
|
||||
|
||||
### Task 4: Rust 供应链漏洞扫描
|
||||
|
||||
**Objective:** 检查 `server-rs` workspace 的 Cargo 依赖漏洞、弃用 crate 和 yanked crate。
|
||||
|
||||
**Files:**
|
||||
- Read-only: `server-rs/Cargo.toml`
|
||||
- Read-only: `server-rs/Cargo.lock`
|
||||
|
||||
**Commands:**
|
||||
|
||||
优先:
|
||||
|
||||
```bash
|
||||
cargo audit --json --manifest-path server-rs/Cargo.toml > /tmp/genarrative-cargo-audit.json
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
如果本机没有 `cargo audit`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
**Execution note:**
|
||||
- 安装 `cargo-audit` 会改变用户 Cargo 工具目录,不属于纯只读扫描;执行前需用户确认。
|
||||
- 如果用户不希望安装工具,则记录“Rust 漏洞扫描未完成”,并给出本地安装或 CI 执行建议。
|
||||
|
||||
**Result fields to extract:**
|
||||
- advisory id
|
||||
- package
|
||||
- version
|
||||
- patched versions
|
||||
- unaffected versions
|
||||
- severity / CVSS if available
|
||||
- dependency path
|
||||
- whether it is runtime reachable in `api-server` / `spacetime-module`
|
||||
|
||||
**Validation:**
|
||||
- 不运行 `cargo update`。
|
||||
- 不改 `Cargo.lock`。
|
||||
|
||||
### Task 5: 密钥和敏感配置泄露扫描
|
||||
|
||||
**Objective:** 检查仓库中是否误提交 API key、token、私钥、cookie、`.env` 类文件或个人 Hermes 配置。
|
||||
|
||||
**Files / paths to scan:**
|
||||
- Full repo excluding `.git/`, `node_modules/`, `target/`, `dist/`, build artifacts。
|
||||
- 特别关注:`.hermes/`、`scripts/`、`server-rs/`、`apps/admin-web/`、`src/`、`docs/`。
|
||||
|
||||
**Preferred commands:**
|
||||
|
||||
如果有 gitleaks:
|
||||
|
||||
```bash
|
||||
gitleaks detect --source . --no-git --redact --report-format json --report-path /tmp/genarrative-gitleaks.json
|
||||
```
|
||||
|
||||
如果没有 gitleaks,先用只读 grep/ripgrep 兜底:
|
||||
|
||||
```bash
|
||||
git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})" > /tmp/genarrative-secret-grep.txt || true
|
||||
```
|
||||
|
||||
**Execution note:**
|
||||
- 安装 gitleaks 需要用户确认。
|
||||
- grep 结果包含 false positive,必须人工分级,不得直接当作泄露结论。
|
||||
|
||||
**Validation:**
|
||||
- 报告中对值做脱敏,只保留前后 3-4 位或完全不记录值。
|
||||
- 如果发现 `.env.local` 或真实 token 被跟踪,立即标为 Critical。
|
||||
|
||||
### Task 6: 常见源码安全模式扫描
|
||||
|
||||
**Objective:** 快速发现高风险代码模式:命令注入、动态执行、路径穿越、危险反序列化、XSS、日志泄密、宽松 CORS 等。
|
||||
|
||||
**Files / paths:**
|
||||
- `src/**/*.{ts,tsx,js,mjs,cjs}`
|
||||
- `apps/admin-web/**/*.{ts,tsx,js,mjs,cjs}`
|
||||
- `scripts/**/*.{js,mjs,cjs,ts}`
|
||||
- `server-rs/crates/**/*.rs`
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
# JS/TS 动态执行与 HTML 注入
|
||||
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
|
||||
|
||||
# Node 命令执行风险
|
||||
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
|
||||
|
||||
# Rust 命令、文件路径、unwrap 风险热点
|
||||
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
|
||||
|
||||
# 宽松 CORS / Cookie / Auth 相关热点
|
||||
rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 输出作为“热点清单”,不等同于漏洞。
|
||||
- 对 auth/session、文件上传、OSS 签名、外部 LLM/图片服务请求、SpacetimeDB 访问 facade 做人工复核。
|
||||
|
||||
**Validation:**
|
||||
- 每个疑似问题必须能说明可利用条件,无法说明则降级为 Informational。
|
||||
|
||||
### Task 7: Web/API 安全配置人工复核
|
||||
|
||||
**Objective:** 对项目特有的安全边界做代码审阅,补足工具扫描无法覆盖的业务风险。
|
||||
|
||||
**Likely files to review:**
|
||||
- `server-rs/crates/api-server/src/**`
|
||||
- `server-rs/crates/platform-auth/src/**`
|
||||
- `server-rs/crates/platform-oss/src/**`
|
||||
- `server-rs/crates/platform-llm/src/**`
|
||||
- `server-rs/crates/spacetime-client/src/**`
|
||||
- `src/services/**`
|
||||
- `apps/admin-web/src/**`
|
||||
- `scripts/*deploy*`
|
||||
- `scripts/*api-server*`
|
||||
- `.github/workflows/**` if present
|
||||
|
||||
**Checklist:**
|
||||
- Auth / session:access token 与 refresh cookie 的生命周期、SameSite/Secure/HttpOnly、错误日志是否泄露 token。
|
||||
- CORS:开发环境与生产环境是否区分,是否存在生产 `Any`。
|
||||
- SSRF / outbound:LLM、图片生成、OSS、任意 URL 下载是否校验协议和大小。
|
||||
- Upload / Data URL:大小限制、MIME 校验、base64 解析错误处理。
|
||||
- Path traversal:脚本和后端是否拼接用户输入路径。
|
||||
- Admin:后台接口是否有权限校验,是否复用普通用户 token。
|
||||
- SpacetimeDB:private table / reducer 是否绕过 api-server facade 暴露敏感数据。
|
||||
- Logging:日志是否打印 API key、token、cookie、用户私密内容。
|
||||
|
||||
**Validation:**
|
||||
- 对每个命中的真实风险,记录具体文件路径和函数名。
|
||||
- 对“需要运行环境才能验证”的风险,列出 smoke 或单测建议。
|
||||
|
||||
### Task 8: 汇总漏洞分级与整改建议
|
||||
|
||||
**Objective:** 把扫描结果转成团队可执行的安全整改报告。
|
||||
|
||||
**Deliverable candidates:**
|
||||
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
|
||||
- 或如果用户只要临时报告:`.hermes/plans/assets/security-scan-YYYY-MM-DD.md`
|
||||
|
||||
**Report structure:**
|
||||
|
||||
```markdown
|
||||
# 安全漏洞扫描报告 YYYY-MM-DD
|
||||
|
||||
## 扫描范围
|
||||
## 扫描命令与环境
|
||||
## 摘要
|
||||
## Critical
|
||||
## High
|
||||
## Medium
|
||||
## Low
|
||||
## Informational / False Positive
|
||||
## 依赖升级建议
|
||||
## 代码修复建议
|
||||
## 需要人工确认的问题
|
||||
## 验证命令
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- 报告不包含真实密钥。
|
||||
- 每条问题都有“证据、影响、建议、验证”。
|
||||
- 明确哪些是工具扫描结果,哪些是人工判断。
|
||||
|
||||
### Task 9: 如用户要求修复,再分批执行最小修复
|
||||
|
||||
**Objective:** 避免一次性大规模升级导致回归,把修复拆为可验证的小批次。
|
||||
|
||||
**Suggested order:**
|
||||
1. Critical secrets:立即移除、轮换密钥、补 `.gitignore`/文档约束(注意项目约束:不要在 `.gitignore` 中添加 `.env.local`)。
|
||||
2. Critical/High direct dependencies:优先升级 direct dependency,运行最小测试。
|
||||
3. Critical/High transitive dependencies:评估是否由 direct dependency patch/minor 升级带出。
|
||||
4. 源码漏洞:按入口编写回归测试,再修复。
|
||||
5. Medium/Low:按风险和 breaking change 代价排期。
|
||||
|
||||
**Required verification after fixes:**
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
cd server-rs && cargo test --workspace
|
||||
```
|
||||
|
||||
后端 API 或 auth 修复涉及运行态时,还需要:
|
||||
|
||||
```bash
|
||||
npm run api-server
|
||||
# 另一个终端检查 /healthz 并执行对应 smoke
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- 修复后重新跑对应 audit / secret scan。
|
||||
- 走 `requesting-code-review` 的独立安全复核流程。
|
||||
|
||||
---
|
||||
|
||||
## Files likely to change(仅修复阶段)
|
||||
|
||||
本计划阶段不修改以下文件;只有用户确认执行修复时才可能变化:
|
||||
|
||||
- `package.json`
|
||||
- `package-lock.json`
|
||||
- `apps/admin-web/package.json`
|
||||
- `server-rs/Cargo.toml`
|
||||
- `server-rs/Cargo.lock`
|
||||
- `server-rs/crates/api-server/src/**`
|
||||
- `server-rs/crates/platform-auth/src/**`
|
||||
- `server-rs/crates/platform-oss/src/**`
|
||||
- `server-rs/crates/platform-llm/src/**`
|
||||
- `src/services/**`
|
||||
- `apps/admin-web/src/**`
|
||||
- `scripts/**`
|
||||
- `docs/audits/SECURITY_VULNERABILITY_SCAN_YYYY-MM-DD.md`
|
||||
- `.hermes/shared-memory/pitfalls.md`(仅当发现长期有效、会反复踩的安全排障经验时更新)
|
||||
|
||||
## Tests / Validation
|
||||
|
||||
安全扫描执行阶段:
|
||||
|
||||
```bash
|
||||
npm audit --json > /tmp/genarrative-npm-audit.json
|
||||
npm audit --audit-level=moderate
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
|
||||
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
|
||||
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
|
||||
```
|
||||
|
||||
修复执行阶段:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
cd server-rs && cargo test --workspace
|
||||
```
|
||||
|
||||
如变更后端运行态、安全中间件、auth/session:
|
||||
|
||||
```bash
|
||||
npm run api-server
|
||||
# 检查 /healthz
|
||||
# 执行相关 auth / API smoke
|
||||
```
|
||||
|
||||
## Risks, tradeoffs, and open questions
|
||||
|
||||
- `npm audit fix` 可能升级 major version,破坏 Vite/React/ESLint/Vitest 兼容性;必须先人工审查 `fixAvailable`。
|
||||
- `cargo audit` 可能需要安装 `cargo-audit`;安装工具属于用户环境变更,应先确认。
|
||||
- 密钥扫描极易产生 false positive;必须人工复核,报告中禁止输出真实密钥。
|
||||
- Rust `unwrap/expect` 不是天然漏洞;只有对外部输入、网络、文件、数据库响应等不可信数据造成 panic/DoS 时才升级为真实风险。
|
||||
- Web 安全检查需要区分开发环境和生产环境;开发 CORS 放宽不等于生产漏洞,但生产配置必须有明确边界。
|
||||
- 如果扫描发现历史提交中曾泄露密钥,删除当前文件不够,必须轮换密钥并考虑历史清理策略。
|
||||
- 当前计划未直接访问 CI/Jenkins/生产配置;若用户希望覆盖 CI/CD、镜像、部署主机和运行时端口,需要补充 Jenkins console、部署脚本和生产环境配置的只读访问方式。
|
||||
|
||||
## Missing artifacts / follow-up checkpoints
|
||||
|
||||
- 尚未获得用户确认是否允许安装 `cargo-audit` / `gitleaks` 等工具。
|
||||
- 尚未执行真实扫描,因此当前没有漏洞结论;执行后需要生成正式报告。
|
||||
- 如果用户希望“检查当前项目”包含远端仓库历史 secrets、Docker 镜像、Jenkins 凭据和生产运行时配置,需要另行确认访问范围和凭据边界。
|
||||
46
.hermes/plugins/game-studio/.codex-plugin/plugin.json
Normal file
46
.hermes/plugins/game-studio/.codex-plugin/plugin.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "game-studio",
|
||||
"version": "0.1.0",
|
||||
"description": "Design, prototype, and ship browser games with guided 2D and 3D workflows, asset pipelines, and playtesting support.",
|
||||
"author": {
|
||||
"name": "OpenAI",
|
||||
"email": "support@openai.com",
|
||||
"url": "https://openai.com/"
|
||||
},
|
||||
"homepage": "https://openai.com/",
|
||||
"repository": "https://github.com/openai/plugins",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"games",
|
||||
"phaser",
|
||||
"threejs",
|
||||
"react-three-fiber",
|
||||
"gltf",
|
||||
"rapier",
|
||||
"webgl",
|
||||
"sprites",
|
||||
"playtest"
|
||||
],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "Game Studio",
|
||||
"shortDescription": "Design, prototype, and ship browser games",
|
||||
"longDescription": "Plan, prototype, and build browser games with guided workflows for gameplay systems, UI, asset pipelines, and playtesting across 2D and 3D projects.",
|
||||
"developerName": "OpenAI",
|
||||
"category": "Coding",
|
||||
"capabilities": [
|
||||
"Interactive",
|
||||
"Write"
|
||||
],
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/terms-of-use/",
|
||||
"defaultPrompt": [
|
||||
"Design a browser game and plan the core loop"
|
||||
],
|
||||
"brandColor": "#0F766E",
|
||||
"composerIcon": "./assets/game-studio.svg",
|
||||
"logo": "./assets/app-icon.png",
|
||||
"screenshots": []
|
||||
}
|
||||
}
|
||||
38
.hermes/plugins/game-studio/__init__.py
Normal file
38
.hermes/plugins/game-studio/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Hermes wrapper for the OpenAI Codex Game Studio plugin.
|
||||
|
||||
This plugin was imported from a Codex curated plugin cache. It exposes the
|
||||
plugin's bundled SKILL.md files as Hermes plugin skills using qualified names
|
||||
like `game-studio:phaser-2d-game`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_description(skill_md: Path) -> str:
|
||||
try:
|
||||
text = skill_md.read_text(encoding="utf-8")[:4000]
|
||||
except Exception:
|
||||
return ""
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
frontmatter = text[3:end]
|
||||
for line in frontmatter.splitlines():
|
||||
if line.strip().startswith("description:"):
|
||||
return line.split(":", 1)[1].strip().strip("\"'")
|
||||
return ""
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
root = Path(__file__).resolve().parent
|
||||
skills_root = root / "skills"
|
||||
if not skills_root.exists():
|
||||
return
|
||||
for skill_md in sorted(skills_root.glob("*/SKILL.md")):
|
||||
ctx.register_skill(
|
||||
name=skill_md.parent.name,
|
||||
path=skill_md,
|
||||
description=_read_description(skill_md),
|
||||
)
|
||||
BIN
.hermes/plugins/game-studio/assets/app-icon.png
Normal file
BIN
.hermes/plugins/game-studio/assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
25
.hermes/plugins/game-studio/assets/game-studio.svg
Normal file
25
.hermes/plugins/game-studio/assets/game-studio.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Game Studio</title>
|
||||
<desc id="desc">A stylized browser game plugin icon with a viewport frame, a d-pad, and layered tiles.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#134E4A"/>
|
||||
<stop offset="100%" stop-color="#0F766E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="screen" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#CCFBF1"/>
|
||||
<stop offset="100%" stop-color="#5EEAD4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="52" fill="url(#bg)"/>
|
||||
<rect x="40" y="46" width="176" height="124" rx="20" fill="#062F2D" stroke="#7DD3C7" stroke-width="8"/>
|
||||
<rect x="58" y="62" width="140" height="92" rx="12" fill="url(#screen)"/>
|
||||
<path d="M76 132h28l14-18 18 20 24-30 18 18v18H76z" fill="#0F766E" opacity="0.9"/>
|
||||
<rect x="62" y="178" width="50" height="50" rx="18" fill="#0B2F2D" stroke="#7DD3C7" stroke-width="6"/>
|
||||
<rect x="144" y="178" width="50" height="50" rx="18" fill="#0B2F2D" stroke="#7DD3C7" stroke-width="6"/>
|
||||
<rect x="81" y="189" width="12" height="28" rx="4" fill="#CCFBF1"/>
|
||||
<rect x="73" y="197" width="28" height="12" rx="4" fill="#CCFBF1"/>
|
||||
<circle cx="160" cy="203" r="8" fill="#FDE68A"/>
|
||||
<circle cx="178" cy="203" r="8" fill="#FCA5A5"/>
|
||||
<circle cx="169" cy="192" r="8" fill="#BFDBFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
6
.hermes/plugins/game-studio/plugin.yaml
Normal file
6
.hermes/plugins/game-studio/plugin.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
name: game-studio
|
||||
version: 0.1.0
|
||||
description: Design, prototype, and ship browser games with guided 2D and 3D workflows,
|
||||
asset pipelines, and playtesting support.
|
||||
author: OpenAI
|
||||
kind: standalone
|
||||
@@ -0,0 +1,50 @@
|
||||
# Alternative 3D Engines
|
||||
|
||||
This plugin defaults to Three.js and React Three Fiber for code generation. Babylon.js and PlayCanvas still matter, but they are reference-only alternatives in the current plugin shape.
|
||||
|
||||
## Babylon.js
|
||||
|
||||
Useful sources:
|
||||
|
||||
- [Babylon.js home](https://babylonjs.com/)
|
||||
- [Engine specifications](https://www.babylonjs.com/specifications/)
|
||||
- [Babylon.js Editor](https://editor.babylonjs.com/)
|
||||
|
||||
Choose Babylon.js when:
|
||||
|
||||
- the user explicitly wants Babylon.js
|
||||
- the team wants a more engine-heavy stack with scene, material, viewer, and editor tooling built around one ecosystem
|
||||
- WebGPU, Havok, node-based rendering or material tooling, or Babylon-specific runtime features are part of the reason for the choice
|
||||
|
||||
What Babylon.js is good at:
|
||||
|
||||
- full-engine 3D workflows
|
||||
- strong built-in tooling and editor surfaces
|
||||
- WebGL and WebGPU support inside one ecosystem
|
||||
- integrated viewer and inspection-oriented workflows
|
||||
|
||||
## PlayCanvas
|
||||
|
||||
Useful sources:
|
||||
|
||||
- [PlayCanvas graphics overview](https://developer.playcanvas.com/user-manual/graphics/)
|
||||
- [Supported formats](https://developer.playcanvas.com/user-manual/assets/supported-formats/)
|
||||
- [PlayCanvas React GLTF API](https://developer.playcanvas.com/user-manual/react/api/gltf/)
|
||||
- [PlayCanvas Web Components](https://developer.playcanvas.com/user-manual/web-components/)
|
||||
|
||||
Choose PlayCanvas when:
|
||||
|
||||
- the user explicitly wants PlayCanvas
|
||||
- the team prefers an editor-centric browser engine workflow
|
||||
- GLB import, runtime tooling, React bindings, or web-component-based embedding are central to the project
|
||||
|
||||
What PlayCanvas is good at:
|
||||
|
||||
- editor and engine working together
|
||||
- GLB-centric browser asset workflows
|
||||
- strong web embedding patterns
|
||||
- WebGL and WebGPU support with browser-focused runtime tooling
|
||||
|
||||
## Default recommendation
|
||||
|
||||
If the user has not already chosen Babylon.js or PlayCanvas, prefer Three.js or React Three Fiber in this plugin because they give the best balance of portability, ecosystem depth, and predictable code generation across normal browser-game repos.
|
||||
53
.hermes/plugins/game-studio/references/engine-selection.md
Normal file
53
.hermes/plugins/game-studio/references/engine-selection.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Engine Selection
|
||||
|
||||
Use this table to choose the default implementation path for browser games in this plugin.
|
||||
|
||||
## Default choices
|
||||
|
||||
- Choose Phaser for 2D games unless the user explicitly asks for another stack.
|
||||
- Choose vanilla Three.js for explicit 3D or WebGL-first experiences in plain TypeScript or Vite apps.
|
||||
- Choose React Three Fiber when the 3D scene is part of a React application and shared app state or declarative composition matters.
|
||||
- Choose raw WebGL only when engine abstractions are the problem, not because WebGL sounds more advanced.
|
||||
- Treat Babylon.js and PlayCanvas as alternative ecosystems, not the default path in this plugin.
|
||||
|
||||
## Phaser is the best fit when
|
||||
|
||||
- the game is sprite- or tile-based
|
||||
- the game is top-down, side-view, or grid tactics
|
||||
- you need camera, sprite, and scene primitives quickly
|
||||
- the UI will mostly live in DOM overlays
|
||||
- the game loop is gameplay-first rather than renderer-first
|
||||
|
||||
## Three.js is the best fit when
|
||||
|
||||
- the game is genuinely 3D
|
||||
- camera movement and depth are central to play
|
||||
- materials, lighting, or scene composition matter more than sprite tooling
|
||||
- the user explicitly asks for Three.js or WebGL-based 3D work
|
||||
- the team wants direct control over scene setup, loaders, physics integration, and the game loop
|
||||
|
||||
## React Three Fiber is the best fit when
|
||||
|
||||
- the project already lives in React
|
||||
- the 3D scene needs to share app state with the rest of the product
|
||||
- declarative scene composition is more valuable than a fully imperative loop
|
||||
- the team benefits from pmndrs tooling such as Drei, React Postprocessing, or `@react-three/rapier`
|
||||
|
||||
## Babylon.js or PlayCanvas are the best fit when
|
||||
|
||||
- the user explicitly asks for those engines
|
||||
- the team already has engine-specific tooling or editor workflows in those ecosystems
|
||||
- the project wants engine-heavy runtime features, editor-first workflows, or platform-specific tooling that Three.js does not provide by default
|
||||
|
||||
## Raw WebGL is the best fit when
|
||||
|
||||
- the project is shader-heavy
|
||||
- you need a custom renderer or post-processing pipeline
|
||||
- the user explicitly wants low-level rendering control
|
||||
|
||||
## Avoid these mismatches
|
||||
|
||||
- Do not choose a 3D stack for a normal 2D tactics or platformer game.
|
||||
- Do not choose raw WebGL for a game that mostly needs engine conveniences.
|
||||
- Do not force HUD and menus into canvas or WebGL when DOM would be clearer.
|
||||
- Do not default to Babylon.js or PlayCanvas when the user mainly wants portable TypeScript code generation across browser-game repos.
|
||||
97
.hermes/plugins/game-studio/references/frontend-prompts.md
Normal file
97
.hermes/plugins/game-studio/references/frontend-prompts.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Frontend Prompts
|
||||
|
||||
Use these prompt shapes to keep browser-game UI intentional instead of generic.
|
||||
|
||||
## Prompt ingredients
|
||||
|
||||
- game genre and fantasy
|
||||
- camera or viewpoint
|
||||
- player verbs
|
||||
- HUD zones
|
||||
- menu surfaces
|
||||
- motion tone
|
||||
- desktop and mobile expectations
|
||||
- playfield protection and disclosure strategy
|
||||
- anti-patterns to avoid
|
||||
|
||||
## HUD implementation prompt
|
||||
|
||||
```text
|
||||
Design and implement the HUD for a browser game.
|
||||
|
||||
Game fantasy: <genre and world>
|
||||
Viewpoint: <top-down, side-view, tactical grid, third-person, first-person>
|
||||
Primary verbs: <attack, move, cast, build, dodge, inspect>
|
||||
HUD zones: <top status, bottom command bar, side objectives, modal panels>
|
||||
Tone: <ornate, rugged, clean sci-fi, painterly, arcade>
|
||||
Motion: <restrained, snappy, dramatic only on important state changes>
|
||||
Platforms: desktop and mobile
|
||||
Constraints: readable over active gameplay, DOM-based overlays, CSS variables, no generic dashboard look
|
||||
Playfield protection: keep the central play area clear during normal play, prefer one primary persistent HUD cluster, and move long-form notes or controls behind menus
|
||||
Avoid: flat admin UI, default font stack, cluttered overlays, constant micro-animation, equal-weight cards around every edge, broad always-on panels that cover the world
|
||||
```
|
||||
|
||||
## Menu implementation prompt
|
||||
|
||||
```text
|
||||
Build the shell UI for a browser game with the following surfaces:
|
||||
- title screen
|
||||
- pause menu
|
||||
- settings panel
|
||||
- game-over or victory screen
|
||||
|
||||
Keep the menus visually tied to the game world, not to a SaaS app aesthetic. Use strong hierarchy, intentional typography, meaningful motion, and responsive layout.
|
||||
```
|
||||
|
||||
## Low-chrome 3D starter prompt
|
||||
|
||||
```text
|
||||
Design the initial playable HUD for a browser 3D game.
|
||||
|
||||
Goal: the first screen should feel playable in under 3 seconds, not like a dashboard.
|
||||
Camera mode: <third-person, first-person, orbit, rail>
|
||||
Primary verbs: <move, inspect, interact, attack, build>
|
||||
Persistent UI budget:
|
||||
- one compact objective chip or status cluster
|
||||
- one optional small secondary surface
|
||||
- one transient controls or interaction hint
|
||||
|
||||
Interaction rules:
|
||||
- keep the center of the playfield clear
|
||||
- keep the lower-middle playfield mostly clear during normal play
|
||||
- lore, notes, quest details, and long control lists live behind a drawer, pause menu, or toggle
|
||||
- modal and pause states must gate camera input correctly
|
||||
|
||||
Avoid:
|
||||
- giant title cards over live gameplay
|
||||
- field notes, controls, and objectives all open at once
|
||||
- equally weighted glass panels in every corner
|
||||
- full-screen overlay chrome during normal movement
|
||||
```
|
||||
|
||||
## 3D overlay prompt
|
||||
|
||||
```text
|
||||
Design and implement the HUD and menu overlays for a browser 3D game.
|
||||
|
||||
Engine context: <vanilla Three.js or React Three Fiber>
|
||||
Camera mode: <third-person, first-person, orbit, rail>
|
||||
Primary verbs: <move, inspect, interact, attack, build>
|
||||
Overlay surfaces: <reticle, quest log, inventory, pause menu, settings>
|
||||
Interaction constraints:
|
||||
- DOM overlays, not in-scene UI by default
|
||||
- modal and menu states must suspend or gate camera input correctly
|
||||
- keyboard and pointer states must be explicit
|
||||
- reduced-motion support for non-essential transitions
|
||||
- keep the center of the screen clear during normal play
|
||||
- keep the lower-middle playfield mostly clear during normal play
|
||||
- start with one compact objective surface and transient hints rather than multiple permanent cards
|
||||
- secondary content such as notes, lore, and full control references should be collapsed by default
|
||||
Avoid:
|
||||
- dashboard UI
|
||||
- cluttered full-screen overlays
|
||||
- boxed panels around every edge of the viewport
|
||||
- full-width top-and-bottom panel stacks
|
||||
- permanent text-heavy cards competing with the scene
|
||||
- camera movement continuing under active menus
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
# GLB Loading Starter
|
||||
|
||||
Use this as the canonical minimal pattern for loading shipped 3D content.
|
||||
|
||||
## Vanilla Three.js
|
||||
|
||||
```ts
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||
|
||||
const draco = new DRACOLoader();
|
||||
draco.setDecoderPath("/draco/");
|
||||
|
||||
const gltfLoader = new GLTFLoader();
|
||||
gltfLoader.setDRACOLoader(draco);
|
||||
|
||||
gltfLoader.load("/assets/hero.glb", (gltf) => {
|
||||
const root = gltf.scene;
|
||||
root.traverse((node) => {
|
||||
if ("castShadow" in node) {
|
||||
node.castShadow = true;
|
||||
node.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
scene.add(root);
|
||||
});
|
||||
```
|
||||
|
||||
## React Three Fiber
|
||||
|
||||
```tsx
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
|
||||
function HeroModel() {
|
||||
const gltf = useGLTF("/assets/hero.glb");
|
||||
return <primitive object={gltf.scene} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Default shipping format is GLB or glTF 2.0.
|
||||
- Keep optimization upstream in the asset pipeline; loader code should stay boring.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phaser Architecture
|
||||
|
||||
This is the default 2D structure for the plugin.
|
||||
|
||||
## Recommended module split
|
||||
|
||||
```text
|
||||
src/
|
||||
game/
|
||||
simulation/
|
||||
state.ts
|
||||
systems/
|
||||
rules/
|
||||
content/
|
||||
encounters/
|
||||
items/
|
||||
maps/
|
||||
input/
|
||||
actions.ts
|
||||
bindings.ts
|
||||
assets/
|
||||
manifest.ts
|
||||
phaser/
|
||||
boot/
|
||||
scenes/
|
||||
BootScene.ts
|
||||
MenuScene.ts
|
||||
BattleScene.ts
|
||||
view/
|
||||
sprites/
|
||||
fx/
|
||||
camera/
|
||||
adapters/
|
||||
sceneBridge.ts
|
||||
ui/
|
||||
hud/
|
||||
menus/
|
||||
overlays/
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- `simulation/`: source of truth for rules and saveable state
|
||||
- `content/`: authored data and encounter configuration
|
||||
- `input/`: action map and physical control bindings
|
||||
- `assets/`: stable manifest keys and asset metadata
|
||||
- `phaser/scenes/`: scene orchestration, not game rules
|
||||
- `phaser/view/`: render and effect helpers
|
||||
- `ui/`: DOM HUD, menus, and narrative panels
|
||||
|
||||
## Rules
|
||||
|
||||
- Phaser scenes read from and write to the simulation through a defined bridge.
|
||||
- Game state changes should not depend on sprite or tween lifetime.
|
||||
- Camera behavior should be isolated from combat or movement rules.
|
||||
- Use DOM for dense text and settings surfaces.
|
||||
51
.hermes/plugins/game-studio/references/playtest-checklist.md
Normal file
51
.hermes/plugins/game-studio/references/playtest-checklist.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Playtest Checklist
|
||||
|
||||
Use this checklist for browser-game QA.
|
||||
|
||||
## Universal checks
|
||||
|
||||
- Does the game boot into a useful first state?
|
||||
- Are the main verbs obvious and responsive?
|
||||
- Does the HUD remain readable over gameplay?
|
||||
- Does the first playable screen prioritize play over dashboard chrome?
|
||||
- Does the central playfield stay mostly clear during normal play?
|
||||
- Do pause, failure, and recovery states work?
|
||||
- Does the game survive viewport changes?
|
||||
|
||||
## 2D checks
|
||||
|
||||
- sprite baseline consistency
|
||||
- hit, hurt, and attack timing
|
||||
- command menu focus and input state
|
||||
- tile or platform readability
|
||||
- particle or camera effects obscuring gameplay
|
||||
|
||||
## 3D checks
|
||||
|
||||
- camera control stability
|
||||
- camera and menu-state handoff
|
||||
- depth readability
|
||||
- persistent overlay weight versus scene readability
|
||||
- secondary notes, controls, and quest details collapsed by default
|
||||
- resize and aspect-ratio handling
|
||||
- renderer fallback or context-loss handling
|
||||
- material and lighting stability across states
|
||||
- GLB asset and texture streaming behavior
|
||||
- collision proxy alignment
|
||||
- GPU bottlenecks isolated with capture tools when needed
|
||||
|
||||
## Browser checks
|
||||
|
||||
- desktop and mobile viewports
|
||||
- input modality differences
|
||||
- reduced-motion behavior
|
||||
- pause behavior when focus changes
|
||||
- pointer-lock and camera-input release when overlays open
|
||||
- transient onboarding hints dismiss or fade once the player is moving
|
||||
|
||||
## Reporting
|
||||
|
||||
- Capture screenshots for visual findings.
|
||||
- Put findings in severity order.
|
||||
- Include reproduction steps.
|
||||
- Call out whether the likely owner is simulation, renderer, frontend, or asset pipeline.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Rapier Integration Starter
|
||||
|
||||
Use this as the smallest canonical pattern for adding physics without letting it take over the whole runtime.
|
||||
|
||||
## Vanilla Three.js
|
||||
|
||||
```ts
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
|
||||
await RAPIER.init();
|
||||
|
||||
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
|
||||
const body = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 2, 0));
|
||||
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5), body);
|
||||
|
||||
renderer.setAnimationLoop(() => {
|
||||
world.step();
|
||||
const p = body.translation();
|
||||
mesh.position.set(p.x, p.y, p.z);
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
```
|
||||
|
||||
## React Three Fiber
|
||||
|
||||
```tsx
|
||||
import { Physics, RigidBody } from "@react-three/rapier";
|
||||
|
||||
<Physics gravity={[0, -9.81, 0]}>
|
||||
<RigidBody colliders="cuboid">
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#3dd9b8" />
|
||||
</mesh>
|
||||
</RigidBody>
|
||||
</Physics>;
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep physics state synchronized through an explicit bridge.
|
||||
- Do not bury gameplay rules inside render or physics callbacks.
|
||||
@@ -0,0 +1,42 @@
|
||||
# React Three Fiber Stack
|
||||
|
||||
This is the default React-native 3D stack for the plugin.
|
||||
|
||||
## Primary components
|
||||
|
||||
- [React Three Fiber](https://r3f.docs.pmnd.rs/getting-started/introduction) for declarative Three.js rendering in React.
|
||||
- [Drei](https://drei.docs.pmnd.rs/controls/introduction) for controls, loaders, helpers, environments, and common scene utilities.
|
||||
- [React Postprocessing](https://react-postprocessing.docs.pmnd.rs/introduction) for effect composition in React-hosted scenes.
|
||||
- [React Three Rapier](https://pmndrs.github.io/react-three-rapier/) for physics integration.
|
||||
- [React Three A11y](https://a11y.docs.pmnd.rs/introduction) when scene interaction benefits from accessibility-aware patterns.
|
||||
- [glTF Transform](https://gltf-transform.dev/) and the [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) for shipped assets.
|
||||
|
||||
## Default stack choices
|
||||
|
||||
- Runtime: `@react-three/fiber` + `three`
|
||||
- Helper ecosystem: `@react-three/drei`
|
||||
- Physics: `@react-three/rapier`
|
||||
- Effects: `@react-three/postprocessing`
|
||||
- Accessibility: `@react-three/a11y` when appropriate
|
||||
- Assets: GLB or glTF 2.0
|
||||
|
||||
## Choose this stack when
|
||||
|
||||
- the 3D scene lives inside a React app
|
||||
- the UI shell, settings, or product flow already uses React
|
||||
- the team benefits from declarative scene composition
|
||||
- the scene must share app state with non-canvas UI
|
||||
|
||||
## Avoid this stack when
|
||||
|
||||
- the project wants a cleaner imperative loop with minimal React coordination
|
||||
- the whole game runtime would be easier to reason about in plain TypeScript
|
||||
|
||||
## Companion references
|
||||
|
||||
- `threejs-stack.md`
|
||||
- `react-three-fiber-starter.md`
|
||||
- `gltf-loading-starter.md`
|
||||
- `rapier-integration-starter.md`
|
||||
- `web-3d-asset-pipeline.md`
|
||||
- `webgl-debugging-and-performance.md`
|
||||
@@ -0,0 +1,51 @@
|
||||
# React Three Fiber Starter
|
||||
|
||||
Use this as the smallest canonical starting point for a React-hosted 3D scene.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
src/
|
||||
App.tsx
|
||||
```
|
||||
|
||||
## `src/App.tsx`
|
||||
|
||||
```tsx
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<mesh rotation={[0.4, 0.6, 0]}>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color="#3dd9b8" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Canvas camera={{ position: [0, 1.5, 4], fov: 60 }}>
|
||||
<color attach="background" args={["#101418"]} />
|
||||
<ambientLight intensity={0.7} />
|
||||
<directionalLight position={[4, 6, 3]} intensity={1.2} />
|
||||
<Spinner />
|
||||
</Canvas>
|
||||
<div className="hud">
|
||||
<div className="objective-chip">Reach the lantern bridge.</div>
|
||||
<div className="hint-pill">WASD to move. Hold mouse to look.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Start here when the 3D scene lives inside an existing React app.
|
||||
- Keep the initial HUD sparse. One compact objective surface and one transient hint is usually enough for a first playable scaffold.
|
||||
- Put lore, notes, map, and settings behind drawers or modals instead of opening them all by default.
|
||||
- Add GLB loading with `gltf-loading-starter.md`.
|
||||
- Add physics with `rapier-integration-starter.md`.
|
||||
- Use `three-hud-layout-patterns.md` for low-chrome 3D overlay defaults.
|
||||
57
.hermes/plugins/game-studio/references/sprite-pipeline.md
Normal file
57
.hermes/plugins/game-studio/references/sprite-pipeline.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Sprite Pipeline
|
||||
|
||||
This is the default 2D animation workflow for the plugin.
|
||||
|
||||
## Principles
|
||||
|
||||
- Start from one approved in-game frame.
|
||||
- Generate the animation as one strip, not isolated frames.
|
||||
- Normalize the whole strip with one shared scale.
|
||||
- Use one shared anchor, typically bottom-center.
|
||||
- Preview before approving the asset.
|
||||
|
||||
## Why this works
|
||||
|
||||
- The approved seed frame preserves identity.
|
||||
- Strip-first generation reduces frame-to-frame drift.
|
||||
- Shared-scale normalization prevents one tall pose from making the character feel smaller.
|
||||
- Locking frame 01 back to the shipped sprite preserves continuity for idle-to-action transitions.
|
||||
|
||||
## Prompt template
|
||||
|
||||
```text
|
||||
Intended use: candidate production spritesheet for a 2D browser game animation review.
|
||||
Edit the provided transparent reference canvas into a single horizontal <N>-frame spritesheet.
|
||||
|
||||
The existing sprite in the leftmost slot is the anchor frame and must remain the same character:
|
||||
- same facing direction
|
||||
- same silhouette family
|
||||
- same palette family
|
||||
- same proportions
|
||||
- same readable face or key features
|
||||
- same outfit details
|
||||
|
||||
Composition:
|
||||
- transparent canvas
|
||||
- exactly one row of <N> equal frame slots
|
||||
- no extra characters
|
||||
- no labels
|
||||
- no scenery
|
||||
- no poster layout
|
||||
|
||||
Action:
|
||||
- describe the specific animation beat from frame 1 through frame N
|
||||
|
||||
Style:
|
||||
- authentic pixel-art production asset
|
||||
- crisp pixel clusters
|
||||
- restrained palette
|
||||
- not concept art
|
||||
```
|
||||
|
||||
## Normalization notes
|
||||
|
||||
- Use the union of detected sprite bounds per slot.
|
||||
- Compute one scale from the largest detected frame and anchor.
|
||||
- Bottom-align frames into the target canvas.
|
||||
- Reuse the exact shipped frame for frame 01 when `--lock-frame1` is appropriate.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 3D HUD Layout Patterns
|
||||
|
||||
Use these defaults for initial 3D browser-game scaffolds. The first screen should be playable before it is informational.
|
||||
|
||||
## Layout Budget
|
||||
|
||||
- Keep the center of the screen clear during normal play.
|
||||
- On desktop, prefer one primary persistent cluster and one small secondary cluster.
|
||||
- On mobile, prefer one compact persistent cluster and transient prompts.
|
||||
- Secondary information belongs in drawers, toggles, pause menus, or contextual popovers.
|
||||
|
||||
## Good Default Patterns
|
||||
|
||||
### Objective chip
|
||||
|
||||
- One short objective in a compact top-corner chip.
|
||||
- One optional sublabel for location or mode.
|
||||
- No giant hero banner over the live scene.
|
||||
|
||||
### Contextual interaction prompt
|
||||
|
||||
- Bottom-center or lower-corner pill.
|
||||
- Appears only near interactables or during onboarding.
|
||||
- Dismisses after first use or fades once the player is moving confidently.
|
||||
|
||||
### Small status strip
|
||||
|
||||
- Health, energy, party count, or beacon progress in a narrow edge-aligned strip.
|
||||
- Use icons, short labels, and compact meters instead of stacked cards.
|
||||
|
||||
### Collapsible journal or quest log
|
||||
|
||||
- Closed by default.
|
||||
- Opened by a hotkey, button, or pause state.
|
||||
- Holds longer prose, lore, map notes, and multi-step objective details.
|
||||
|
||||
### Pause and settings modal
|
||||
|
||||
- Explicit modal state.
|
||||
- Suspends pointer-lock, drag-look, or camera input while active.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- four to six glass cards permanently framing the viewport
|
||||
- large lore or field-notes panels open during normal movement
|
||||
- controls lists permanently pinned to the screen
|
||||
- symmetric dashboard composition that competes with the scene
|
||||
- oversized title panels staying visible after the first second of play
|
||||
|
||||
## Example UI Budget
|
||||
|
||||
- top-left: objective chip
|
||||
- top-right: compact status strip
|
||||
- bottom-center: transient interaction or controls hint
|
||||
- pause menu or drawer: map, notes, inventory, settings
|
||||
|
||||
## Prompt Add-On
|
||||
|
||||
```text
|
||||
Default to a low-chrome playable HUD. Keep the central playfield clear. Use one compact objective chip, one small status surface, and transient prompts. Put lore, field notes, full controls, and long checklists behind a drawer or pause menu. Avoid equal-weight boxed panels in every corner.
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# Three WebGL Architecture
|
||||
|
||||
This is the default 3D structure for the plugin.
|
||||
|
||||
## Recommended module split
|
||||
|
||||
```text
|
||||
src/
|
||||
game/
|
||||
simulation/
|
||||
content/
|
||||
input/
|
||||
save/
|
||||
render/
|
||||
app/
|
||||
createRenderer.ts
|
||||
createScene.ts
|
||||
createCamera.ts
|
||||
createLoop.ts
|
||||
loaders/
|
||||
loadGltf.ts
|
||||
loadEnvironment.ts
|
||||
loadTextures.ts
|
||||
objects/
|
||||
materials/
|
||||
lights/
|
||||
post/
|
||||
adapters/
|
||||
renderBridge.ts
|
||||
physics/
|
||||
world.ts
|
||||
colliders.ts
|
||||
sync.ts
|
||||
diagnostics/
|
||||
debugFlags.ts
|
||||
perf.ts
|
||||
ui/
|
||||
hud/
|
||||
menus/
|
||||
overlays/
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- `simulation/`: rules, state, AI, progression, save data
|
||||
- `render/app/`: renderer, scene, camera, resize, context lifecycle
|
||||
- `render/loaders/`: GLTF, compression, texture, and environment loading
|
||||
- `render/objects/`: scene graph construction and disposal
|
||||
- `render/materials/`: material setup and shader boundaries
|
||||
- `physics/`: Rapier world and simulation bridge
|
||||
- `diagnostics/`: performance probes and GPU debugging hooks
|
||||
- `ui/`: DOM HUD and menus
|
||||
|
||||
## Rules
|
||||
|
||||
- Scene graph objects are not the source of truth for game rules.
|
||||
- Keep camera logic explicit and testable.
|
||||
- Handle resize and context-loss as real browser concerns.
|
||||
- Keep high-density UI in DOM even when the world is fully 3D.
|
||||
- Treat GLB or glTF 2.0 as the default content format.
|
||||
- Add physics and diagnostics as real subsystems, not temporary one-off utilities.
|
||||
41
.hermes/plugins/game-studio/references/threejs-stack.md
Normal file
41
.hermes/plugins/game-studio/references/threejs-stack.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Three.js Stack
|
||||
|
||||
This is the default non-React 3D runtime stack for the plugin.
|
||||
|
||||
## Primary components
|
||||
|
||||
- [Three.js documentation](https://threejs.org/docs/) for the core renderer, scene graph, materials, cameras, loaders, and examples.
|
||||
- [Rapier JavaScript guide](https://rapier.rs/docs/user_guides/javascript/getting_started_js/) for physics integration.
|
||||
- [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html) for the default shipping asset format.
|
||||
- [glTF Transform](https://gltf-transform.dev/) for optimization, packaging, and compression workflows.
|
||||
- [SpectorJS](https://spector.babylonjs.com/) for WebGL frame capture and GPU debugging.
|
||||
|
||||
## Default stack choices
|
||||
|
||||
- Runtime: `three`
|
||||
- Tooling: TypeScript + Vite
|
||||
- Assets: GLB or glTF 2.0
|
||||
- Loaders: `GLTFLoader`, `DRACOLoader`, `KTX2Loader` when the asset pipeline requires them
|
||||
- Physics: Rapier JS
|
||||
- UI: DOM overlays, not in-scene UI by default
|
||||
|
||||
## Choose this stack when
|
||||
|
||||
- the project is not React-first
|
||||
- the team wants direct control over the render loop
|
||||
- scene composition, loader setup, or custom render behavior needs imperative structure
|
||||
- the game code should feel engine-like without a React abstraction layer
|
||||
|
||||
## Avoid this stack when
|
||||
|
||||
- the surrounding app is already React-heavy and wants shared declarative state
|
||||
- the project needs an editor-first engine workflow more than a portable TypeScript runtime
|
||||
|
||||
## Companion references
|
||||
|
||||
- `three-webgl-architecture.md`
|
||||
- `threejs-vanilla-starter.md`
|
||||
- `gltf-loading-starter.md`
|
||||
- `rapier-integration-starter.md`
|
||||
- `web-3d-asset-pipeline.md`
|
||||
- `webgl-debugging-and-performance.md`
|
||||
@@ -0,0 +1,58 @@
|
||||
# Three.js Vanilla Starter
|
||||
|
||||
Use this as the smallest canonical starting point for a plain TypeScript or Vite Three.js app.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
src/
|
||||
main.ts
|
||||
```
|
||||
|
||||
## `src/main.ts`
|
||||
|
||||
```ts
|
||||
import * as THREE from "three";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color("#101418");
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
|
||||
camera.position.set(0, 1.5, 4);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
light.position.set(4, 6, 3);
|
||||
scene.add(light);
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
new THREE.MeshStandardMaterial({ color: "#3dd9b8" }),
|
||||
);
|
||||
scene.add(mesh);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
renderer.setAnimationLoop(() => {
|
||||
mesh.rotation.y += 0.01;
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Start here for direct loop and renderer control.
|
||||
- If the scaffold needs UI, start with one compact objective chip and one transient controls hint rather than multiple permanent cards.
|
||||
- Keep notes, codex, maps, and settings behind on-demand surfaces. The starter scene should stay readable while moving the camera.
|
||||
- Add GLB loading with `gltf-loading-starter.md`.
|
||||
- Add physics sync with `rapier-integration-starter.md`.
|
||||
- Use `three-hud-layout-patterns.md` for low-chrome 3D overlay defaults.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Web 3D Asset Pipeline
|
||||
|
||||
This is the default 3D asset shipping guidance for the plugin.
|
||||
|
||||
## Primary sources
|
||||
|
||||
- [Blender glTF exporter manual](https://docs.blender.org/manual/en/latest/addons/import_export/scene_gltf2.html)
|
||||
- [glTF 2.0 specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html)
|
||||
- [glTF Transform](https://gltf-transform.dev/)
|
||||
- [PlayCanvas supported formats](https://developer.playcanvas.com/user-manual/assets/supported-formats/) for a good reference on why GLB is the recommended runtime format in browser engines
|
||||
|
||||
## Default output
|
||||
|
||||
- Ship GLB when possible.
|
||||
- Use `.gltf` with external files only when the asset pipeline or delivery strategy genuinely needs that shape.
|
||||
|
||||
## Recommended workflow
|
||||
|
||||
1. Clean the source asset in the DCC tool.
|
||||
2. Export to GLB or glTF 2.0.
|
||||
3. Run glTF Transform for validation, pruning, deduplication, and size reduction.
|
||||
4. Apply the chosen geometry and texture compression strategy.
|
||||
5. Verify pivots, scale, collision assumptions, and hierarchy naming.
|
||||
6. Test the asset in the runtime before treating it as final.
|
||||
|
||||
## Compression and optimization
|
||||
|
||||
- Use Draco or Meshopt deliberately, not both by default.
|
||||
- Use KTX2 or BasisU when the runtime stack supports GPU-friendly compressed textures.
|
||||
- Keep texture resolution aligned with actual on-screen use.
|
||||
- Reuse materials and avoid unnecessary texture uniqueness.
|
||||
|
||||
## Runtime checks
|
||||
|
||||
- scale is consistent across assets
|
||||
- pivots match gameplay expectations
|
||||
- node names are stable
|
||||
- collision proxy needs are handled
|
||||
- animation clips and variants load correctly
|
||||
- memory and load time are reasonable for the scene
|
||||
|
||||
## Starter patterns
|
||||
|
||||
- `threejs-vanilla-starter.md`
|
||||
- `react-three-fiber-starter.md`
|
||||
- `gltf-loading-starter.md`
|
||||
- `rapier-integration-starter.md`
|
||||
@@ -0,0 +1,36 @@
|
||||
# WebGL Debugging and Performance
|
||||
|
||||
Use this reference when a browser 3D scene is visually wrong, unstable, or slower than expected.
|
||||
|
||||
## Primary tools
|
||||
|
||||
- [SpectorJS](https://spector.babylonjs.com/) for frame capture, pipeline inspection, draw-call review, and shader debugging.
|
||||
- Browser performance tooling for main-thread work, asset decode stalls, and memory pressure.
|
||||
- Engine-native debug views and stats surfaces where available.
|
||||
|
||||
## What to inspect first
|
||||
|
||||
- draw-call count
|
||||
- shader compilation churn
|
||||
- texture memory pressure
|
||||
- geometry count and material count
|
||||
- post-processing cost
|
||||
- asset decode and streaming stalls
|
||||
- WebGL context loss or fallback behavior
|
||||
|
||||
## Common causes of poor performance
|
||||
|
||||
- too many unique materials
|
||||
- oversized textures
|
||||
- heavy GLB assets loaded without optimization
|
||||
- complex post-processing on top of an already expensive scene
|
||||
- physics and render state fighting for ownership
|
||||
- React and scene state updating each other too frequently in React-hosted 3D apps
|
||||
|
||||
## Debugging rules
|
||||
|
||||
- Capture first, then guess.
|
||||
- Reduce the scene until the perf cliff becomes obvious.
|
||||
- Disable post-processing before rewriting core scene code.
|
||||
- Verify the asset pipeline before blaming the renderer.
|
||||
- Treat context-loss handling as a browser requirement, not an edge case.
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a transparent edit canvas around a shipped seed sprite frame."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Upscale a seed sprite with nearest-neighbor sampling and place it into "
|
||||
"the leftmost slot of a larger transparent edit canvas."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--seed", required=True, help="Path to the approved seed frame.")
|
||||
parser.add_argument("--out", required=True, help="Path to the output PNG.")
|
||||
parser.add_argument(
|
||||
"--frames",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of horizontal frame slots to reserve. Default: 4.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slot-size",
|
||||
type=int,
|
||||
default=256,
|
||||
help="Size of each square frame slot in pixels. Default: 256.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--canvas-size",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Size of the square transparent canvas in pixels. Default: 1024.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resize_seed(seed: Image.Image, slot_size: int) -> Image.Image:
|
||||
max_dim = max(seed.size)
|
||||
scale = slot_size / max_dim
|
||||
if scale >= 1:
|
||||
scale = max(1, int(scale))
|
||||
width = max(1, int(round(seed.width * scale)))
|
||||
height = max(1, int(round(seed.height * scale)))
|
||||
return seed.resize((width, height), Image.Resampling.NEAREST)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.frames < 1:
|
||||
raise SystemExit("--frames must be at least 1.")
|
||||
if args.slot_size < 1 or args.canvas_size < 1:
|
||||
raise SystemExit("--slot-size and --canvas-size must be positive.")
|
||||
|
||||
strip_width = args.frames * args.slot_size
|
||||
if strip_width > args.canvas_size or args.slot_size > args.canvas_size:
|
||||
raise SystemExit("Frame slots do not fit inside the requested canvas size.")
|
||||
|
||||
seed = Image.open(args.seed).convert("RGBA")
|
||||
seed = resize_seed(seed, args.slot_size)
|
||||
|
||||
canvas = Image.new("RGBA", (args.canvas_size, args.canvas_size), (0, 0, 0, 0))
|
||||
strip_left = (args.canvas_size - strip_width) // 2
|
||||
strip_top = (args.canvas_size - args.slot_size) // 2
|
||||
slot_left = strip_left
|
||||
slot_top = strip_top
|
||||
paste_x = slot_left + (args.slot_size - seed.width) // 2
|
||||
paste_y = slot_top + (args.slot_size - seed.height) // 2
|
||||
canvas.alpha_composite(seed, (paste_x, paste_y))
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize a raw animation strip into fixed-size transparent frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Extract one horizontal strip into fixed-size frames using a shared "
|
||||
"global scale and bottom-center alignment."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--input", required=True, help="Path to the raw strip image.")
|
||||
parser.add_argument("--out-dir", required=True, help="Output directory for frames.")
|
||||
parser.add_argument(
|
||||
"--frames",
|
||||
type=int,
|
||||
required=True,
|
||||
help="Number of horizontal frames in the strip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-size",
|
||||
type=int,
|
||||
default=64,
|
||||
help="Output square frame size in pixels. Default: 64.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--anchor",
|
||||
help="Optional anchor frame used to stabilize global scale and frame 01 output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lock-frame1",
|
||||
action="store_true",
|
||||
help="Replace frame 01 with the provided anchor frame after normalization.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--alpha-threshold",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Pixels with alpha above this threshold count as sprite content. Default: 8.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def threshold_bbox(image: Image.Image, alpha_threshold: int) -> tuple[int, int, int, int] | None:
|
||||
alpha = image.getchannel("A").point(lambda value: 255 if value > alpha_threshold else 0)
|
||||
return alpha.getbbox()
|
||||
|
||||
|
||||
def crop_to_content(image: Image.Image, alpha_threshold: int) -> Image.Image | None:
|
||||
bbox = threshold_bbox(image, alpha_threshold)
|
||||
if bbox is None:
|
||||
return None
|
||||
return image.crop(bbox)
|
||||
|
||||
|
||||
def split_strip(strip: Image.Image, frames: int) -> list[Image.Image]:
|
||||
if frames < 1:
|
||||
raise ValueError("frames must be at least 1")
|
||||
step = strip.width / frames
|
||||
slots: list[Image.Image] = []
|
||||
for index in range(frames):
|
||||
left = int(round(index * step))
|
||||
right = int(round((index + 1) * step))
|
||||
slots.append(strip.crop((left, 0, right, strip.height)))
|
||||
return slots
|
||||
|
||||
|
||||
def max_content_size(images: Iterable[Image.Image | None]) -> tuple[int, int]:
|
||||
widths: list[int] = []
|
||||
heights: list[int] = []
|
||||
for image in images:
|
||||
if image is None:
|
||||
continue
|
||||
widths.append(image.width)
|
||||
heights.append(image.height)
|
||||
if not widths or not heights:
|
||||
raise SystemExit("No sprite content was detected in the provided strip.")
|
||||
return max(widths), max(heights)
|
||||
|
||||
|
||||
def compose_frame(
|
||||
image: Image.Image | None,
|
||||
frame_size: int,
|
||||
scale: float,
|
||||
) -> Image.Image:
|
||||
canvas = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||
if image is None:
|
||||
return canvas
|
||||
|
||||
width = max(1, int(round(image.width * scale)))
|
||||
height = max(1, int(round(image.height * scale)))
|
||||
resized = image.resize((width, height), Image.Resampling.NEAREST)
|
||||
offset_x = (frame_size - width) // 2
|
||||
offset_y = frame_size - height
|
||||
canvas.alpha_composite(resized, (offset_x, offset_y))
|
||||
return canvas
|
||||
|
||||
|
||||
def load_anchor(path: str | None, alpha_threshold: int) -> tuple[Image.Image | None, Image.Image | None]:
|
||||
if path is None:
|
||||
return None, None
|
||||
anchor = Image.open(path).convert("RGBA")
|
||||
cropped = crop_to_content(anchor, alpha_threshold)
|
||||
return anchor, cropped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.frames < 1:
|
||||
raise SystemExit("--frames must be at least 1.")
|
||||
if args.frame_size < 1:
|
||||
raise SystemExit("--frame-size must be positive.")
|
||||
if args.lock_frame1 and not args.anchor:
|
||||
raise SystemExit("--lock-frame1 requires --anchor.")
|
||||
|
||||
strip = Image.open(args.input).convert("RGBA")
|
||||
slots = split_strip(strip, args.frames)
|
||||
contents = [crop_to_content(slot, args.alpha_threshold) for slot in slots]
|
||||
anchor_image, anchor_content = load_anchor(args.anchor, args.alpha_threshold)
|
||||
max_width, max_height = max_content_size([*contents, anchor_content])
|
||||
scale = min(args.frame_size / max_width, args.frame_size / max_height)
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for index, content in enumerate(contents, start=1):
|
||||
if index == 1 and args.lock_frame1:
|
||||
assert anchor_image is not None
|
||||
if anchor_image.width == args.frame_size and anchor_image.height == args.frame_size:
|
||||
frame = anchor_image
|
||||
else:
|
||||
frame = compose_frame(anchor_content, args.frame_size, scale)
|
||||
else:
|
||||
frame = compose_frame(content, args.frame_size, scale)
|
||||
frame.save(out_dir / f"{index:02d}.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render a simple contact sheet from a directory of normalized sprite frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
||||
) from exc
|
||||
|
||||
|
||||
NUMBER_RE = re.compile(r"(\d+)")
|
||||
|
||||
|
||||
def natural_key(path: Path) -> list[int | str]:
|
||||
parts: list[int | str] = []
|
||||
for chunk in NUMBER_RE.split(path.stem):
|
||||
if not chunk:
|
||||
continue
|
||||
if chunk.isdigit():
|
||||
parts.append(int(chunk))
|
||||
else:
|
||||
parts.append(chunk)
|
||||
parts.append(path.suffix)
|
||||
return parts
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render a preview contact sheet from a directory of sprite frames."
|
||||
)
|
||||
parser.add_argument("--frames-dir", required=True, help="Directory containing PNG frames.")
|
||||
parser.add_argument("--out", required=True, help="Output PNG path.")
|
||||
parser.add_argument(
|
||||
"--columns",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of columns in the preview sheet. Default: 4.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gap",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Gap between frames in pixels. Default: 8.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def paint_checkerboard(image: Image.Image, tile: int = 16) -> None:
|
||||
draw = ImageDraw.Draw(image)
|
||||
colors = ((240, 243, 246, 255), (225, 230, 235, 255))
|
||||
for top in range(0, image.height, tile):
|
||||
for left in range(0, image.width, tile):
|
||||
color = colors[((left // tile) + (top // tile)) % 2]
|
||||
draw.rectangle((left, top, left + tile, top + tile), fill=color)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.columns < 1:
|
||||
raise SystemExit("--columns must be at least 1.")
|
||||
if args.gap < 0:
|
||||
raise SystemExit("--gap cannot be negative.")
|
||||
|
||||
frame_dir = Path(args.frames_dir)
|
||||
frames = sorted(frame_dir.glob("*.png"), key=natural_key)
|
||||
if not frames:
|
||||
raise SystemExit("No PNG frames were found in --frames-dir.")
|
||||
|
||||
images = [Image.open(path).convert("RGBA") for path in frames]
|
||||
frame_width = max(image.width for image in images)
|
||||
frame_height = max(image.height for image in images)
|
||||
rows = math.ceil(len(images) / args.columns)
|
||||
sheet_width = args.columns * frame_width + max(0, args.columns - 1) * args.gap
|
||||
sheet_height = rows * frame_height + max(0, rows - 1) * args.gap
|
||||
sheet = Image.new("RGBA", (sheet_width, sheet_height), (255, 255, 255, 255))
|
||||
paint_checkerboard(sheet)
|
||||
|
||||
for index, image in enumerate(images):
|
||||
row = index // args.columns
|
||||
column = index % args.columns
|
||||
left = column * (frame_width + args.gap) + (frame_width - image.width) // 2
|
||||
top = row * (frame_height + args.gap) + (frame_height - image.height) // 2
|
||||
sheet.alpha_composite(image, (left, top))
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
76
.hermes/plugins/game-studio/skills/game-playtest/SKILL.md
Normal file
76
.hermes/plugins/game-studio/skills/game-playtest/SKILL.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: game-playtest
|
||||
description: Run browser-game playtests and frontend QA. Use when the user asks for smoke tests, screenshot-based verification, browser automation, HUD or overlay review, or structured issue-finding in a browser game.
|
||||
---
|
||||
|
||||
# Game Playtest
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to test browser games the way players experience them: through boot, input, scene transitions, HUD readability, and visual state changes. Prefer browser automation and screenshot review when the project supports it.
|
||||
|
||||
## Preferred Workflow
|
||||
|
||||
1. Boot the game and confirm the first actionable screen.
|
||||
2. Exercise the main verbs.
|
||||
3. Capture screenshots from representative states.
|
||||
4. Check the UI layer independently from the render layer.
|
||||
5. Report findings in severity order with reproduction steps.
|
||||
|
||||
## Tooling Guidance
|
||||
|
||||
- Prefer Playwright or equivalent browser automation already available in the repo.
|
||||
- When the game is canvas or WebGL heavy, screenshots are mandatory because DOM assertions alone miss visual regressions.
|
||||
- Use screenshots to judge playfield obstruction and HUD weight, not just correctness of text or layout.
|
||||
- When deterministic automation is not practical, do a structured manual pass and capture evidence.
|
||||
- For 3D rendering bugs or unexplained frame cost, use SpectorJS and browser performance tooling rather than guessing from code alone.
|
||||
|
||||
## Common Checks
|
||||
|
||||
### 2D checks
|
||||
|
||||
- sprite alignment and baseline consistency
|
||||
- hit or hurt animation readability
|
||||
- HUD overlap with the playfield
|
||||
- command menu state changes
|
||||
- tile or platform readability
|
||||
- input-state feedback and turn-state clarity
|
||||
|
||||
### 3D checks
|
||||
|
||||
- first-load playability versus dashboard-like chrome
|
||||
- persistent overlay weight versus playfield visibility
|
||||
- camera control and camera reset behavior
|
||||
- pointer-lock or drag-look transitions when menus and overlays open
|
||||
- depth readability and silhouette clarity
|
||||
- secondary panels collapsed or dismissible during normal play
|
||||
- resize behavior
|
||||
- WebGL context loss or renderer fallback behavior
|
||||
- material or lighting regressions
|
||||
- GLB or texture streaming stalls
|
||||
- collision proxy or physics mismatch
|
||||
- performance cliffs tied to post-processing or asset load
|
||||
|
||||
## Responsive and Browser Checks
|
||||
|
||||
- desktop and mobile viewport sanity
|
||||
- safe-area and notch issues where relevant
|
||||
- reduced-motion behavior for UI transitions
|
||||
- keyboard, pointer, and pause-state handling
|
||||
- React state and scene state synchronization when the project uses React Three Fiber
|
||||
|
||||
## Reporting Standard
|
||||
|
||||
Lead with findings. Keep each finding concrete:
|
||||
|
||||
- what the user sees
|
||||
- how to reproduce it
|
||||
- why it matters
|
||||
- what likely subsystem owns it
|
||||
|
||||
## References
|
||||
|
||||
- Shared architecture: `../web-game-foundations/SKILL.md`
|
||||
- Frontend review cues: `../game-ui-frontend/SKILL.md`
|
||||
- 3D debugging notes: `../../references/webgl-debugging-and-performance.md`
|
||||
- Full checklist: `../../references/playtest-checklist.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Game Playtest"
|
||||
short_description: "Run browser-game playtests and QA"
|
||||
default_prompt: "Playtest the browser game, check core interactions and visual state changes, and report concrete issues."
|
||||
94
.hermes/plugins/game-studio/skills/game-studio/SKILL.md
Normal file
94
.hermes/plugins/game-studio/skills/game-studio/SKILL.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: game-studio
|
||||
description: Route early browser-game work. Use when the user needs stack selection and workflow planning across design, implementation, assets, and playtesting before moving to a specialist skill.
|
||||
---
|
||||
|
||||
# Game Studio
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill as the umbrella entrypoint for browser-game work. Default to a 2D Phaser path unless the user explicitly asks for 3D, Three.js, React Three Fiber, shader-heavy rendering, or another WebGL-first direction.
|
||||
|
||||
This plugin is intentionally asymmetric:
|
||||
|
||||
- 2D is the strongest execution path in v1.
|
||||
- 3D has one opinionated default ecosystem: vanilla Three.js for plain TypeScript or Vite apps, React Three Fiber for React-hosted 3D apps, and GLB or glTF 2.0 as the default shipping asset format.
|
||||
- Shared architecture, UI, and playtest practices apply to both.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- the user is still choosing a stack
|
||||
- the request spans multiple domains such as runtime, UI, asset pipeline, and QA
|
||||
- the user says "help me build a game" without naming the implementation path
|
||||
|
||||
## Do Not Stay Here When
|
||||
|
||||
- the runtime is clearly plain Three.js
|
||||
- the runtime is clearly React Three Fiber
|
||||
- the task is clearly a shipped-asset problem
|
||||
- the task is clearly frontend-only or QA-only
|
||||
|
||||
Once the intent is clear, route to the most specific specialist skill and continue from there.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
1. Classify the request before designing or coding:
|
||||
- `2D default`: Phaser, sprites, tilemaps, top-down, side-view, grid tactics, action platformers.
|
||||
- `3D + plain TS/Vite`: imperative scene control, engine-like loops, non-React apps, direct Three.js work.
|
||||
- `3D + React`: React-hosted product surfaces, declarative scene composition, shared React state, UI-heavy 3D apps.
|
||||
- `3D asset pipeline`: GLB, glTF, texture packaging, compression, LOD, runtime asset size.
|
||||
- `Alternative engine`: Babylon.js or PlayCanvas requests, usually as comparison or ecosystem fit questions.
|
||||
- `Shared`: core loop design, frontend direction, save/debug/perf boundaries, browser QA.
|
||||
2. Route to the specialist skills immediately after classification:
|
||||
- Shared architecture and engine choice: `../web-game-foundations/SKILL.md`
|
||||
- Deep 2D implementation: `../phaser-2d-game/SKILL.md`
|
||||
- Vanilla Three.js implementation: `../three-webgl-game/SKILL.md`
|
||||
- React-hosted 3D implementation: `../react-three-fiber-game/SKILL.md`
|
||||
- 3D asset shipping and optimization: `../web-3d-asset-pipeline/SKILL.md`
|
||||
- HUD and menu direction: `../game-ui-frontend/SKILL.md`
|
||||
- 2D sprite generation and normalization: `../sprite-pipeline/SKILL.md`
|
||||
- Browser QA and visual review: `../game-playtest/SKILL.md`
|
||||
3. Keep one coherent plan across the routed skills. Do not let engine, UI, asset, and QA decisions drift apart.
|
||||
|
||||
## Default Workflow
|
||||
|
||||
1. Lock the game fantasy and player verbs.
|
||||
2. Define the core loop, failure states, progression, and target play session length.
|
||||
3. Choose the implementation track:
|
||||
- Default to Phaser for 2D browser games.
|
||||
- Choose vanilla Three.js when the project is explicitly 3D and wants direct render-loop control in a plain TypeScript or Vite app.
|
||||
- Choose React Three Fiber when the project already lives in React or wants declarative scene composition with shared React state.
|
||||
- Choose raw WebGL only when the user explicitly wants a custom renderer or shader-first surface.
|
||||
4. Define the UI surface early. Browser games usually need a DOM HUD and menu layer even when the playfield is canvas or WebGL.
|
||||
- For 3D starter scaffolds, default to a low-chrome HUD that preserves the playfield and keeps secondary panels collapsed.
|
||||
5. Decide the asset workflow:
|
||||
- 2D characters and effects: use `sprite-pipeline`.
|
||||
- 3D models, textures, and shipping format: use `web-3d-asset-pipeline`.
|
||||
6. Close with a playtest loop before calling the work production-ready.
|
||||
|
||||
## Output Expectations
|
||||
|
||||
- For planning requests, return a game-specific plan with stack choice, gameplay loop, UI surface, asset workflow, and test approach.
|
||||
- For implementation requests, keep the chosen stack obvious in the file structure and code boundaries.
|
||||
- For mixed requests, preserve the plugin default: 2D Phaser first unless the user asks for something else.
|
||||
- When the user asks about Babylon.js or PlayCanvas, compare them honestly but keep Three.js and R3F as the primary code-generation defaults unless the user explicitly chooses another engine.
|
||||
|
||||
## References
|
||||
|
||||
- Engine selection: `../../references/engine-selection.md`
|
||||
- Three.js stack: `../../references/threejs-stack.md`
|
||||
- React Three Fiber stack: `../../references/react-three-fiber-stack.md`
|
||||
- 3D asset pipeline: `../../references/web-3d-asset-pipeline.md`
|
||||
- Vanilla Three.js starter: `../../references/threejs-vanilla-starter.md`
|
||||
- React Three Fiber starter: `../../references/react-three-fiber-starter.md`
|
||||
- Frontend prompting patterns: `../../references/frontend-prompts.md`
|
||||
- Playtest checklist: `../../references/playtest-checklist.md`
|
||||
|
||||
## Examples
|
||||
|
||||
- "Help me prototype a browser tactics game."
|
||||
- "I need a Phaser-based action game loop with a HUD and menus."
|
||||
- "I want a Three.js exploration demo with WebGL lighting and browser-safe UI."
|
||||
- "I want a React-based 3D configurator with React Three Fiber."
|
||||
- "Optimize my GLB assets for the web and keep the file sizes under control."
|
||||
- "Set up the asset workflow for consistent 2D sprite animations."
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Game Studio"
|
||||
short_description: "Route browser-game work to the right path"
|
||||
default_prompt: "Help me choose the right browser-game stack and workflow before implementation starts."
|
||||
112
.hermes/plugins/game-studio/skills/game-ui-frontend/SKILL.md
Normal file
112
.hermes/plugins/game-studio/skills/game-ui-frontend/SKILL.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: game-ui-frontend
|
||||
description: Design UI surfaces for browser games. Use when the user asks for HUDs, menus, overlays, responsive layouts, or visual direction that must protect the playfield.
|
||||
---
|
||||
|
||||
# Game UI Frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill whenever the game needs a visible interface layer. The job is not to produce generic dashboard UI. The job is to produce a readable, thematic browser-game interface that supports the play experience.
|
||||
|
||||
Default assumption: build the game world in canvas or WebGL, and build text-heavy UI in DOM.
|
||||
|
||||
## Frontend Standards
|
||||
|
||||
1. Establish visual direction before coding.
|
||||
- Genre and fantasy
|
||||
- Material language
|
||||
- Typography
|
||||
- Palette
|
||||
- Motion tone
|
||||
2. Use CSS variables for the UI theme.
|
||||
3. Build clear hierarchy.
|
||||
- Critical combat or survival information first
|
||||
- Secondary tools second
|
||||
- Rarely used settings behind menus or drawers
|
||||
4. Protect the playfield first, especially in 3D.
|
||||
- The initial screen should feel playable within a few seconds.
|
||||
- Default to one primary persistent HUD cluster and at most one small secondary cluster.
|
||||
- Keep the center of the playfield clear during normal play.
|
||||
- Keep the lower-middle playfield mostly clear during normal play.
|
||||
- Put lore, field notes, quest details, and long control lists behind drawers, toggles, or pause surfaces.
|
||||
- Prefer contextual prompts and transient hints over permanent boxed panels.
|
||||
5. Keep overlays readable over motion.
|
||||
- Use backing panels, edge treatment, contrast, and restrained blur where needed.
|
||||
6. Design for both desktop and mobile from the start.
|
||||
7. Design 3D UI around camera and input control boundaries.
|
||||
- Pause or gate camera-control input when menus, dialogs, or pointer-driven UI are active.
|
||||
- Keep pointer-lock, drag-to-look, and menu interaction states explicit.
|
||||
|
||||
## 3D Starter Defaults
|
||||
|
||||
For exploration, traversal, or third-person starter scaffolds, prefer this UI budget:
|
||||
|
||||
- one compact objective chip or status strip at the edge
|
||||
- one transient controls hint or interaction prompt
|
||||
- one optional collapsible secondary surface such as a journal, map, or quest log
|
||||
|
||||
Do not open every informational surface on first load. The scene should be readable before the user opens any deeper UI.
|
||||
|
||||
As a default implementation constraint for 3D browser games:
|
||||
|
||||
- no always-on full-width header plus multi-card body plus full-width footer layout
|
||||
- no large center-screen or lower-middle overlays during normal movement
|
||||
- no more than roughly 20-25% of the viewport covered by persistent HUD on desktop unless the user explicitly requests a denser layout
|
||||
- on mobile, collapse to a narrow stack or contextual chips before covering the playfield with larger panels
|
||||
|
||||
## Prompting Rules
|
||||
|
||||
When asking the model to design or implement game UI, include:
|
||||
|
||||
- the game fantasy
|
||||
- the camera or viewpoint
|
||||
- the player verbs
|
||||
- the HUD layers
|
||||
- the camera or control mode when the game is 3D
|
||||
- the tone of motion
|
||||
- desktop and mobile expectations
|
||||
- playfield protection and disclosure strategy
|
||||
- explicit anti-patterns to avoid
|
||||
|
||||
Use `../../references/frontend-prompts.md` for concrete prompt shapes.
|
||||
|
||||
## Motion Rules
|
||||
|
||||
- Prefer a few meaningful transitions over constant micro-animation.
|
||||
- Reserve strong motion for state change, reward, danger, and onboarding.
|
||||
- Respect reduced-motion settings for non-essential animation.
|
||||
- Keep 3D HUD motion from competing with camera motion.
|
||||
|
||||
## What Good Looks Like
|
||||
|
||||
- HUD elements are legible without flattening the scene.
|
||||
- Menus feel native to the game world, not like a SaaS admin panel.
|
||||
- Layout adapts cleanly across breakpoints.
|
||||
- Pointer, keyboard, and game-state feedback are obvious.
|
||||
- In 3D games, menu and HUD states do not fight camera control or pointer-lock.
|
||||
- In 3D games, the first playable view keeps most of the viewport available for movement, aiming, and spatial reading.
|
||||
- Persistent information density is low enough that screenshots still read as game scenes, not UI comps.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Generic app dashboard layouts
|
||||
- Flat placeholder styling with no theme
|
||||
- Default font stacks without intent
|
||||
- Dense overlays that obscure the playfield
|
||||
- Large title cards or multi-paragraph notes sitting over a live playable scene
|
||||
- Equal-weight boxed panels distributed around every edge of the viewport
|
||||
- Controls, objectives, notes, and lore all expanded at once on first load
|
||||
- Full-width top-and-bottom chrome with large always-on center or body panels in 3D play
|
||||
- Excessive motion on every element
|
||||
- Canvas-only UI when DOM would be clearer and cheaper
|
||||
- Forcing HUD controls into the 3D scene when standard DOM would be clearer
|
||||
- Letting camera input remain active under modals or inventory panels
|
||||
|
||||
## References
|
||||
|
||||
- Shared architecture: `../web-game-foundations/SKILL.md`
|
||||
- Prompt recipes: `../../references/frontend-prompts.md`
|
||||
- Low-chrome 3D layout patterns: `../../references/three-hud-layout-patterns.md`
|
||||
- React-hosted 3D UI context: `../react-three-fiber-game/SKILL.md`
|
||||
- Playtest review: `../../references/playtest-checklist.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Game UI Frontend"
|
||||
short_description: "Design browser-game HUDs, menus, and overlays"
|
||||
default_prompt: "Design a browser-game UI layer that supports the play experience without crowding the playfield."
|
||||
88
.hermes/plugins/game-studio/skills/phaser-2d-game/SKILL.md
Normal file
88
.hermes/plugins/game-studio/skills/phaser-2d-game/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: phaser-2d-game
|
||||
description: Implement 2D browser games with Phaser. Use when the user wants a Phaser, TypeScript, and Vite stack for scenes, gameplay systems, cameras, sprite animation, and DOM-overlay HUD patterns.
|
||||
---
|
||||
|
||||
# Phaser 2D Game
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill for the main execution path in this plugin. Phaser is the default stack for 2D browser games here because it handles rendering, timing, sprites, cameras, and scene orchestration well without forcing gameplay rules into the framework.
|
||||
|
||||
Preferred stack:
|
||||
|
||||
- Phaser
|
||||
- TypeScript
|
||||
- Vite
|
||||
- DOM-based HUD or menus layered over the game canvas
|
||||
|
||||
## Architecture
|
||||
|
||||
1. Keep gameplay state outside Phaser scenes.
|
||||
- Systems own rules, turn order, movement, combat, inventory, objectives, and progression.
|
||||
- Phaser scenes adapt system state into sprites, camera motion, animation playback, and effects.
|
||||
2. Make scenes thin.
|
||||
- Boot and asset preload
|
||||
- Menu or shell scene
|
||||
- Gameplay scene
|
||||
- Optional overlay or debug scene
|
||||
3. Keep renderer-facing objects disposable.
|
||||
- Sprite containers, emitters, tweens, and camera rigs are view state, not source of truth.
|
||||
4. Favor stable asset manifest keys over direct file-path references throughout gameplay code.
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
- Use one integration boundary where the scene reads simulation state and emits input actions back.
|
||||
- Prefer deterministic system updates over scene-local mutation.
|
||||
- Treat HUD and menus as DOM when text, status density, or responsiveness matter.
|
||||
- Keep animation state derived from gameplay state rather than ad hoc sprite flags.
|
||||
|
||||
## 2D Modes Covered Well
|
||||
|
||||
- Turn-based grids and tactics
|
||||
- Top-down exploration
|
||||
- Side-view action platformers
|
||||
- Character-action combat with sprite animation
|
||||
- Lightweight management or deck-driven battle scenes
|
||||
|
||||
## Camera and Presentation
|
||||
|
||||
- Choose the camera model early: locked, follow, room-based, or tactical-pan.
|
||||
- Keep camera logic separate from game rules.
|
||||
- Use restrained screen shake, hit-stop, and parallax. Effects should improve readability, not obscure it.
|
||||
|
||||
## UI Integration
|
||||
|
||||
- Use DOM overlays for HUD, command menus, settings, and narrative panels.
|
||||
- Keep the canvas responsible for the world, combat readability, and motion.
|
||||
- Avoid shoving dense text or complex settings UIs into Phaser unless the project explicitly needs an in-canvas presentation.
|
||||
|
||||
## Asset Organization
|
||||
|
||||
- `characters/`
|
||||
- `environment/`
|
||||
- `ui/`
|
||||
- `fx/`
|
||||
- `audio/`
|
||||
- `data/`
|
||||
|
||||
Keep manifest keys human-readable and stable.
|
||||
|
||||
## Default Directory Shape
|
||||
|
||||
See `../../references/phaser-architecture.md` for a concrete module split.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Game rules inside `update()` loops without a system boundary
|
||||
- Scene-to-scene state passed through mutable global objects
|
||||
- HUD text rendered in the game canvas just because it is convenient
|
||||
- Asset paths embedded everywhere instead of a manifest layer
|
||||
- Overusing generic React dashboard patterns for game UI
|
||||
|
||||
## References
|
||||
|
||||
- Shared architecture: `../web-game-foundations/SKILL.md`
|
||||
- Frontend direction: `../game-ui-frontend/SKILL.md`
|
||||
- Sprite workflow: `../sprite-pipeline/SKILL.md`
|
||||
- Phaser structure: `../../references/phaser-architecture.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Phaser 2D Game"
|
||||
short_description: "Build 2D browser games with Phaser"
|
||||
default_prompt: "Implement this 2D browser game with Phaser, TypeScript, and a clear gameplay architecture."
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: react-three-fiber-game
|
||||
description: Build React-hosted 3D browser games with React Three Fiber. Use when the user wants pmndrs-based scene composition, shared React state, and 3D HUD integration inside a React app.
|
||||
---
|
||||
|
||||
# React Three Fiber Game
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill when the 3D runtime lives inside a React application. This is the default React-native 3D path in the plugin and should be preferred over vanilla Three.js when the app shell, settings, storefront, editor surface, or surrounding product already uses React.
|
||||
|
||||
Recommended stack:
|
||||
|
||||
- `@react-three/fiber`
|
||||
- `three`
|
||||
- `@react-three/drei`
|
||||
- `@react-three/rapier`
|
||||
- `@react-three/postprocessing`
|
||||
- `@react-three/a11y` when accessibility-sensitive interaction matters
|
||||
- DOM overlays in the normal React tree
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- the project already uses React
|
||||
- the 3D scene must share state with the rest of the app
|
||||
- declarative scene composition is a net gain
|
||||
- the team wants pmndrs helpers instead of building every helper layer by hand
|
||||
|
||||
## Do Not Use This Skill When
|
||||
|
||||
- the app is not React-based
|
||||
- the project wants a cleaner imperative runtime with minimal React coordination
|
||||
- the problem is asset packaging rather than runtime composition
|
||||
|
||||
## Best Fit Scenarios
|
||||
|
||||
- 3D configurators and tool-rich browser products
|
||||
- React apps with embedded game or scene surfaces
|
||||
- 3D menus, editors, or world maps in an existing React app
|
||||
- 3D game UIs that depend on shared app state and non-canvas shells
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Keep simulation state outside render components.
|
||||
- React components should describe scene composition, not become the source of truth for gameplay rules.
|
||||
2. Use React state and scene state deliberately.
|
||||
- Shared UI state can live in app state.
|
||||
- High-frequency simulation should not force the whole app through unnecessary React churn.
|
||||
3. Use pmndrs helpers intentionally.
|
||||
- Drei for controls, loaders, helpers, environments, and common scene primitives.
|
||||
- `@react-three/rapier` for physics integration.
|
||||
- `@react-three/postprocessing` for optional effects.
|
||||
- `@react-three/a11y` when the interaction model benefits from accessible scene semantics.
|
||||
4. Keep HUD, settings, and menus in DOM by default.
|
||||
5. Keep starter scaffolds visually restrained.
|
||||
- Start with one compact objective or status surface and transient prompts.
|
||||
- Keep notes, maps, and multi-step checklists collapsed until opened.
|
||||
- Do not surround the `Canvas` with equally weighted glass cards.
|
||||
|
||||
## Architectural Guidance
|
||||
|
||||
- Use a dedicated scene root component that owns the `Canvas`.
|
||||
- Keep camera rigs and control components isolated from gameplay systems.
|
||||
- Keep loader and asset wrappers predictable.
|
||||
- Keep DOM overlays and the 3D scene coordinated through explicit state boundaries.
|
||||
- If a system needs tight imperative control, isolate it rather than forcing everything into declarative patterns.
|
||||
- If the scene is immediately playable, keep the initial overlay budget low and let the world do more of the onboarding.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Treating React components as the gameplay state store
|
||||
- Pushing heavy per-frame mutation through broad app state
|
||||
- Using R3F only because React is available, even when the project needs a cleaner imperative runtime
|
||||
- Building HUD or inventory UI inside the 3D scene by default
|
||||
- Shipping an initial scaffold with large cards occupying every side of the viewport
|
||||
|
||||
## References
|
||||
|
||||
- Shared architecture: `../web-game-foundations/SKILL.md`
|
||||
- Frontend direction: `../game-ui-frontend/SKILL.md`
|
||||
- 3D HUD layout patterns: `../../references/three-hud-layout-patterns.md`
|
||||
- React Three Fiber stack: `../../references/react-three-fiber-stack.md`
|
||||
- React starter: `../../references/react-three-fiber-starter.md`
|
||||
- GLB loader starter: `../../references/gltf-loading-starter.md`
|
||||
- Rapier starter: `../../references/rapier-integration-starter.md`
|
||||
- 3D asset pipeline: `../../references/web-3d-asset-pipeline.md`
|
||||
- WebGL debugging and perf: `../../references/webgl-debugging-and-performance.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "React Three Fiber Game"
|
||||
short_description: "Build React-hosted 3D browser games"
|
||||
default_prompt: "Build this 3D browser game with React Three Fiber and keep the 3D runtime aligned with the React app shell."
|
||||
102
.hermes/plugins/game-studio/skills/sprite-pipeline/SKILL.md
Normal file
102
.hermes/plugins/game-studio/skills/sprite-pipeline/SKILL.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: sprite-pipeline
|
||||
description: Generate and normalize 2D sprite animations. Use when the user asks for full-strip generation from approved source frames, consistent anchor and scale normalization, or preview assets for browser-game animation.
|
||||
---
|
||||
|
||||
# Sprite Pipeline
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill for 2D sprite generation and normalization. This workflow is intentionally anchored around one approved frame and a whole-strip generation pass because frame-by-frame generation drifts too easily.
|
||||
|
||||
This skill is 2D-specific. If the request is for 3D characters, meshes, or materials, route back through `../game-studio/SKILL.md`.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Start from an approved in-game seed frame.
|
||||
- The seed frame should already reflect the right silhouette, palette, costume, and proportions.
|
||||
2. Build a larger transparent reference canvas around that frame.
|
||||
- Use `../../scripts/build_sprite_edit_canvas.py`.
|
||||
3. Ask for the full animation strip in one edit request.
|
||||
- Do not generate each frame independently unless the user explicitly accepts lower consistency.
|
||||
4. Normalize the result into fixed-size game frames.
|
||||
- Use `../../scripts/normalize_sprite_strip.py`.
|
||||
- Use one shared scale across the whole strip.
|
||||
- Align frames with one shared anchor, typically bottom-center.
|
||||
5. Optionally lock frame 01 back to the shipped seed frame.
|
||||
- Do this when the animation should begin from the exact idle or base pose already in game.
|
||||
6. Render a preview sheet and inspect the animation in-engine before approving it.
|
||||
- Use `../../scripts/render_sprite_preview_sheet.py`.
|
||||
|
||||
## Prompting Rules
|
||||
|
||||
Always preserve these invariants in the prompt:
|
||||
|
||||
- same character
|
||||
- same facing direction
|
||||
- same palette family
|
||||
- same silhouette family
|
||||
- same readable face or key features
|
||||
- same outfit proportions
|
||||
- transparent background
|
||||
- exact frame count and slot layout
|
||||
|
||||
Always ask for:
|
||||
|
||||
- one strip at once
|
||||
- a transparent canvas
|
||||
- no scenery, labels, or poster composition
|
||||
- crisp pixel-art clusters for pixel work
|
||||
- production asset tone, not concept art
|
||||
|
||||
## Using Image Generation
|
||||
|
||||
For live asset generation or edits, use the installed `imagegen` skill in this workspace. This skill defines the game-specific process; `imagegen` handles the API-backed generation or edit execution.
|
||||
|
||||
## Script Recipes
|
||||
|
||||
Create a reference canvas:
|
||||
|
||||
```bash
|
||||
python3 scripts/build_sprite_edit_canvas.py \
|
||||
--seed output/sprites/idle-01.png \
|
||||
--out output/sprites/hurt-edit-canvas.png \
|
||||
--frames 4 \
|
||||
--slot-size 256 \
|
||||
--canvas-size 1024
|
||||
```
|
||||
|
||||
Normalize a raw strip:
|
||||
|
||||
```bash
|
||||
python3 scripts/normalize_sprite_strip.py \
|
||||
--input output/sprites/hurt-raw.png \
|
||||
--out-dir output/sprites/hurt \
|
||||
--frames 4 \
|
||||
--frame-size 64 \
|
||||
--anchor output/sprites/idle-01.png \
|
||||
--lock-frame1
|
||||
```
|
||||
|
||||
Render a preview sheet:
|
||||
|
||||
```bash
|
||||
python3 scripts/render_sprite_preview_sheet.py \
|
||||
--frames-dir output/sprites/hurt \
|
||||
--out output/sprites/hurt-preview.png \
|
||||
--columns 4
|
||||
```
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- proportions stay stable across frames
|
||||
- frame-to-frame size does not drift
|
||||
- action reads clearly at game scale
|
||||
- transparency is preserved
|
||||
- frame 01 matches the shipped sprite when lockback is enabled
|
||||
- preview looks correct before any in-engine asset index update
|
||||
|
||||
## References
|
||||
|
||||
- Detailed workflow: `../../references/sprite-pipeline.md`
|
||||
- Shared frontend context: `../game-ui-frontend/SKILL.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Sprite Pipeline"
|
||||
short_description: "Generate and normalize 2D sprite animations"
|
||||
default_prompt: "Create and normalize 2D sprite animation assets for a browser game with consistent scale and anchors."
|
||||
124
.hermes/plugins/game-studio/skills/three-webgl-game/SKILL.md
Normal file
124
.hermes/plugins/game-studio/skills/three-webgl-game/SKILL.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
name: three-webgl-game
|
||||
description: Implement browser-game runtimes with plain Three.js. Use when the user wants imperative scene control in TypeScript or Vite with GLB assets, loaders, physics, and low-level WebGL debugging.
|
||||
---
|
||||
|
||||
# Three WebGL Game
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill for the default non-React 3D path in the plugin. This is not generic WebGL advice. It is an opinionated stack for browser 3D work:
|
||||
|
||||
- `three`
|
||||
- TypeScript
|
||||
- Vite
|
||||
- GLB or glTF 2.0 assets
|
||||
- Three.js loaders such as `GLTFLoader`, `DRACOLoader`, and `KTX2Loader`
|
||||
- Rapier JS for physics
|
||||
- SpectorJS for GPU and frame debugging
|
||||
- DOM overlays for HUD, menus, and settings
|
||||
|
||||
Use this skill when the project wants direct scene, camera, renderer, and game-loop control. If the app already lives in React, route to `../react-three-fiber-game/SKILL.md` instead.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- the app is plain TypeScript or Vite rather than React-first
|
||||
- the project wants direct imperative control over the render loop
|
||||
- the user asks for Three.js specifically
|
||||
- the runtime needs engine-like control over scene, camera, loaders, and physics
|
||||
|
||||
## Do Not Use This Skill When
|
||||
|
||||
- the 3D scene lives inside an existing React app
|
||||
- the main problem is shipped-asset optimization rather than runtime code
|
||||
- the user explicitly chose Babylon.js or PlayCanvas
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Keep simulation state outside Three.js objects.
|
||||
- Game rules, AI, quest state, timers, and progression should not live inside meshes or materials.
|
||||
2. Treat the render graph as an adapter.
|
||||
- Scene graph, cameras, materials, loaders, and post-processing are view concerns layered over simulation state.
|
||||
3. Keep camera behavior explicit.
|
||||
- Orbit, follow, chase, rail, and first-person styles each need their own control boundary.
|
||||
4. Keep UI out of WebGL unless the presentation absolutely depends on it.
|
||||
- Menus, HUD, inventories, and settings should default to DOM.
|
||||
5. Use GLB or glTF 2.0 as the default shipping model format.
|
||||
- Do not build the runtime around DCC-native formats.
|
||||
6. Use Rapier instead of ad hoc collision code when the game has meaningful 3D physics or collision response.
|
||||
7. Keep the first playable view low-chrome.
|
||||
- Default to one compact objective or status cluster plus transient prompts.
|
||||
- Long notes, lore, and controls references should be collapsed until asked for.
|
||||
- Do not frame the scene with multiple equal-weight cards during normal play.
|
||||
|
||||
## Initial Scaffold UX
|
||||
|
||||
For exploration, traversal, and character-control prototypes, start with a sparse shell:
|
||||
|
||||
- one edge-aligned objective chip
|
||||
- one transient controls hint
|
||||
- one optional compact status strip
|
||||
|
||||
Only add larger UI surfaces when the game loop truly requires them. Journal, quest log, codex, map, and settings surfaces should open on demand, not occupy the viewport by default.
|
||||
|
||||
## Recommended Structure
|
||||
|
||||
Use the module shape in `../../references/three-webgl-architecture.md`, then keep these boundaries clean:
|
||||
|
||||
- `simulation/`: rules, progression, state, and AI
|
||||
- `render/app/`: renderer, scene, camera, resize, context lifecycle
|
||||
- `render/loaders/`: GLTF, Draco, KTX2, texture, and environment loading
|
||||
- `render/objects/`: mesh instantiation and disposal
|
||||
- `render/materials/`: material setup and shader boundaries
|
||||
- `physics/`: Rapier world, bodies, colliders, and simulation bridge
|
||||
- `ui/`: DOM overlays and menus
|
||||
- `diagnostics/`: debug toggles, perf probes, and capture hooks
|
||||
|
||||
## Good Fit Scenarios
|
||||
|
||||
- Exploration demos
|
||||
- Lightweight 3D combat prototypes
|
||||
- Vehicle or traversal prototypes
|
||||
- Scene-driven product or world showcases with gameplay
|
||||
- Material, lighting, or post-process-led experiences
|
||||
- 3D games where camera movement and depth readability are central
|
||||
|
||||
## Loaders, Assets, and Post-Processing
|
||||
|
||||
- Start with `GLTFLoader` for shipped 3D content.
|
||||
- Add `DRACOLoader` or Meshopt-compatible optimization as part of the asset pipeline, not as a random runtime patch.
|
||||
- Use `KTX2Loader` for compressed textures when the asset pipeline provides them.
|
||||
- Prefer built-in Three.js render and post-processing utilities first. Add heavier abstraction only when the project actually needs it.
|
||||
- Keep post-processing optional and measurable. Bloom and color effects should not hide gameplay readability.
|
||||
|
||||
## Shader and Material Guidance
|
||||
|
||||
- Start with standard Three.js materials and correct lighting before reaching for custom shaders.
|
||||
- Use custom shaders only when the visual target genuinely needs them.
|
||||
- Keep shader parameters driven by game state, not by incidental scene mutations.
|
||||
- If a material stack gets complex, isolate it behind material factories instead of scattering shader setup across scene code.
|
||||
|
||||
## Browser Safety
|
||||
|
||||
- Handle resize explicitly.
|
||||
- Expect WebGL context loss and recovery.
|
||||
- Keep a fallback or degraded mode in mind for fragile GPU paths.
|
||||
- Watch texture size, geometry count, draw-call growth, and post-processing cost.
|
||||
- Use SpectorJS when the scene behaves incorrectly or frame cost is unclear.
|
||||
|
||||
## Scope Warning
|
||||
|
||||
Do not claim that this plugin offers equal 3D depth to the Phaser track. It supports serious 3D implementation, but the plugin is still 2D-first overall.
|
||||
|
||||
## References
|
||||
|
||||
- Shared architecture: `../web-game-foundations/SKILL.md`
|
||||
- Frontend direction: `../game-ui-frontend/SKILL.md`
|
||||
- 3D HUD layout patterns: `../../references/three-hud-layout-patterns.md`
|
||||
- Three.js ecosystem: `../../references/threejs-stack.md`
|
||||
- Three.js structure: `../../references/three-webgl-architecture.md`
|
||||
- Vanilla starter: `../../references/threejs-vanilla-starter.md`
|
||||
- GLB loader starter: `../../references/gltf-loading-starter.md`
|
||||
- Rapier starter: `../../references/rapier-integration-starter.md`
|
||||
- 3D asset pipeline: `../../references/web-3d-asset-pipeline.md`
|
||||
- WebGL debugging and perf: `../../references/webgl-debugging-and-performance.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Three WebGL Game"
|
||||
short_description: "Build browser-game runtimes with Three.js"
|
||||
default_prompt: "Implement this browser-game runtime with plain Three.js and keep the scene architecture easy to debug."
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: web-3d-asset-pipeline
|
||||
description: Prepare and optimize browser-game 3D assets. Use when the user asks for GLB or glTF shipping work, including Blender cleanup and export, collision or LOD setup, compression, texture packaging, and runtime validation.
|
||||
---
|
||||
|
||||
# Web 3D Asset Pipeline
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill for shipped 3D assets, not runtime scene code. The default output format for browser 3D work in this plugin is GLB or glTF 2.0. The goal is predictable runtime assets, not whatever the DCC tool happened to export first.
|
||||
|
||||
This guidance is engine-agnostic and can serve Three.js, React Three Fiber, Babylon.js, or PlayCanvas.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- the task is about GLB or glTF shipping format
|
||||
- the task is about model cleanup, texture packaging, compression, LOD, or collision proxies
|
||||
- the runtime stack is already chosen and the remaining problem is asset quality or size
|
||||
|
||||
## Do Not Use This Skill When
|
||||
|
||||
- the task is about scene, camera, renderer, or game-loop structure
|
||||
- the task is purely about React versus vanilla Three.js routing
|
||||
- the user is still deciding between runtime engines
|
||||
|
||||
## Default Pipeline
|
||||
|
||||
1. Author and clean the source asset in a DCC tool such as Blender.
|
||||
2. Export to GLB or glTF 2.0.
|
||||
3. Optimize with glTF Transform.
|
||||
4. Validate naming, pivots, transforms, material reuse, and texture budgets.
|
||||
5. Add collision proxies, LOD strategy, and baked-lighting assumptions as needed.
|
||||
6. Ship the optimized asset and load it with engine-native GLTF support.
|
||||
|
||||
## Format Rules
|
||||
|
||||
- Default shipping format: GLB or glTF 2.0.
|
||||
- Do not treat FBX, OBJ, or DCC-native formats as the long-term runtime contract.
|
||||
- Apply or normalize transforms before shipping.
|
||||
- Keep units, pivots, and orientation conventions consistent across the whole asset set.
|
||||
|
||||
## Optimization Rules
|
||||
|
||||
- Use glTF Transform for pruning, deduplication, simplification, and packaging.
|
||||
- Use geometry compression intentionally.
|
||||
- Draco is a valid option when decode cost and compatibility fit the runtime.
|
||||
- Meshopt is often a strong default for web delivery.
|
||||
- Compress textures deliberately.
|
||||
- Use KTX2 or BasisU when the runtime stack supports it.
|
||||
- Use WebP or AVIF where they make sense in the broader asset pipeline.
|
||||
- Reuse materials and textures where possible to cut memory and draw-call cost.
|
||||
|
||||
## Runtime-Ready Asset Rules
|
||||
|
||||
- Keep model hierarchy names stable and meaningful.
|
||||
- Set pivots and origins for gameplay interaction, not just for DCC convenience.
|
||||
- Author explicit collision proxies for physics-heavy scenes.
|
||||
- Decide whether lighting is dynamic, baked, or hybrid before final export.
|
||||
- Plan LODs for large environments or repeated props.
|
||||
- Keep texture resolution proportional to on-screen use, not source-art ambition.
|
||||
|
||||
## Common Failure Modes
|
||||
|
||||
- Shipping raw DCC exports without cleanup
|
||||
- Too many unique materials
|
||||
- Texture sizes far above visible need
|
||||
- Missing collision proxies
|
||||
- Scale or pivot mismatches between assets
|
||||
- Runtime code compensating for asset mistakes that should be fixed upstream
|
||||
|
||||
## References
|
||||
|
||||
- Three.js stack: `../../references/threejs-stack.md`
|
||||
- React Three Fiber stack: `../../references/react-three-fiber-stack.md`
|
||||
- GLB loader starter: `../../references/gltf-loading-starter.md`
|
||||
- Rapier starter: `../../references/rapier-integration-starter.md`
|
||||
- 3D asset pipeline reference: `../../references/web-3d-asset-pipeline.md`
|
||||
- Alternative engines: `../../references/alternative-3d-engines.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Web 3D Asset Pipeline"
|
||||
short_description: "Prepare and optimize browser-game 3D assets"
|
||||
default_prompt: "Prepare these browser-game 3D assets for shipping as predictable runtime-ready GLB or glTF files."
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: web-game-foundations
|
||||
description: Set browser-game architecture before implementation. Use when the user needs engine choice, simulation and render boundaries, input model, asset organization, or save/debug/performance strategy.
|
||||
---
|
||||
|
||||
# Web Game Foundations
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to establish the non-negotiable architecture before implementation starts. Browser games degrade quickly when simulation, rendering, UI, asset loading, and input handling are mixed together.
|
||||
|
||||
Default rule: simulation state is owned outside the renderer, browser UI is not forced into the canvas unless there is a clear reason, and shipped 3D assets default to GLB or glTF 2.0 rather than ad hoc model formats.
|
||||
|
||||
## Use This Skill When
|
||||
|
||||
- the user has not settled the engine or renderer choice
|
||||
- the task is about boundaries, module shape, state ownership, or asset policy
|
||||
- multiple specialist skills need one shared architectural frame
|
||||
|
||||
## Do Not Stay Here When
|
||||
|
||||
- the runtime track is clearly Phaser
|
||||
- the runtime track is clearly vanilla Three.js
|
||||
- the runtime track is clearly React Three Fiber
|
||||
- the task is purely about shipped 3D assets
|
||||
|
||||
Once the stack is clear, hand off to the runtime or asset specialist skill.
|
||||
|
||||
## Architecture Rules
|
||||
|
||||
1. Separate simulation from rendering.
|
||||
- Simulation owns entities, turns, timers, collisions, progression, and saveable state.
|
||||
- The renderer owns scene composition, animation playback, camera, particles, and input plumbing.
|
||||
2. Keep input mapping explicit.
|
||||
- Define actions such as `move`, `confirm`, `cancel`, `ability-1`, and `pause`.
|
||||
- Map physical inputs to actions in one place.
|
||||
3. Treat asset loading as a first-class system.
|
||||
- Use stable manifest keys.
|
||||
- Group by domain: characters, environment, UI, audio, FX.
|
||||
- For 3D content, standardize on GLB or glTF 2.0 unless the chosen engine ecosystem requires another format upstream.
|
||||
4. Define save/debug/perf boundaries up front.
|
||||
- Save serializable simulation state, not renderer objects.
|
||||
- Keep debug overlays and perf probes easy to toggle.
|
||||
5. Use DOM overlays for menus and HUD by default.
|
||||
- Canvas or WebGL should handle the playfield.
|
||||
- DOM should handle text-heavy HUD, menus, settings, and accessibility-sensitive controls.
|
||||
- In 3D, keep the persistent UI budget small so the scene stays readable and interactive.
|
||||
6. Lock 3D runtime conventions early.
|
||||
- Choose consistent units, origins, pivots, and naming conventions.
|
||||
- Decide how collision proxies, LODs, and baked lighting data are authored before runtime integration starts.
|
||||
|
||||
## Engine Selection
|
||||
|
||||
- Default to Phaser for 2D games with sprites, tilemaps, top-down or side-view action, turn-based grids, and classic browser arcade flows.
|
||||
- Default to vanilla Three.js for explicit 3D scenes that want direct scene, camera, renderer, and loop control in plain TypeScript or Vite.
|
||||
- Default to React Three Fiber when the 3D scene lives inside a React application and needs declarative composition, shared app state, or React-first UI coordination.
|
||||
- Use raw WebGL only for shader-heavy or renderer-first projects where engine abstractions would get in the way.
|
||||
- Keep Babylon.js and PlayCanvas as alternative-engine paths rather than the default code-generation target.
|
||||
|
||||
See `../../references/engine-selection.md` for the default decision table.
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
Define these before writing core code:
|
||||
|
||||
- Player fantasy and primary verbs
|
||||
- Core loop and loss or reset states
|
||||
- Camera model
|
||||
- Input action map
|
||||
- Simulation modules
|
||||
- Renderer modules
|
||||
- Asset manifest layout
|
||||
- 3D asset format and optimization rules
|
||||
- HUD and menu surfaces
|
||||
- Save data boundary
|
||||
- Debug and perf surfaces
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Mixing gameplay rules directly into scene callbacks
|
||||
- Treating the renderer as the source of truth for game state
|
||||
- Putting all HUD and menu UI into the canvas by default
|
||||
- Letting asset filenames become the public API instead of manifest keys
|
||||
- Shipping unoptimized 3D assets straight from the DCC tool into the browser
|
||||
- Mixing camera-control state and menu or modal state without an explicit input boundary
|
||||
- Rebuilding architecture every time the game changes genre
|
||||
|
||||
## References
|
||||
|
||||
- Engine selection: `../../references/engine-selection.md`
|
||||
- Phaser structure: `../../references/phaser-architecture.md`
|
||||
- Three.js structure: `../../references/three-webgl-architecture.md`
|
||||
- Three.js ecosystem stack: `../../references/threejs-stack.md`
|
||||
- React Three Fiber stack: `../../references/react-three-fiber-stack.md`
|
||||
- 3D asset shipping: `../../references/web-3d-asset-pipeline.md`
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Web Game Foundations"
|
||||
short_description: "Set browser-game architecture before implementation"
|
||||
default_prompt: "Establish the core architecture for this browser game before implementation starts."
|
||||
@@ -16,6 +16,64 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
|
||||
|
||||
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。
|
||||
- 决策:热身舞台统一采用绘本草地视觉语言,真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`,生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
|
||||
- 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
|
||||
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。
|
||||
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。
|
||||
|
||||
## 2026-05-10 运行态输入设备抽象层全项目通用化
|
||||
|
||||
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
|
||||
- 决策:前端运行态输入统一通过 `src/services/input-devices/` 承接,设备适配层只输出 `press / move / release / tap / drop` 等通用语义和通用坐标;玩法组件自己解释目标对象、落点和业务动作,输入层不得写拼图等玩法专用规则。
|
||||
- 影响范围:拼图运行态鼠标/触控/mocap 输入、后续运行态设备接入、运行态输入技术文档与相关前端回归测试。
|
||||
- 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts`、`npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。
|
||||
- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`、`docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`。
|
||||
|
||||
## 2026-05-11 前端调试模式统一判断
|
||||
|
||||
- 背景:拼图 mocap 调试面板此前在运行态常驻展示,生产构建和正式体验里容易遮挡棋盘内容;后续其它局部诊断 UI 也需要统一的调试模式入口。
|
||||
- 决策:前端新增 `src/config/debugMode.ts` 作为全局调试模式判断,默认跟随 Vite 开发态,允许 `VITE_DEBUG_MODE=true/false` 显式覆盖。拼图运行态 mocap 调试面板只在调试模式下渲染,并默认折叠,只保留连接状态行。
|
||||
- 影响范围:前端局部调试 UI、拼图运行态 mocap 诊断面板、`.env.example` 和运行态输入技术文档。
|
||||
- 验证方式:执行 `npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。
|
||||
- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`。
|
||||
|
||||
## 2026-05-10 儿童动作热身关直接消费 mocap 数据源
|
||||
|
||||
- 背景:儿童动作 Demo 不能只依赖浏览器摄像头状态和键鼠调试输入,否则真实硬件接入后会出现“mocap 在线但页面提示摄像头不可用”或“能看到画面但动作不推进”的卡点。
|
||||
- 决策:热身关全流程直接接入 `useMocapInput`,通过本地 mocap WebSocket `/stream` 消费 `general.body.center_norm` 身体中心、`actions/action/gesture/gestures/event/name/type` 动作名,以及 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 手部坐标;位置步骤由身体中心推进,`wave_greeting`、`wave_left_hand`、`wave_right_hand` 和 `jump_once` 由 mocap 手势/轨迹推进。浏览器摄像头只作为背景层,动作数据源状态优先展示,键鼠仍作为本地调试兜底。
|
||||
- 影响范围:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、对应单测与热身关技术文档。
|
||||
- 验证方式:执行 `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`、`npx eslint ...`、`npm run typecheck`、`npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 可握手、`http://127.0.0.1:3000/child-motion-demo` 可访问。
|
||||
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine
|
||||
|
||||
- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。
|
||||
- 决策:所有 GPT-image-2 生图请求统一走 VectorEngine `POST /v1/images/generations`,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2-all`,请求体不再携带 `official_fallback`,参考图字段改为 `image`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
|
||||
- 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。
|
||||
- 验证方式:执行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run api-server` + `/healthz` 做后端 smoke。
|
||||
- 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。
|
||||
|
||||
## 2026-05-08 Hyper3D Rodin Gen-2 只通过后端安全代理接入
|
||||
|
||||
- 背景:需要接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型,但供应商 API Key 不能进入前端、文档或 Git;本次只是外部副作用代理,不需要新增平台真相表。
|
||||
- 决策:Hyper3D 统一走 `api-server` 的 `/api/assets/hyper3d/*` 鉴权路由,配置只读取 `HYPER3D_BASE_URL` / `HYPER3D_API_KEY` / `HYPER3D_MODEL_REQUEST_TIMEOUT_MS` 及兼容 `RODIN_*` 变量;生成提交、状态查询和下载列表都由后端代理。首版不写 SpacetimeDB、不确认 `asset_object`,下载链接后续由调用方决定是否进入 OSS 资产链。
|
||||
- 影响范围:`api-server` 外部服务配置、Hyper3D route、`shared-contracts` / TS contract、前端 service、生产环境示例和外部服务环境变量文档。
|
||||
- 验证方式:执行 `cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts hyper3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck` 和编码检查;真实 API smoke 只在本地私密环境配置 key 后手动执行。
|
||||
- 关联文档:`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。
|
||||
|
||||
## 2026-05-08 APIMart 接口统一携带 `official_fallback`
|
||||
|
||||
> 2026-05-09 追认:本决策中的图片生成部分已被“GPT-image-2 图片生成统一迁移到 VectorEngine”覆盖;当前只保留 APIMart `gpt-5` Responses 文本/多模态链路继续显式携带 `official_fallback`。
|
||||
|
||||
- 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。
|
||||
- 决策:凡是仓库内调用 APIMart 的 OpenAI 兼容接口,请求体统一携带 `official_fallback: true`;其中图片生成请求直接固定写入,`platform-llm` 的 APIMart GPT-5 client 通过显式开关开启,不默认扩散到 Ark 等其它 provider。
|
||||
- 影响范围:`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle.rs`、`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/platform-llm/src/lib.rs`、`.codex/skills/gpt-image-2-apimart/` 和相关技术文档。
|
||||
- 验证方式:图片生成与 creative-agent APIMart 路径的单测都应断言 `official_fallback` 已写入请求 JSON;编码检查和相关 Rust 测试应持续通过。
|
||||
- 关联文档:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md`、`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`。
|
||||
|
||||
## 2026-05-07 server-rs Cargo 依赖集中到 workspace
|
||||
|
||||
- 背景:`server-rs` 多 crate 已稳定成 DDD workspace,成员 `Cargo.toml` 中重复散写第三方版本和本地 path 依赖,升级 SpacetimeDB SDK、`serde`、`reqwest`、`tokio` 等依赖时容易漂移。
|
||||
@@ -24,6 +82,14 @@
|
||||
- 验证方式:修改 Cargo 配置后先执行 `cargo metadata --manifest-path server-rs\Cargo.toml --format-version 1 --no-deps`,再按影响范围执行 `cargo check`、DDD 边界检查和编码检查。
|
||||
- 关联文档:`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`。
|
||||
|
||||
## 2026-05-08 资料页反馈提交必须走 Rust 后端与 SpacetimeDB
|
||||
|
||||
- 背景:`/profile/feedback` 首版页面曾只做前端成功态,无法沉淀到用户账号和数据库,也容易与主站平台主题脱节。
|
||||
- 决策:反馈提交统一走鉴权 HTTP 路由 `POST /api/profile/feedback`,由 `api-server` 取当前 access token 用户,调用 `spacetime-client` facade,再通过 `spacetime-module` procedure 写入私有表 `profile_feedback_submission`;前端只负责输入采集、Data URL 预览和提交元数据,不再保存 `File[]` 作为外部契约。
|
||||
- 影响范围:`src/components/platform-entry/PlatformFeedbackView.tsx`、`src/services/rpg-entry/rpgProfileClient.ts`、`packages/shared/src/contracts/runtime.ts`、`server-rs/crates/shared-contracts`、`api-server`、`module-runtime`、`spacetime-client`、`spacetime-module`、表目录与 bindings。
|
||||
- 验证方式:前端定向测试应覆盖 Data URL 预览与 `/api/profile/feedback` 请求体;后端变更需同步 `migration.rs`、`SPACETIMEDB_TABLE_CATALOG.md` 和生成绑定;API smoke 使用 `npm run api-server` 和 `/healthz`。
|
||||
- 关联文档:`docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。
|
||||
|
||||
## 2026-05-06 Maincloud 历史残留引用禁止再使用
|
||||
|
||||
- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。
|
||||
@@ -40,6 +106,142 @@
|
||||
- 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。
|
||||
- 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 text-game 作为百梦幕间文字游戏模板接入
|
||||
|
||||
- 背景:团队希望参考 MOKU / 幕间类 AI 文游,设计可在百梦内落地的 AI 文字游戏模板,但不能把外部平台社区、支付、榜单、论坛、账号或私有存档迁入 Genarrative。
|
||||
- 决策:新增 `text-game` 作为百梦 AI 原生文字游戏模板口径,展示名可用“幕间”或“幕间文字”;它与 `visual-novel` 分离,重点是 AI GM、自由行动、状态后果、长期记忆、章节目标和轻量剧本模拟器;入口、作品、发布、资产、钱包、埋点、存档和广场全部复用百梦平台接口;禁止新增 replay、外部社区、外部支付、外部榜单和私有存档系统。
|
||||
- 影响范围:后续 `text-game` shared contracts、`module-text-game`、SpacetimeDB 表、`api-server` 路由、前端入口 / workspace / result / runtime、平台作品架和发现聚合。
|
||||
- 验证方式:后续落地时确认路由使用 `/api/creation/text-game/*` 与 `/api/runtime/text-game/*`;确认正式业务真相在 Rust / SpacetimeDB 后端;确认没有 `replay` 能力和外部平台功能误入;确认 `text-game` 不复用 `visual-novel` step 契约作为运行态真相。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 2048 玩法模板采用 `twenty-forty-eight` 工程域
|
||||
|
||||
- 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。
|
||||
- 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`,Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。
|
||||
- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight`、`shared-contracts`、`spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。
|
||||
- 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 幸存者类玩法作为平台模板接入
|
||||
|
||||
- 背景:平台继续扩展新玩法模板,需要把幸存者 / 割草 / 轻度 Roguelite 类玩法纳入统一创作中心、作品架、广场和运行态体系,避免再起一套独立小游戏工程。
|
||||
- 决策:新增 `survivor` 作为 Genarrative 平台玩法模板,统一使用 `server-rs + Axum + SpacetimeDB`,创作端、结果页、试玩、发布和运行态都复用平台接口;前端只负责表现和高频模拟,不承接正式规则真相。
|
||||
- 影响范围:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`、后续 `survivor` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台作品闭环。
|
||||
- 验证方式:后续落地时检查 `survivor` 入口、session、work profile、runtime run、checkpoint、升级候选和结算接口是否都落在平台统一链路内,并确认没有新增独立小游戏壳层。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 视觉小说 TXT 玩法只作为平台模板接入且删除回放
|
||||
|
||||
- 背景:`Interactive-fiction-backend` 与 `Interactive-fiction-frontend` 是完整平台类工程,其中 TXT / Galgame 玩法可借鉴,但账号、商城、后台、公开市场、回放等平台能力不能迁入 Genarrative。
|
||||
- 决策:`visual-novel` 只作为 Genarrative 视觉小说模板接入,保留想法 / 文档 / 空白创建、世界观 / 角色 / 场景 / 剧情阶段编辑、视觉小说 step 运行时、历史和重生成等模板能力;入口、作品、发布、资产、钱包、存档和广场全部使用 Genarrative 平台接口;彻底删除回放、分享回放、回放编译、回放路由、回放表和回放 UI。
|
||||
- 影响范围:视觉小说 PRD、旧 TXT 文档口径、后续 `visual-novel` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台存档接入。
|
||||
- 验证方式:后续落地时扫描前端、后端、契约、表和文档,确认不存在 `replay` 能力;确认视觉小说没有迁入外部平台账号、订单、会员、促销、后台、公开市场或私有存档系统;确认后端落在 `server-rs + Axum + SpacetimeDB`。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md`、`docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md`。
|
||||
|
||||
## 2026-05-05 视觉小说 VN-02 表与 spacetime-client facade 收口
|
||||
|
||||
- 背景:`visual-novel` 后续 API、创作工作台和运行时需要稳定的 SpacetimeDB schema 与 Rust facade,且必须延续“无回放、无私有存档”的产品边界。
|
||||
- 决策:视觉小说首批数据库只落六张表:`visual_novel_agent_session`、`visual_novel_agent_message`、`visual_novel_work_profile`、`visual_novel_runtime_run`、`visual_novel_runtime_history_entry`、`visual_novel_runtime_event`;`visual_novel_runtime_event` 是 `public event` 审计事件表,不是 replay 数据源;运行历史只保存继续体验与历史重生成需要的 typed step 和快照哈希。`api-server` 后续接入必须经 `spacetime-client/src/visual_novel.rs` typed facade,不直接依赖生成 bindings。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/visual_novel.rs`、`migration.rs`、`server-rs/crates/spacetime-client/src/visual_novel.rs`、`module_bindings/`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`、VN-05 API 联调。
|
||||
- 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`npm run check:encoding`;扫描视觉小说 schema / facade / 表目录确认没有 `replay` 表、路由或私有 save 表。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
|
||||
## 2026-05-05 视觉小说 VN-07 前端创作闭环按阶段边界落地
|
||||
|
||||
- 背景:`visual-novel` 模板需要先完成创作工作台与结果页,真实生成和正式玩家 runtime 仍依赖 VN-05 后端路由。
|
||||
- 决策:VN-07 前端只接入口、Agent 工作台、可编辑 `VisualNovelResultDraft` 结果页和测试 run;`blank` 起点直接生成本地空白草稿进入结果页,`idea` / `document` 继续调用 `/api/creation/visual-novel/sessions`;结果页保存先更新当前 session 草稿,显式“编译草稿”才调用 `/compile`,测试 run 在真实 runtime 不可用时降级为本地 test run。
|
||||
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/visual-novel-creation/`、`src/components/visual-novel-result/`、`packages/shared/src/contracts/visualNovel.ts`、视觉小说 PRD。
|
||||
- 验证方式:执行前端 typecheck、视觉小说工作台 / 结果页定向测试和编码检查;确认未新增 replay、作品聚合或正式 runtime 能力。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-07 视觉小说 VN-11 负向扫描门禁
|
||||
|
||||
- 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。
|
||||
- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/`、`packages/shared/src/`、`server-rs/crates/`、`docs/` 与 `.hermes/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。
|
||||
- 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。
|
||||
- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`。
|
||||
|
||||
## 2026-05-07 视觉小说 VN-12 采用单独验收门禁脚本
|
||||
|
||||
- 背景:VN-12 是视觉小说模板的全链路联调与自动化验收收口任务,需要把关键路径、API smoke、前端测试和报告输出固化成可复跑门禁,避免后续改动只靠手工口述结论。
|
||||
- 决策:新增 `npm run check:visual-novel-vn12`,由 `scripts/check-visual-novel-vn12-acceptance.mjs` 校验 PRD、VN-11 报告、关键前端测试、视觉小说 service client、`api-server` / `module-visual-novel` / `shared-contracts` 相关文件和路由命中,并生成 `docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`。
|
||||
- 影响范围:VN-12 验收、视觉小说后续回归、同类玩法的收口门禁模式。
|
||||
- 验证方式:执行 `npm run check:visual-novel-vn12 -- --write-report`,报告应覆盖自动化验收清单、API smoke、前端关键路径、桌面/移动端检查说明和已执行命令;若脚本失败,直接回流到对应 owner 修复。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`。
|
||||
|
||||
## 2026-05-07 视觉小说 VN-13 文档与交接收口
|
||||
|
||||
- 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。
|
||||
- 决策:视觉小说后续维护的正式入口固定为 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`SPACETIMEDB_TABLE_CATALOG.md`、`VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md`、`VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md` 和 `VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;旧 TXT 迁移文档仅保留历史参考地位。
|
||||
- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。
|
||||
- 验证方式:打开上述文档即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`。
|
||||
|
||||
## 2026-05-05 平台移动端一级 Tab 改为推荐/发现/创作/草稿/我的
|
||||
|
||||
- 背景:移动端平台入口需要从旧“首页/排行/创作/存档/我的”调整为更直接的推荐流和应用式底部导航。
|
||||
- 决策:前端内部继续复用 `PlatformHomeTab` 的 `home/category/create/saves/profile` 状态值,但用户看到的一级 Tab 分别为“推荐/发现/创作/草稿/我的”;`home` 直接展示公开推荐流,`category` 承载发现页及排行子 Tab,`saves` 承载草稿作品架,原存档结构并入“我的-玩过”弹层。
|
||||
- 影响范围:平台入口导航、移动端推荐页、发现页子 Tab、创作中心作品架、个人页玩过弹层、相关设计文档。
|
||||
- 验证方式:检查移动端底部导航文案和顺序,确认登录态为“推荐/发现/创作/草稿/我的”,未登录态为“推荐/创作/发现”且创作居中;“推荐”无搜索/频道栏直出作品流,“发现”包含搜索/推荐/今日/分类/排行,“创作”只显示新建入口,“草稿”显示作品架,“我的-玩过”可恢复存档。
|
||||
- 关联文档:`docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 创作 Tab 固定为智能创作首页,草稿 Tab 承接旧作品架
|
||||
|
||||
- 背景:创作首页需要变成面向对话式生成的智能创作页,旧模板卡和作品架继续保留但不应再占据创作首屏。
|
||||
- 决策:`create` 只承载 `CreativeAgentHome` 智能创作首页与会话流,顶部品牌栏、问候、快捷胶囊、底部输入框和左侧抽屉是主结构;旧的新建作品类型卡不再在 `create` 里展示。原本的 RPG / 拼图 / 大鱼 / Match3D / 方洞 / 视觉小说作品架统一归到 `saves` 草稿 Tab。
|
||||
- 影响范围:平台创作页布局、创作首页抽屉、草稿页作品架、相关交互测试、旧创作入口 helper。
|
||||
- 验证方式:移动端点击“创作”直接看到智能创作首页;点击“草稿”看到旧作品架;旧模板入口不再从创作页出现。
|
||||
- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 创意互动内容生成 Agent 采用 LangChain-Rust 六模块闭环
|
||||
|
||||
- 背景:需要支持用户输入文字、图片或文档后,先理解创作意图,再从多个模板候选中选择一个,并把内容填入拼图等目标玩法草稿契约中。
|
||||
- 决策:新增方案改为基于 LangChain-Rust 的六模块 Agent 架构,核心模块是感知、思考、记忆、行动、反思、协作;首版只支持拼图玩法,但必须先展示多个拼图子模板候选,用户选择某个模板后,再确认该模板下的关卡模式、关卡数和预计积分范围,确认后才生成草稿;Agent 理解、规划和修订统一使用 APIMart Responses `gpt-5` 并支持文本/图像多模态输入;Agent 创作方式就是填充和修订模板草稿字段,表单化创作页与 Agent 自然语言修订都操作同一份 `PuzzleResultDraft`,且草稿可编辑字段只收敛为 `workTitle`、`workDescription`、`workTags`、`levels[].levelName`、`levels[].pictureDescription`、`levels[].pictureReference`;其中 `pictureReference` 已采用 `PuzzleDraftLevel.pictureReference` / Rust `picture_reference` 正式字段方案,不再走 metadata 过渡;单关卡/多关卡图片生成通过拼图模块 Tool 与模板协议实现;生成好的内容必须可立即试玩。
|
||||
- 影响范围:创作中心入口、`platform-agent`、`module-creative-agent`、`module-puzzle` 拼图模板协议和工具、`shared-contracts`、`api-server` creative facade、SpacetimeDB creative agent 表、拼图玩法工具。
|
||||
- 验证方式:后续落地时以创意互动内容生成 Agent 技术方案和 Phase 1 PRD 为编码依据,优先完成拼图 Phase 1,并执行 shared contracts、module、platform-agent、api-server、前端 typecheck 与编码检查。
|
||||
- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`、`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 creative-agent Task C 首版平台 PoC 已落地
|
||||
|
||||
- 背景:Phase 1 的平台侧需要先把 LangChain-Rust 适配层、APIMart `gpt-5` 多模态 Responses 请求和工具注册边界立起来,才能继续接 API facade。
|
||||
- 决策:新增 `server-rs/crates/platform-agent` 作为独立 workspace crate,保留项目自有 `CreativeAgentExecutor`、工具注册表、回调事件和 mock executor;`platform-llm` 的 Responses 请求体扩展为可序列化 `input_text` / `input_image` content part。
|
||||
- 影响范围:`server-rs/Cargo.toml`、`server-rs/crates/platform-agent`、`server-rs/crates/platform-llm`、任务 C 的后续 API / SSE 接入。
|
||||
- 验证方式:`cargo check -p platform-agent`、`cargo test -p platform-agent`、`cargo test -p platform-llm responses_multimodal` 已通过。
|
||||
- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`。
|
||||
|
||||
## 2026-05-05 creative-agent Task E API / SSE facade 已落地
|
||||
|
||||
- 背景:Phase 1 需要先把创意 Agent 的 HTTP/SSE 门面接入 Rust `api-server`,用于前端工作区调用和拼图模板确认闭环。
|
||||
- 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由;creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner;未确认模板前不创建拼图 session,`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session`;`gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client,不复用通用 `GENARRATIVE_LLM_API_KEY`。
|
||||
- 影响范围:`server-rs/crates/api-server/src/creative_agent.rs`、`creative_agent_sse.rs`、`app.rs`、`state.rs`、`module-puzzle` creative template/tool、Phase 1 PRD。
|
||||
- 验证方式:`cargo check -p api-server`、`cargo test -p module-puzzle creative`、`cargo test -p api-server creative_agent`、`npm run api-server` 后检查 `/healthz`、`POST /api/runtime/creative-agent/sessions`、`POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`。
|
||||
- 关联文档:`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-10 视觉小说入口收敛为单句创作 + 画风选择
|
||||
|
||||
- 背景:视觉小说入口页要对齐抓大鹅式的线性创作入口,只保留最小可用输入,避免再暴露文档 / 空白 / 对话式工作台。
|
||||
- 决策:入口页只展示一句话创作输入框和横向视觉画风卡片;画风通过 `seedText` 追加 `视觉画风` 和 `画风要求` 两行透传给既有创作链路;点击生成后先进入 `visual-novel-generating` 过程页,再自动进入 `visual-novel-result`。
|
||||
- 影响范围:`VisualNovelAgentWorkspace`、`PlatformEntryFlowShellImpl`、`platformEntryTypes`、视觉小说 PRD;不新增后端字段或数据库结构。
|
||||
- 验证方式:视觉小说工作台单测通过,`npm run check:encoding` 通过;`npm run typecheck` 仍受仓库里 `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` 的既有类型错误影响。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-07 移动端整页缩放由入口统一锁定
|
||||
|
||||
- 背景:移动端游戏式页面如果允许浏览器整页缩放,容易把固定画布、HUD 和底部操作区一起放大或缩小,破坏操作节奏。
|
||||
- 决策:主站入口统一使用 `viewport` 锁定 `minimum-scale=1.0`、`maximum-scale=1.0`、`user-scalable=no` 和 `viewport-fit=cover`,并在应用启动时调用 `lockMobileViewportZoom()` 拦截 iOS `gesture*` 与多指 `touchmove` 触发的页面级缩放。
|
||||
- 影响范围:主站 `index.html`、`src/main.tsx`、后续所有依赖主入口的移动端游戏/画布页面;不要求每个画布组件重复实现缩放锁定。
|
||||
- 验证方式:移动端打开主站后,双指捏合和快速双击不应再缩放整页;单指滚动、点击和组件内交互保持正常。
|
||||
- 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。
|
||||
|
||||
## 2026-05-07 视觉小说 VN-10 资产引用统一走平台资产对象
|
||||
|
||||
- 背景:视觉小说文档输入、封面、场景背景、角色立绘和音乐需要接入平台资产链路,不能在前端状态或 SpacetimeDB 中保存大 Data URL、二进制对象或外部 R2 路径。
|
||||
- 决策:VN 上传统一复用 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm`、`/api/assets/read-url`。文档上传后只把 `assetObjectId` 放入 `sourceAssetIds`,`seedText` 仅放截断摘要;封面、场景、角色、音乐只写 `/generated-*` 引用和平台 asset id。角色立绘写入 `imageAssets[].source = platform_asset`。运行时图片渲染统一使用 `ResolvedAssetImage` 换签。
|
||||
- 影响范围:`src/services/visual-novel-creation/visualNovelAssetClient.ts`、`VisualNovelAgentWorkspace`、`VisualNovelResultView`、`VisualNovelRuntimeShell`、`server-rs/crates/api-server/src/visual_novel.rs`。
|
||||
- 验证方式:VN 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`cargo test -p api-server visual_novel`、`cargo test -p api-server creation_agent_document_input`。
|
||||
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
|
||||
|
||||
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
||||
|
||||
@@ -91,6 +91,8 @@ npm run spacetime:generate
|
||||
|
||||
## 常用检查命令
|
||||
|
||||
- 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。
|
||||
|
||||
编码检查:
|
||||
|
||||
```bash
|
||||
@@ -183,6 +185,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- 移动端优先,再兼容网页端。
|
||||
- 页面只展示后端返回的状态,不自行计算结论型业务状态。
|
||||
- 创作中心入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;前端只在 `platformEntryCreationTypes.ts` 做展示派生,api-server 路由熔断也使用同一份配置,禁止恢复前端硬编码入口配置文件。
|
||||
- 优先复用现有面板、抽屉、弹窗,不新建独立大系统。
|
||||
- 不在 UI 中默认写功能说明类文本。
|
||||
- 弹出独立面板的交互不要实现成在当前面板下方追加内容。
|
||||
|
||||
@@ -43,6 +43,54 @@
|
||||
- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。
|
||||
- 关联:`.hermes/README.md`。
|
||||
|
||||
## 儿童动作 Demo 卡在摄像头不可用或挥手不推进先查 mocap 消费链路
|
||||
|
||||
- 现象:`/child-motion-demo` 打开后即使 `http://127.0.0.1:8876/` 已启动,页面仍提示“摄像头暂不可用”,或到“打个招呼”、左右手挥动、站位步骤时真实硬件动作无法检测通过,只能用鼠标拖拽或键盘调试继续。
|
||||
- 原因:浏览器摄像头视频流只是舞台背景;如果热身关把 `getUserMedia` 状态当成主动作数据源,或只在 gesture 阶段消费 `useMocapInput`,就会错过 mocap 的身体中心、动作名和手部坐标。
|
||||
- 处理:确认 `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 全热身流程启用 `useMocapInput`,页面主提示展示 mocap 动作数据源状态而不是浏览器摄像头状态;确认 `src/services/useMocapInput.ts` 能解析 `/stream` 包里的 `general.body.center_norm`、`actions/action/gesture/gestures/event/name/type`、`hands[]`、`leftHand/rightHand`、`left_hand/right_hand`、左右手标记和 `open_palm/grab` 状态。`/stream` 是 WebSocket,普通 HTTP 访问返回 404 不能当成服务不可用。
|
||||
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
|
||||
- 关联:`src/services/useMocapInput.ts`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## 儿童动作 Demo 真实绘本背景图未生成先查 VectorEngine 配置
|
||||
|
||||
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.webp` 不存在,Network 里该图返回 404,或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
|
||||
- 原因:儿童动作 Demo 的真实背景图使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key,缺配置时页面只能使用 CSS 草地绘本兜底。
|
||||
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` 与 `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git;先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt,再运行 `npm run assets:child-motion-demo -- --live` 生成默认背景图。
|
||||
- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过。
|
||||
- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||
|
||||
## GPT-image-2 不再读 APIMart 图片配置
|
||||
|
||||
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
||||
- 原因:2026-05-09 后 GPT-image-2 图片生成已切到 VectorEngine `gpt-image-2-all`,APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
|
||||
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认路径为 `/v1/images/generations`、模型为 `gpt-image-2-all`、参考图字段为 `image`。
|
||||
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
|
||||
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。
|
||||
|
||||
## 拼图参考图没有影响生成时先查 action payload 和阶段日志
|
||||
|
||||
- 现象:拼图上传参考图后生成出的画面明显不像参考图,或结果页重新生成没有按保存的参考图走图生图。
|
||||
- 原因:首图生成只通过 `compile_puzzle_draft.referenceImageSrc` 临时传 Data URL,不持久化到 SpacetimeDB;结果页重新生成则要把当前上传图或关卡 `pictureReference` 作为 `generate_puzzle_images.referenceImageSrc` 继续传给后端。
|
||||
- 处理:浏览器 Network 里确认 action payload 带 `referenceImageSrc`;api-server 日志按同一 `session_id` 查看 `拼图参考图解析完成`、`拼图 VectorEngine 图片生成 HTTP 返回`、`拼图 VectorEngine 图片下载完成`、`拼图生成图片已写入 OSS 与资产索引`,可定位慢在参考图读取、VectorEngine、下载或 OSS。
|
||||
- 验证:前端测试覆盖上传图 + AI 重绘、结果页保存的 `pictureReference` 重新生成;后端单测覆盖 VectorEngine 请求体 `image` 字段。
|
||||
- 关联:`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle.rs`。
|
||||
|
||||
## 拼图首图生成后要把入口参考图写回 `pictureReference`
|
||||
|
||||
- 现象:入口页上传图后,首图看着像没吃到参考图;结果页重新生成时默认只沿用关卡旧图,没有继续带入口上传图。
|
||||
- 原因:首图生成请求虽然已经把 `referenceImageSrc` 传给 VectorEngine,但如果后端只更新 `cover_image_src` / `selected_candidate_id` 而不回写首关 `pictureReference`,结果页后续重绘就会丢失参考图。
|
||||
- 处理:在 `compile_puzzle_draft` 和 `generate_puzzle_images` 的成功与 SpacetimeDB 降级快照路径里,都把本次入口参考图写入首关 `pictureReference`。
|
||||
- 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update` 和 `apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/components/puzzle-result/PuzzleResultView.tsx`。
|
||||
|
||||
## 拼图图生图仍不像参考图时先看是否走了 edits
|
||||
|
||||
- 现象:Network payload 已带 `referenceImageSrc`,但 VectorEngine 生成结果仍明显不像上传图。
|
||||
- 原因:`gpt-image-2-all` 的 `/v1/images/generations` 更适合纯文生图;有参考图且需要重绘时应切到 `/v1/images/edits` 的 multipart 图生图接口。
|
||||
- 处理:`referenceImageSrc` 存在且 `aiRedraw = true` 时直接走 edits,prompt 仍保留参考图强约束;入口页关闭 AI 重绘时直接应用上传图,不调用图片生成;前端把参考图压到单边 1024 内,后端解析后拒绝超过 8MB 的参考图字节。
|
||||
- 验证:后端单测应覆盖 `images/edits` 路由、`b64_json` 响应解码和参考图强提示;真实联调先看日志里是否命中 `拼图 VectorEngine 图片编辑 HTTP 返回`。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/services/puzzleReferenceImage.ts`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。
|
||||
|
||||
## 旧后端路线文档造成判断漂移
|
||||
|
||||
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
|
||||
@@ -59,14 +107,54 @@
|
||||
- 验证:发布前完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
|
||||
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
|
||||
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
|
||||
|
||||
- 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform`。
|
||||
- 原因:SpacetimeDB module 的 wasm32 构建树被间接带入原生/网页依赖;已验证链路是 `reqwest -> platform-oss -> shared-contracts -> module-runtime -> spacetime-module`,由共享契约默认启用资产 OSS 契约触发。
|
||||
- 处理:让 `shared-contracts` 的 OSS 资产契约走 `oss-contracts` feature,workspace 根依赖保持 `default-features = false`;`api-server` 这类原生后端需要资产 DTO 时在自身 `Cargo.toml` 显式启用 `features = ["oss-contracts"]`。
|
||||
- 验证:执行 `cargo tree -i wasm-bindgen --manifest-path server-rs\crates\spacetime-module\Cargo.toml --target wasm32-unknown-unknown` 应显示 nothing to print;再执行 `cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml --target wasm32-unknown-unknown`。
|
||||
- 关联:`server-rs/crates/shared-contracts/Cargo.toml`、`server-rs/crates/api-server/Cargo.toml`、`docs/technical/RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md`。
|
||||
|
||||
## 本地 SpacetimeDB replica identity 不匹配
|
||||
|
||||
- 现象:本地 standalone 启动时报 `mismatched database identity`。
|
||||
- 原因:root-dir / replica 数据残留与当前数据库身份不一致。
|
||||
- 原因:本地 SpacetimeDB 数据目录中的 replica 数据残留与当前数据库身份不一致。
|
||||
- 处理:按本地 replica identity mismatch 文档进行备份、重建和脚本诊断。
|
||||
- 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
|
||||
- 关联:`docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md`。
|
||||
|
||||
## 本地 SpacetimeDB publish 403 优先查 CLI 身份和目标库
|
||||
|
||||
- 现象:`spacetime publish` 在 `Pre-publish check` 阶段返回 `403 Forbidden`,提示当前 identity 无权对目标 database identity 执行 `update database`。
|
||||
- 原因:当前 CLI 登录态不是目标数据库的创建者或授权身份,或 `.env.local` / publish 命令指向了另一个数据库或 SpacetimeDB 服务。
|
||||
- 处理:除 CI/CD 脚本内部受控用法外,不再使用 `spacetime --root-dir` 排障或发布。先执行 `spacetime login show`、`spacetime server list`,再用 `spacetime list --server http://127.0.0.1:3101` 或实际 `--server-url` 确认当前身份是否能看到目标库;本地开发发布优先使用 `npm run dev:rust` 或从 `server-rs` 目录执行显式 `--server` 的 `spacetime publish`。如果身份不对,重新登录正确身份、使用项目脚本重新生成本地库,或在 SpacetimeDB 侧补授权。
|
||||
- 验证:`spacetime list --server http://127.0.0.1:3101` 能看到目标库;重新发布不再使用无权限 identity。
|
||||
- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`。
|
||||
|
||||
## `npm run dev` 本地 SpacetimeDB 401 / 403 可重置默认 local 身份
|
||||
|
||||
- 现象:`npm run dev` 启动本地开发栈时,SpacetimeDB 在登录、发布或预检查阶段返回 `401` / `403`,清理后仍像在使用旧 token 或旧本地库。
|
||||
- 原因:本机 `spacetime` CLI 保存的旧 token、默认 server、正在运行的 standalone 进程或默认 local 数据库与当前发布身份不一致。
|
||||
- 处理:确认只是本地测试库且数据可丢弃后,先查看并停止本地 `spacetimedb-standalone`,执行 `spacetime logout`,确认并设置 `spacetime server set-default local`,停 server 后用 `spacetime server clear -y` 清空默认本地库,再 `spacetime start`,另开终端执行 `spacetime login --server-issued-login local`,最后用 `spacetime publish --server local A` 或项目脚本重新发布。
|
||||
- 验证:`spacetime server list` 默认目标为 local;重新登录后发布不再返回 `401` / `403`;`npm run dev` 可以完成 SpacetimeDB publish 并继续启动 `api-server`。
|
||||
- 关联:`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`、`scripts/dev-rust-stack.sh`。
|
||||
|
||||
## 本地 SpacetimeDB 联调可按阶段跳过宿主或发布
|
||||
|
||||
- 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
|
||||
- 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
|
||||
- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。`npm run dev` 会在启动前检查 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,不可用时自动寻找后续可用端口,并把实际端口传给 publish、后端环境变量和前端代理目标。
|
||||
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。端口漂移时控制台会打印 `[dev:ports] ... 不可用,改用 ...`,后续 `[dev:rust] web/admin web/rust api/spacetime` 地址应与实际端口一致。
|
||||
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。
|
||||
|
||||
## 本地 SpacetimeDB publish 401 可清本地库重发
|
||||
|
||||
- 现象:本地 `spacetime publish` 显示 `401` 无权限,或重新发布仍像是在更新旧库。
|
||||
- 原因:本地开发数据目录中保留的数据库、控制库身份或发布身份与当前目标不一致。
|
||||
- 处理:确认本地开发数据可以丢弃后,停止本地 SpacetimeDB,备份或删除 `server-rs/.spacetimedb/local/data`,再重新运行 `npm run dev` 或本地 publish;不要用 `--root-dir` 手工清库。
|
||||
- 验证:重新发布日志应显示创建新的数据库,而不是更新旧数据库;若仍显示更新或继续 `401`,继续检查数据目录、库名和 CLI 身份。
|
||||
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`。
|
||||
|
||||
## Vite SPA fallback 吞掉 API 请求
|
||||
|
||||
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。
|
||||
@@ -75,13 +163,116 @@
|
||||
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
||||
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
||||
|
||||
## 拼图 APIMart 图片生成密钥不能复用 DashScope / ARK key
|
||||
## 反馈页清空 file input 前必须先拷贝 FileList
|
||||
|
||||
- 现象:拼图新手引导或拼图创作点击生成后返回 `APIMart 图片生成密钥未配置`。
|
||||
- 原因:拼图 `gpt-image-2` / `nanobanana2` 图片生成已按技术方案统一走 APIMart;后端只读取 `APIMART_BASE_URL`、`APIMART_API_KEY`、`APIMART_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY`、`LLM_API_KEY` 或 `ARK_API_KEY` 兜底。
|
||||
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `APIMART_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
|
||||
- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
|
||||
- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。
|
||||
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
|
||||
- 原因:浏览器传入的 `FileList` 可能跟 `<input type="file">` 保持 live 绑定;如果先执行 `input.value = ''`,再从参数里的 `FileList` 读取文件,列表可能已经为空。
|
||||
- 处理:在清空 file input 前先执行 `const selectedFiles = files ? Array.from(files) : []`,后续图片类型、大小、Data URL 读取和预览都基于这个普通数组。
|
||||
- 验证:`PlatformFeedbackView.test.tsx` 用 mock `FileReader` 断言选择图片后出现 `反馈凭证预览`,且提交 payload 带 `evidenceItems[].dataUrl`。
|
||||
- 关联:`src/components/platform-entry/PlatformFeedbackView.tsx`、`docs/technical/PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md`。
|
||||
|
||||
## 拼图 VectorEngine 图片生成密钥不能复用 DashScope / ARK key
|
||||
|
||||
- 现象:拼图新手引导或拼图创作点击生成后返回 `VectorEngine 图片生成密钥未配置`。
|
||||
- 原因:拼图 `gpt-image-2` / 历史 `nanobanana2` 图片生成已统一走 VectorEngine;后端只读取 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY`、`LLM_API_KEY`、`ARK_API_KEY` 或 `APIMART_API_KEY` 兜底。
|
||||
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `VECTOR_ENGINE_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
|
||||
- 验证:不打印密钥内容,只检查 `VECTOR_ENGINE_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
|
||||
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。
|
||||
|
||||
## `npm run api-server` 读取 env 的顺序必须让 `.env.secrets.local` 最后覆盖
|
||||
|
||||
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
|
||||
- 原因:`scripts/api-server-dev.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
|
||||
- 处理:`npm run api-server` / `npm run dev:rust` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。
|
||||
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。
|
||||
- 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。
|
||||
|
||||
## 拼图图片生成 98% 后报 OSS V4 签名时间格式化失败
|
||||
|
||||
- 现象:拼图创作表单生成进度卡在 98%,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `拼图图片生成失败:OSS V4 签名时间格式化失败`。
|
||||
- 原因:`platform-oss` 曾用 `OffsetDateTime::time().to_string()` 拼接 `x-oss-date`,UTC 小时、分钟或秒为个位数时可能缺少前导零,导致 V4 签名时间不是固定 `YYYYMMDDTHHMMSSZ`。
|
||||
- 处理:OSS V4 签名日期统一显式补零格式化;签名 scope 用 `YYYYMMDD`,完整签名时间用 `YYYYMMDDTHHMMSSZ`,不要再依赖 `time().to_string()`。
|
||||
- 验证:运行 `cargo test -p platform-oss` 和 `cargo check -p api-server`;重启 `npm run api-server` 后检查 `/healthz`,再重新触发拼图生成。
|
||||
- 关联:`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md`。
|
||||
|
||||
## 拼图生成完成后图片只显示破图或 alt 文案
|
||||
|
||||
- 现象:拼图结果页生成完成后,“画面图”区域出现破图图标和作品名,图片无法正常预览;但打开历史拼图素材时同一张图可能可以正常预览。
|
||||
- 原因:拼图正式图保存为 `/generated-puzzle-assets/*` 兼容标识,旧 `/generated-*` 直读代理已删除;如果前端没有通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签,或收到无前导斜杠的 `generated-puzzle-assets/*` object key 后未识别为 generated 私有资源,浏览器会直接请求裸路径并失败。生成完成后的结果图还会传入 `refreshKey`,它只能用于重新请求 `/api/assets/read-url`,不能给 OSS V4 签名 URL 追加 `_v`;OSS 会把 query 纳入签名,额外参数会让签名失效,历史素材常因未传 `refreshKey` 而表现正常。
|
||||
- 处理:拼图结果页、发布预览、运行态和历史素材预览都走 `ResolvedAssetImage` 或 `useResolvedAssetReadUrl`;`isGeneratedLegacyPath(...)` 必须同时识别 `/generated-*` 和 `generated-*`;`refreshKey` 只绕过前端签名缓存并重新换签,不修改已返回的 OSS 签名 URL;禁止恢复 `/generated-puzzle-assets` 直读代理。
|
||||
- 验证:运行 `npm run test -- src\services\assetReadUrlService.test.ts src\hooks\useResolvedAssetReadUrl.test.tsx src\components\puzzle-result\PuzzleResultView.test.tsx`,再触发一次真实生成确认 Network 中先请求 `/api/assets/read-url`,图片 `src` 为未追加 `_v` 的签名 URL。
|
||||
- 关联:`src/services/assetReadUrlService.ts`、`src/components/ResolvedAssetImage.tsx`、`docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md`。
|
||||
|
||||
## 本地短信登录页签突然消失
|
||||
|
||||
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
|
||||
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
|
||||
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
||||
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
||||
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
|
||||
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
|
||||
- 处理:优先用 `npm run api-server`、`npm run dev:rust` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。
|
||||
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
|
||||
- 关联:`scripts/api-server-dev.mjs`、`scripts/api-server-maincloud.mjs`、`scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。
|
||||
|
||||
## 本地短信收不到验证码先查 provider
|
||||
|
||||
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
|
||||
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
|
||||
- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。
|
||||
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。
|
||||
- 关联:`scripts/api-server-dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。
|
||||
|
||||
## 手机验证码登录 500 先查短信 provider 语义
|
||||
|
||||
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。
|
||||
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。
|
||||
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run api-server`。
|
||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||
|
||||
## 手机验证码登录成功后又瞬间回到未登录
|
||||
|
||||
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
|
||||
- 原因:`AuthGate` 首次 hydrate 会异步轮换 refresh cookie 并请求 `/api/auth/me`。如果用户在 hydrate 完成前已经登录,晚到的旧 hydrate 仍可能把刚写入的 `user` 覆盖成 `null`。
|
||||
- 处理:给 `AuthGate` 的 hydrate 增加版本号保护;登录成功、退出登录和全局 auth 事件都会推进版本号,旧 hydrate 结果到达后直接丢弃。
|
||||
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
|
||||
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。
|
||||
|
||||
## 刷新网页后登录态失效
|
||||
|
||||
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
|
||||
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
||||
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## 登录后推荐页加载出作品又回到未登录
|
||||
|
||||
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
|
||||
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。更隐蔽的是,`refreshAccessToken()` 自身曾在 refresh 失败时静默清 token,即便调用方关闭了 `clearAuthOnUnauthorized`,也可能让后续 hydrate 变成未登录。
|
||||
- 处理:请求层统一使用 `authImpact: 'global' | 'local'` 区分账号权威请求与局部后台请求;推荐页自动运行态、图片换签、公开拼图运行态和平台 bootstrap 私有投影刷新统一使用 `BACKGROUND_AUTH_REQUEST_OPTIONS` / `RUNTIME_BACKGROUND_AUTH_OPTIONS`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的账号动作仍保留默认全局鉴权失败处理。
|
||||
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
||||
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
||||
|
||||
## 推荐页作品卡一直显示加载中
|
||||
|
||||
- 现象:推荐页有公开作品,但主视口一直停在“加载中...”,没有进入作品,也没有显示可操作错误。
|
||||
- 原因:推荐页自动启动嵌入运行态时先设置 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,但失败或并发切换时外层缺少稳定错误态和请求版本保护,旧启动请求可能晚到覆盖新状态。
|
||||
- 处理:`selectRecommendRuntimeEntry` 使用启动请求版本号丢弃旧请求;启动失败统一设置 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"` 并关闭 `isStartingRecommendEntry`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## 推荐页未登录入口误打开公开详情
|
||||
|
||||
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。
|
||||
- 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。
|
||||
- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块统一走登录门禁。未登录推荐页只显示封面,点击封面只弹登录窗,不携带登录后自动打开详情的回调。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend"`。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## Rust 冷编译导致 api-server 健康检查误超时
|
||||
|
||||
@@ -91,14 +282,113 @@
|
||||
- 验证:冷启动时不再误杀仍在编译的 api-server。
|
||||
- 关联:`docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md`。
|
||||
|
||||
## Windows debug api-server 主线程栈溢出
|
||||
|
||||
- 现象:`cargo check -p api-server` 和 `build_router` 测试通过,但 `npm run api-server` 在 Windows debug 启动时 `thread 'main' has overflowed its stack`。
|
||||
- 原因:`api-server` Axum 路由树已经很深,debug 主线程默认栈偏小,初始化状态和构造路由时容易触顶。
|
||||
- 处理:入口 `main` 用显式 16MB 栈线程启动 Tokio runtime,并把实际服务逻辑放入 `run_server()`;新增路由时优先用小 router `.merge()`,避免继续拉长主链。
|
||||
- 验证:`npm run api-server` 后 `/healthz` 返回 200,相关路由冒烟通过。
|
||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/app.rs`。
|
||||
|
||||
## Windows debug api-server.exe 锁文件与强杀退出码容易混淆
|
||||
|
||||
- 现象:`cargo run -p api-server` 或 `npm run api-server` 报 `failed to remove file ... target\debug\api-server.exe`;清理旧进程后,旧终端可能继续打印 `process didn't exit successfully: server-rs\target\debug\api-server.exe (exit code: 0xffffffff)`。
|
||||
- 原因:Windows 不能覆盖仍在运行的 exe;通常是上一条 `npm run api-server` 链路仍在运行,进程树为 `npm run api-server -> node scripts/api-server-dev.mjs -> cargo run -> api-server.exe`。`0xffffffff` 常见于排障时用 `Stop-Process -Force` 强制结束旧 `api-server.exe` 后由 Cargo 回显,不一定代表新启动失败。
|
||||
- 处理:先按目标路径确认并停止本仓库的旧 `api-server.exe` 及其父级 `cargo/node/cmd` 启动链路,再重新启动;不要同时开多个 `npm run api-server`。
|
||||
- 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run api-server` 启动并访问 `/healthz` 返回 200。
|
||||
- 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/main.rs`。
|
||||
|
||||
## dev-rust-stack 端口被旧进程占用时会误判健康检查
|
||||
|
||||
- 现象:`node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --skip-spacetime` 输出 `Port 3000 is in use, trying another one...`,随后 `api-server.exe` 报 `AddrInUse` / `code: 10048`。
|
||||
- 原因:旧 `api-server` 仍监听默认 `8082` 时,脚本的 `/healthz` 探测会命中旧进程并误判新服务已就绪;旧 Vite 占住 `3000` 时,Vite 默认漂移到新端口,浏览器仍可能打开旧页面。
|
||||
- 处理:`scripts/dev-rust-stack.sh` 已在 publish / 编译前预检 `api-server`、主站 Vite、后台 Vite 端口,并让 Vite 使用 `--strictPort`;遇到端口占用时按脚本打印的 PID 停止旧进程,或显式传入 `--api-port` / `--web-port` / `--admin-web-port`。
|
||||
- 验证:默认端口被占用时,完整栈应在发布模块前直接失败并打印监听进程;清理端口后重新启动不再漂移端口或命中旧 `/healthz`。
|
||||
- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md`。
|
||||
|
||||
## Windows debug 长 SSE Future 触发 api-server 断连
|
||||
|
||||
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
|
||||
- 原因:单个 `async_stream::stream!` 中塞入 Agent 执行、外部模型请求、会话更新和大量 SSE 事件,会在 Windows debug 下生成很大的 Future;真实消费 SSE body 时容易触发 worker 线程栈压力或进程级中断,单元测试若只测函数和路由状态会漏掉。
|
||||
- 处理:长 SSE 路由优先使用 `tokio::spawn` 跑业务流程,通过 `mpsc` + `UnboundedReceiverStream` 向 Axum 返回轻量 stream;失败时更新会话为 `failed` 并发送 SSE `error`,不要把大段执行逻辑内联到路由返回的 stream future 中。
|
||||
- 验证:补充实际 `collect()` SSE body 的路由测试,确认首轮包含 `stage`、`puzzle_template_catalog` 和 `done`,且不会提前发送 `puzzle_template_selection` / `puzzle_cost_range`;再执行 `cargo check -p api-server`、`cargo test -p api-server creative_agent`,联调时用 `npm run api-server` 检查 `/healthz`。
|
||||
- 关联:`server-rs/crates/api-server/src/creative_agent.rs`、`server-rs/crates/api-server/src/app.rs`。
|
||||
|
||||
## creative-agent 过程项不要把历史事件渲染成运行中
|
||||
|
||||
- 现象:智能创作页过程中多个阶段从一开始同时转圈,生成结束或进入模板确认后仍有过程项保持转圈。
|
||||
- 原因:前端把历史 `stage`、`tool_started` 和 `thought_summary_delta` 都按 active 渲染;后端工具开始/完成事件如果 `toolCallId` 不一致,也会导致开始事件无法收口。
|
||||
- 处理:
|
||||
- 只有最新且仍在执行的 stage 可为 active;等待确认、等待用户、target ready 和 failed 都是静态状态。
|
||||
- 工具开始事件必须等同一 `toolCallId` 的 `tool_completed` 收口;兼容旧流时可按后续同名完成事件兜底。
|
||||
- 思考摘要只展示用户可见摘要,且流结束或会话进入等待/完成/失败态后必须改成 done。
|
||||
- 验证:前端测试断言完成后 `CreativeAgentProcessItem` 不再存在 `tone === 'active'`;后端测试确认工具开始/完成事件使用相同 `toolCallId`。
|
||||
- 关联:`src/components/creative-agent/creativeAgentViewModel.ts`、`server-rs/crates/api-server/src/creative_agent.rs`、`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`。
|
||||
|
||||
## creative-agent 会话切换要清理本地待确认模板
|
||||
|
||||
- 现象:用户在一个智能创作会话中点开模板确认面板后,立即切到另一条创作会话,可能看到上一会话的确认面板残留。
|
||||
- 原因:模板确认面板的 `pendingSelection` 是 `CreativeAgentWorkspace` 本地 UI 状态,不属于后端 session 快照;组件复用时如果不监听 `sessionId` 清理,会跨会话泄漏。
|
||||
- 处理:工作区以 `session?.sessionId` 为边界清空 `pendingSelection`;服务端仍以 `puzzleTemplateSelection` / `targetBinding` 作为正式业务状态。
|
||||
- 验证:前端测试先点开模板确认面板,再 rerender 到另一 session,断言确认面板消失。
|
||||
- 关联:`src/components/creative-agent/CreativeAgentWorkspace.tsx`、`src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
||||
|
||||
## 视觉小说 VN-10 不要绕过平台资产引用
|
||||
|
||||
- 现象:文档、封面、场景背景、角色立绘或音乐为了预览方便被写成 Data URL、裸对象路径、外部 URL 或本地临时文件路径。
|
||||
- 原因:前端上传与预览容易混在一起,若不走平台资产对象,SpacetimeDB 和长期草稿会被大文本或大二进制污染。
|
||||
- 处理:VN 资产统一用 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm`,长期状态只保存 `assetObjectId` 和 `/generated-*` 引用;运行时图片用 `ResolvedAssetImage` 换签。
|
||||
- 验证:文档模式 `sourceAssetIds` 为平台资产 id;草稿中不出现 `data:`;图片和音乐字段为平台 generated 引用或 null。
|
||||
- 关联:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`src/services/visual-novel-creation/visualNovelAssetClient.ts`。
|
||||
|
||||
## 视觉小说 VN-13 交接时不要再回头找旧迁移方案
|
||||
|
||||
- 现象:接手视觉小说的人容易重新打开旧 TXT 迁移文档,把“外部平台工程迁入”误当成当前实现目标。
|
||||
- 原因:视觉小说历史资料里保留了很多迁移阶段的讨论,而当前真正的实现口径已经收口到 PRD、表目录、Prompt 工具说明、实现收口文档和负向扫描报告。
|
||||
- 处理:维护视觉小说时优先看 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`SPACETIMEDB_TABLE_CATALOG.md`、`VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md`、`VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md` 和 `VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`。
|
||||
- 验证:新开发者只读这组文档即可继续维护,不需要把旧 TXT 迁移方案重新当作编码依据。
|
||||
- 关联:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`。
|
||||
|
||||
## 视觉小说公开广场不要触发登录刷新
|
||||
|
||||
- 现象:未登录用户进入平台公开广场或从推荐流读取视觉小说公开作品时,前端可能先尝试 `/api/auth/refresh`,失败后再读取公开列表,导致无意义的鉴权噪声或 401 状态刷新。
|
||||
- 原因:公开只读接口如果复用默认 `requestJson` 选项,缺少 access token 时会先走静默 refresh。
|
||||
- 处理:视觉小说公开广场列表使用 `skipAuth: true` 与 `skipRefresh: true`;鉴权 mutation 仍保持默认鉴权链路。
|
||||
- 验证:执行 `src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts`,确认 `/api/runtime/visual-novel/gallery` 请求携带 `skipAuth` / `skipRefresh`,而 run、重生成和存档 mutation 仍走受保护路由。
|
||||
- 关联:`src/services/visual-novel-runtime/visualNovelRuntimeClient.ts`、`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`。
|
||||
|
||||
## 创作 Tab 语义迁移后,旧“新建作品”测试要改看智能创作首页
|
||||
|
||||
- 现象:把 `create` 从旧创作中心切到 `CreativeAgentHome` 后,旧测试仍尝试在创作页找“新建作品”类型卡,导致用例失败或定位不到元素。
|
||||
- 原因:产品语义已经变成“创作 = 智能创作首页,草稿 = 旧作品架”,但测试夹具和 helper 还沿用旧入口。
|
||||
- 处理:把这类测试改成验证智能创作首页、快捷胶囊、抽屉与草稿 Tab;同时给 `useRpgEntryLibraryDetail` 这类恢复路径补上 `setPlatformTabToDraft`。
|
||||
- 验证:定向 `vitest`、`eslint`、`typecheck`、`check:encoding` 都通过。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`、`src/components/rpg-entry/useRpgEntryLibraryDetail.ts`。
|
||||
|
||||
## server-rs 默认 cargo build 不能等同于构建 SpacetimeDB 模块
|
||||
|
||||
- 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。
|
||||
- 原因:workspace default-members 当前只包含 `crates/api-server`;SpacetimeDB module 有独立构建/发布方式。
|
||||
- 处理:默认 Rust 构建只覆盖原生 `api-server`;模块产物继续走 `spacetime build` / publish / bindings 生成流程。
|
||||
- 处理:默认 Rust 构建只覆盖原生 `api-server`;本地模块发布继续走 `spacetime publish --module-path ... --build-options="--debug"` / bindings 生成流程。
|
||||
- 验证:查看 `server-rs/Cargo.toml` default-members,并按相关 SpacetimeDB 文档执行模块构建。
|
||||
- 关联:`server-rs/Cargo.toml`、`docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`。
|
||||
|
||||
## Windows 原生 `spacetime-module` 单测会链接缺失 SpacetimeDB 宿主符号
|
||||
|
||||
- 现象:在 Windows 上执行 `cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml` 可能编译到链接阶段后失败,出现 `LNK2019` / `LNK1120`,缺失 `datastore_insert_bsatn`、`procedure_start_mut_tx`、`console_log` 等 SpacetimeDB 宿主符号。
|
||||
- 原因:`spacetime-module` 依赖的 SpacetimeDB runtime API 面向 wasm 宿主环境,原生 test exe 链接不到这些宿主导出。
|
||||
- 处理:日常语法和类型验证使用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`;需要验证模块行为时走 SpacetimeDB publish/dev 或模块域纯 Rust crate 的单测,不把该原生链接错误当作业务测试失败。
|
||||
- 验证:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能通过;原生 `cargo test` 若仍报上述宿主符号缺失,按当前限制记录为未执行。
|
||||
- 关联:`server-rs/crates/spacetime-module`、`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`。
|
||||
|
||||
## Rust 构建不要让不可用的 sccache 阻断 rustc
|
||||
|
||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
||||
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
|
||||
- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
||||
- 验证:`rustc -Vv` 能输出版本;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明仍在使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||
- 关联:`scripts/dev-rust-stack.sh`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||
|
||||
- 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。
|
||||
@@ -107,6 +397,14 @@
|
||||
- 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。
|
||||
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## Jenkins 可选参数在 set -u 下不能裸读
|
||||
|
||||
- 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。
|
||||
- 原因:Jenkins string/boolean 参数留空时不一定会导出同名环境变量,而生产数据库导入导出脚本块启用了 `set -u`。
|
||||
- 处理:进入 Bash 执行块后先使用 `${VAR:-}` 或 `${VAR:-默认值}` 收敛成本地变量;必填项使用 `${VAR:?中文错误}` 明确失败原因。
|
||||
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES`、`CHUNK_SIZE`、`SERVER_BACKUP_DIRECTORY`、`SMOKE_HEALTH_URL` 等可选参数不再裸读。
|
||||
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`。
|
||||
|
||||
## 个人任务 scope 不得扩成 work/site/module
|
||||
|
||||
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
||||
@@ -114,3 +412,59 @@
|
||||
- 处理:Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`;API 和领域构造层拒绝非 `User`。
|
||||
- 验证:非 `user` scope 返回错误;相关测试覆盖 `Site` / `Module` / `Work` 被拒绝。
|
||||
- 关联:`docs/technical/RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。
|
||||
|
||||
## 拼图发布 409 不一定是接口故障
|
||||
|
||||
- 现象:拼图结果页点击发布后,控制台出现 `POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions 409 (Conflict)`,用户只看到发布失败。
|
||||
- 原因:`publish_puzzle_work` 是资产操作发布入口,发布前会预扣 `1` 枚光点;余额不足时后端按业务冲突返回 `409 CONFLICT`,`details.message` 为 `光点余额不足`。
|
||||
- 处理:前端发布弹窗在用户点击发布后必须保留并展示后端业务错误,不能只把错误写到弹窗背后的页面 banner。
|
||||
- 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `光点余额不足`。
|
||||
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。
|
||||
|
||||
## WebGL 画布在高 DPR 移动端放大溢出
|
||||
|
||||
- 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。
|
||||
- 原因:`WebGLRenderer.setPixelRatio(...)` 会把绘图缓冲区乘上设备 DPR;如果没有给 `renderer.domElement` 单独设置 CSS `width/height: 100%` 和绝对铺满,浏览器可能把高 DPR 缓冲区尺寸当成页面显示尺寸。
|
||||
- 处理:中心棋盘和托盘预览的 WebGL canvas 统一套用 `position:absolute; inset:0; width:100%; height:100%; display:block`,`renderer.setSize(..., false)` 只负责同步绘图缓冲区。
|
||||
- 验证:强制移动端 `390x844`、DPR 2 截图,确认棋盘左右边界在视口内,canvas CSS 尺寸等于容器尺寸,内部 `width/height` 属性可大于 CSS 尺寸。
|
||||
- 关联:`src/components/match3d-runtime/Match3DPhysicsBoard.tsx`、`docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md`。
|
||||
|
||||
## Hyper3D subscriptionKey 不要按固定短文本限长
|
||||
|
||||
- 现象:抓大鹅生成草稿时,内联 Rodin 图生 3D 模型提交成功后,状态轮询报 `subscriptionKey 超过 256 字符`,导致 `/api/creation/match3d/sessions/{sessionId}/actions` 返回 400。
|
||||
- 原因:`subscriptionKey` 是 Hyper3D 返回的 opaque token,长度由上游决定;后端状态查询曾复用普通文本校验,把它限制在 256 字符。
|
||||
- 处理:`query_task_status` 对 `subscriptionKey` 只做 trim 和非空校验,不做固定长度限制;前端临时任务和 Match3D 草稿响应可继续展示该 token,但不要把它当作可编辑短文本。
|
||||
- 验证:`cargo test -p api-server accepts_opaque_subscription_key_without_length_cap --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。
|
||||
|
||||
## 抓大鹅草稿生成不要阻塞在 Rodin 模型下载
|
||||
|
||||
- 现象:抓大鹅草稿生成时 Hyper3D 状态已完成,但下载列表为空或没有可用模型文件,`/api/creation/match3d/sessions/{sessionId}/actions` 返回 `502 Bad Gateway`,前端提示 `Hyper3D 已完成但未返回可下载模型文件`。
|
||||
- 原因:草稿生成链路曾在切割图片后立即并行调用 Rodin 图生模型,并把模型下载成功作为草稿完成前置条件;上游完成态和可下载文件列表不是强一致,容易把本来可用的图片草稿卡死。
|
||||
- 处理:草稿阶段只生成物品名、素材图、切割独立图片并上传 OSS,返回 `status = image_ready`;Rodin 3D 模型生成留到结果页 `3D素材` Tab 手动触发。
|
||||
- 验证:草稿响应中的 `generatedItemAssets[].imageSrc` 有值、`modelSrc` 为空、状态为 `image_ready`;结果页显示 `图片已就绪` 和 `0 文件`,不会自动请求 Hyper3D 下载。
|
||||
- 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## 抓大鹅切图路径不能只用中文物品名
|
||||
|
||||
- 现象:草稿页 `3D素材` Tab 中多个素材名称不同,但预览图片完全一样;点击图生模型生成时还可能提示 `参考图必须是 data URL`。
|
||||
- 原因:中文物品名经过 OSS 路径段清洗后都可能退化成 `item`,多张切割图片写到同一个 `items/item/image.png` object key,后写入覆盖先写入;结果页手动 Rodin 图生模型还曾把 `/generated-match3d-assets/...` 私有路径直接作为 `imageDataUrls` 提交。
|
||||
- 处理:切割图上传路径必须带稳定唯一 `itemId` 前缀,例如 `items/match3d-item-1-item/image.png`;结果页提交图生模型前,generated 私有路径先经同源 `/api/assets/read-bytes` 由后端换签并读取字节,前端再转成 `data:image/...;base64,...`,不要在浏览器里直接 `fetch` OSS 签名 URL,否则会被 bucket CORS 拦截。
|
||||
- 验证:后端单测覆盖中文名路径唯一;前端单测覆盖 generated 参考图会换签、fetch 并以 Data URL 调用 `submitHyper3dImageToModel`。
|
||||
- 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## 抓大鹅生成素材不能只挂在 compile response
|
||||
|
||||
- 现象:抓大鹅草稿生成完成后停留在结果页能看到切割好的 `3D素材` 图片;退出后从草稿 Tab 重新进入同一草稿,素材列表变回默认占位或为空,已生成的物品名称和图片丢失。
|
||||
- 原因:`generatedItemAssets` 如果只附加在 `match3d_compile_draft` 的 HTTP response draft 上,刷新或重进时 `getMatch3DWorkDetail` 只能读取 SpacetimeDB 中的 `match3d_work_profile`;旧 mapper 返回空数组,自然无法恢复素材。拼图链路已经通过 `save_puzzle_generated_images` 把候选图和 levels 写回 work profile,抓大鹅也必须同样写持久字段。
|
||||
- 处理:compile 成功时把独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`;`update_match3d_work` / `publish_match3d_work` 保留该字段;API work summary/detail 映射反序列化为 `generatedItemAssets`。前端保持“本次 draft 优先,重进 profile 兜底”的读取顺序。
|
||||
- 验证:`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/match3d/*`、`server-rs/crates/spacetime-client/src/mapper.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-result/Match3DResultView.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## 公开作品详情深链找不到作品不能停在空详情页
|
||||
|
||||
- 现象:直接访问 `/works/detail?work=PZ-...`,作品不存在或已下架时会弹出“作品不存在或已下架,将返回首页。”;关闭提示后仍可能停在大白屏。
|
||||
- 原因:旧恢复逻辑只覆盖 `/runtime/...`,没有覆盖 `/works/detail`。同时 `selectionStage === 'work-detail'` 且 `selectedPublicWorkDetail === null` 时没有兜底渲染,详情数据为空就只剩空页面。
|
||||
- 处理:公开详情失效统一走 `resolveWorkNotFoundRecoveryAction(...)`,覆盖 `/works/detail`、`/gallery/puzzle/detail` 和 `/gallery/visual-novel/detail`;搜索失败和拼图详情 404 分支清理详情/运行态临时状态并回首页;`work-detail` 空数据阶段显示轻量读取态,避免异步间隙白屏。
|
||||
- 验证:`npm run test -- src/routing/runtimeNotFoundRecovery.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail alert returns to platform home"`。
|
||||
- 关联:`docs/technical/PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md`、`src/routing/runtimeNotFoundRecovery.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。
|
||||
|
||||
@@ -79,7 +79,7 @@ DDD 分层边界以 `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
|
||||
- SpacetimeDB 接入:`spacetime-client`、`spacetime-module`
|
||||
- HTTP 服务与测试:`api-server`、`tests-support`
|
||||
|
||||
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`,SpacetimeDB 模块产物继续走 `spacetime build` / 发布链路。
|
||||
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`,本地 SpacetimeDB 模块发布继续走 `spacetime publish --module-path ... --build-options="--debug"`。
|
||||
|
||||
Cargo 依赖口径:第三方依赖版本和 workspace 内部 crate path 统一维护在 `server-rs/Cargo.toml` 的 `[workspace.dependencies]`,成员 crate 默认继承 workspace 依赖,只保留自身 `features`、`optional` 或 target-specific 差异。
|
||||
|
||||
|
||||
392
.hermes/skills/behavior-driven-development/SKILL.md
Normal file
392
.hermes/skills/behavior-driven-development/SKILL.md
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
name: behavior-driven-development
|
||||
description: 在 Genarrative 中需要用 BDD/行为驱动方式把 PRD、用户故事、验收标准转成可执行场景、Gherkin 用例、测试计划或 TDD 落地顺序时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [BDD, Gherkin, 验收标准, 用户故事, 测试, Genarrative]
|
||||
related_skills: [writing-plans, test-driven-development, systematic-debugging, requesting-code-review]
|
||||
---
|
||||
|
||||
# BDD 行为驱动开发流程
|
||||
|
||||
用于在 Genarrative 项目中,把产品需求、用户故事、业务规则和验收标准沉淀成清晰、可讨论、可验证的行为场景,并进一步映射到前端测试、API 测试、领域服务测试或 E2E 测试。
|
||||
|
||||
BDD 的重点不是“先写一堆 UI 自动化脚本”,而是让团队先对“用户在什么上下文下做什么,系统应该给出什么可观察结果”达成一致。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 从 PRD、设计文档、用户故事中提炼验收标准。
|
||||
- 功能需求容易产生理解偏差,需要先把行为边界说清楚。
|
||||
- 涉及前端、后端、运行态、异步任务、权限、埋点或状态流转的跨层功能。
|
||||
- 需要把“完成标准”写成 Given / When / Then 场景。
|
||||
- 需要在编码前规划测试覆盖:单元测试、组件测试、API 测试、E2E 测试。
|
||||
- 希望和 TDD 配合:先写行为场景,再把场景拆到 RED-GREEN-REFACTOR。
|
||||
- 需要给产品、测试、前后端开发共同评审的一份中文验收说明。
|
||||
|
||||
不适用:
|
||||
|
||||
- 纯重构且对外行为不变,只需要 characterization tests 或回归测试。
|
||||
- 一次性小改动,验收规则非常明确且无跨层状态。
|
||||
- 只想记录实现细节、代码结构或技术方案;这类内容更适合技术设计文档。
|
||||
|
||||
## 必读约束
|
||||
|
||||
1. 先描述用户可观察行为,再讨论实现细节。
|
||||
2. 场景必须可验证,避免“体验更好”“更智能”“合理展示”等不可判定表述。
|
||||
3. 不要把 Gherkin 写成低层 UI 点击脚本;UI 细节只在确实属于业务行为时出现。
|
||||
4. 中文 PRD、中文 UI 文案、中文剧情和注释不要擅自改成英文。
|
||||
5. 如果项目文档不足以支持准确落地,应先补齐 `docs/` 下的 PRD/设计/技术文档,再进入编码。
|
||||
6. BDD 场景要覆盖主要成功路径、关键失败路径、权限/登录态、边界条件和回归风险。
|
||||
|
||||
## 核心格式
|
||||
|
||||
推荐使用中文 Gherkin:
|
||||
|
||||
```gherkin
|
||||
功能: <业务能力名称>
|
||||
为了 <用户/业务价值>
|
||||
作为 <角色>
|
||||
我希望 <能力>
|
||||
|
||||
背景:
|
||||
假如 <所有场景共享的前置条件>
|
||||
|
||||
场景: <具体行为名称>
|
||||
假如 <上下文/已有状态>
|
||||
当 <用户动作或系统事件>
|
||||
那么 <可观察结果>
|
||||
而且 <额外可观察结果>
|
||||
|
||||
场景大纲: <带参数的行为名称>
|
||||
假如 <上下文中包含 <变量>>
|
||||
当 <动作>
|
||||
那么 <结果>
|
||||
|
||||
例子:
|
||||
| 变量 | 期望 |
|
||||
| A | X |
|
||||
| B | Y |
|
||||
```
|
||||
|
||||
英文关键字也可以使用:
|
||||
|
||||
```gherkin
|
||||
Feature: Work publish permission
|
||||
Scenario: Anonymous user attempts to publish a draft
|
||||
Given an anonymous user has a generated draft
|
||||
When the user clicks publish
|
||||
Then the login modal should be shown
|
||||
And the draft should remain unchanged
|
||||
```
|
||||
|
||||
在 Genarrative 项目内,若参与评审的人主要使用中文,优先中文场景;测试框架要求英文命名时,可以保留中文场景标题并在测试文件中使用英文 describe/it。
|
||||
|
||||
## 从需求提炼 BDD 场景
|
||||
|
||||
### Step 1: 识别角色和业务目标
|
||||
|
||||
先回答:
|
||||
|
||||
- 谁在使用?游客、已登录用户、创作者、管理员、审核人员、系统任务?
|
||||
- 用户想完成什么?创建、生成、保存、发布、试玩、查看、兑换、导出?
|
||||
- 业务价值是什么?降低创作门槛、保护权限、保证数据一致性、提升运营可见性?
|
||||
|
||||
### Step 2: 抽取领域词汇
|
||||
|
||||
建立统一术语,避免同一概念多种叫法:
|
||||
|
||||
- work / 作品
|
||||
- draft / 草稿
|
||||
- session / 创作会话
|
||||
- runtime / 运行态
|
||||
- publish / 发布
|
||||
- profile / 我的页签
|
||||
- invite code / 邀请码
|
||||
- analytics event / 埋点事件
|
||||
|
||||
场景中优先使用业务词,不要直接写组件名、函数名、数据库表名,除非这些就是用户可见对象。
|
||||
|
||||
### Step 3: 列出行为切片
|
||||
|
||||
按用户旅程切分:
|
||||
|
||||
1. 入口是否出现、是否可点击。
|
||||
2. 进入页面或工作台后的初始状态。
|
||||
3. 用户提交输入后的成功路径。
|
||||
4. 失败路径:未登录、参数无效、权限不足、网络/API 失败、异步任务失败。
|
||||
5. 状态持久化:刷新、返回、重新进入、跨设备或重新登录。
|
||||
6. 对外副作用:保存、发布、埋点、通知、导出、生成资产。
|
||||
7. 回归风险:旧入口、旧数据、移动端布局、中文编码。
|
||||
|
||||
### Step 4: 把每个切片写成 Given / When / Then
|
||||
|
||||
检查每个场景:
|
||||
|
||||
- Given 只描述前置状态,不写动作过程。
|
||||
- When 只描述一个主要触发动作或事件。
|
||||
- Then 描述可观察结果,可以被测试或人工验收。
|
||||
- 一个场景只验证一个核心行为;不要把完整长流程塞进一个巨型场景。
|
||||
|
||||
## Genarrative 场景模板
|
||||
|
||||
### 前端入口 / 页面行为
|
||||
|
||||
```gherkin
|
||||
功能: 我的页签反馈入口
|
||||
为了让用户能从个人中心提交问题
|
||||
作为已登录用户
|
||||
我希望在我的页签打开独立的帮助与反馈页面
|
||||
|
||||
场景: 已登录用户从我的页签进入反馈页面
|
||||
假如用户已登录并停留在我的页签
|
||||
当用户点击“帮助与反馈”入口
|
||||
那么系统应进入独立的反馈页面
|
||||
而且底部 tab 应保持选中“我的”
|
||||
而且页面不应展开在我的页签当前面板下方
|
||||
|
||||
场景: 用户从反馈页面返回我的页签
|
||||
假如用户正在反馈页面
|
||||
当用户点击返回按钮
|
||||
那么系统应回到平台主页面
|
||||
而且当前 tab 应为“我的”
|
||||
```
|
||||
|
||||
### 登录态 / 权限行为
|
||||
|
||||
```gherkin
|
||||
功能: 需要登录的发布能力
|
||||
为了保护作品归属和发布链路
|
||||
作为游客
|
||||
我不能在未登录时发布作品
|
||||
|
||||
场景: 游客尝试发布生成草稿
|
||||
假如游客已经生成一个草稿
|
||||
当游客点击发布按钮
|
||||
那么系统应打开登录弹窗
|
||||
而且不应创建正式作品
|
||||
而且草稿内容应保留在当前会话中
|
||||
```
|
||||
|
||||
### 后端 API / 领域规则
|
||||
|
||||
```gherkin
|
||||
功能: 作品正式游玩开始埋点
|
||||
为了统计不同玩法的正式游玩行为
|
||||
作为数据分析人员
|
||||
我希望每次用户进入正式作品运行态时记录统一事件
|
||||
|
||||
场景大纲: 支持的玩法进入正式游玩
|
||||
假如存在一个已发布的 <玩法> 作品
|
||||
当用户从作品详情进入正式游玩
|
||||
那么后端应记录 work_play_start 事件
|
||||
而且 scope_kind 应为 work
|
||||
而且 metadata 应包含 playType、workId、sourceRoute 和 userId
|
||||
|
||||
例子:
|
||||
| 玩法 |
|
||||
| puzzle |
|
||||
| match3d |
|
||||
| square-hole |
|
||||
| custom-world |
|
||||
| big-fish |
|
||||
| visual-novel |
|
||||
```
|
||||
|
||||
### 异步生成 / SSE 行为
|
||||
|
||||
```gherkin
|
||||
功能: AI 创作会话流式回复
|
||||
为了让用户看到生成进度
|
||||
作为创作者
|
||||
我希望提交创作指令后能收到流式反馈并最终得到可编辑草稿
|
||||
|
||||
场景: 成功生成草稿
|
||||
假如用户已登录并创建了创作会话
|
||||
当用户提交有效的创作指令
|
||||
那么系统应开始展示流式回复
|
||||
而且生成结束后应展示草稿结果
|
||||
而且会话快照应包含最新用户输入和 AI 回复
|
||||
|
||||
场景: 生成失败
|
||||
假如用户已登录并创建了创作会话
|
||||
当用户提交创作指令但后端生成失败
|
||||
那么系统应展示可理解的失败状态
|
||||
而且用户应能够重试
|
||||
而且不应覆盖上一次成功生成的草稿
|
||||
```
|
||||
|
||||
## 映射到测试类型
|
||||
|
||||
| BDD 场景关注点 | 推荐测试层级 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 纯领域规则、状态机、校验 | Rust/TS 单元测试 | reducer、module-*、schema validator |
|
||||
| DTO 契约、API 请求响应 | API/contract 测试 | Axum handler、shared-contracts serde |
|
||||
| 页面渲染、按钮状态、表单校验 | 组件测试 | Vitest + Testing Library |
|
||||
| 路由、tab、页面阶段切换 | 前端集成测试 | appPageRoutes、FlowShell 行为 |
|
||||
| 登录态、发布、运行态完整链路 | E2E/smoke | Playwright 或项目 smoke 脚本 |
|
||||
| 埋点、副作用、后台导出 | 后端集成/API 测试 | tracking event、admin export |
|
||||
|
||||
原则:
|
||||
|
||||
- 不是每个 BDD 场景都必须落成 E2E。
|
||||
- 能在低层稳定验证的规则,不要强行放到脆弱的浏览器自动化里。
|
||||
- E2E 只覆盖最关键的用户旅程和跨层集成风险。
|
||||
|
||||
## 与 TDD 的配合方式
|
||||
|
||||
BDD 先回答“行为是什么”,TDD 再推动“代码怎么长出来”。
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. 写 BDD 场景,确认业务行为和验收标准。
|
||||
2. 给每个场景标注测试层级:unit / component / API / E2E。
|
||||
3. 选择一个最小场景进入 TDD。
|
||||
4. RED:先写失败测试,测试名称对应场景标题。
|
||||
5. GREEN:实现最小代码让测试通过。
|
||||
6. REFACTOR:清理重复、命名、边界和文档。
|
||||
7. 回到下一个场景,直到主要路径和关键失败路径覆盖。
|
||||
|
||||
测试命名建议:
|
||||
|
||||
```ts
|
||||
describe('帮助与反馈入口', () => {
|
||||
it('已登录用户从我的页签进入独立反馈页面', () => {
|
||||
// Given ...
|
||||
// When ...
|
||||
// Then ...
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Rust 测试命名建议:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn anonymous_user_cannot_publish_generated_draft() {
|
||||
// Given
|
||||
// When
|
||||
// Then
|
||||
}
|
||||
```
|
||||
|
||||
## 推荐产物
|
||||
|
||||
根据任务复杂度选择产物位置。使用本 skill 产出 Gherkin/BDD 场景时,必须先决定落点,不要把正式验收场景随手写在聊天记录里。
|
||||
|
||||
### Gherkin/BDD 场景默认落点
|
||||
|
||||
| 产物类型 | 推荐路径 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| 实施前分析 / 临时计划 | `.hermes/plans/<task-name>-bdd-scenarios.md` | 某次 Hermes 开发任务前,用于澄清行为、拆测试、辅助实现;不一定作为长期产品依据。 |
|
||||
| 正式产品验收 / PRD 场景 | `docs/prd/<FEATURE>_BDD_YYYY-MM-DD.md` | 产品、测试、开发都需要长期参考的验收标准、用户故事、功能边界。 |
|
||||
| 技术/API/领域行为场景 | `docs/technical/<FEATURE>_BDD_YYYY-MM-DD.md` | 后端 API、领域规则、状态机、SpacetimeDB reducer/table、SSE/异步任务、埋点副作用。 |
|
||||
| 自动化 Gherkin feature 文件 | `tests/features/*.feature` 或 `e2e/features/*.feature` | 项目已接入 Cucumber/Playwright BDD 等 Gherkin runner 时。未接入前不要随意新建测试 runner 目录。 |
|
||||
| 稳定流程或团队经验 | `.hermes/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 |
|
||||
|
||||
默认规则:
|
||||
|
||||
1. 用户只说“先用 BDD 梳理一下/写场景/写 Gherkin”,默认写到 `.hermes/plans/<task-name>-bdd-scenarios.md`。
|
||||
2. 用户说“正式验收标准/PRD/产品文档/给测试验收”,写到 `docs/prd/<FEATURE>_BDD_YYYY-MM-DD.md`。
|
||||
3. 用户说“API 行为/后端规则/状态机/埋点/异步任务/SpacetimeDB”,写到 `docs/technical/<FEATURE>_BDD_YYYY-MM-DD.md`。
|
||||
4. 用户明确要求“可执行 feature 文件”且项目已有 runner,再写 `.feature` 文件;否则先写 Markdown BDD 文档,并在测试映射中标注未来自动化落点。
|
||||
5. 如果 BDD 场景会作为编码依据,文档中必须包含“测试映射”表,标注场景要落到哪些测试文件。
|
||||
|
||||
命名建议:
|
||||
|
||||
```text
|
||||
.hermes/plans/profile-feedback-bdd-scenarios.md
|
||||
docs/prd/PROFILE_FEEDBACK_BDD_2026-05-11.md
|
||||
docs/technical/WORK_PLAY_TRACKING_BDD_2026-05-11.md
|
||||
tests/features/profile-feedback.feature
|
||||
e2e/features/invite-code.feature
|
||||
```
|
||||
|
||||
### 其他配套产物
|
||||
|
||||
除 BDD/Gherkin 场景外,相关配套内容可放在:
|
||||
|
||||
- 实施计划:`.hermes/plans/<task-name>.md`
|
||||
- 产品/验收文档:`docs/prd/<FEATURE>_PRD_YYYY-MM-DD.md`
|
||||
- 技术设计:`docs/technical/<FEATURE>_TECHNICAL_YYYY-MM-DD.md`
|
||||
- 共享经验或稳定流程:`.hermes/shared-memory/` 或 `.hermes/skills/`
|
||||
|
||||
BDD 文档建议包含:
|
||||
|
||||
```markdown
|
||||
# <功能名> BDD 验收场景
|
||||
|
||||
## 背景
|
||||
- 需求来源:
|
||||
- 相关文档:
|
||||
- 相关入口/接口:
|
||||
|
||||
## 角色与目标
|
||||
- 角色:
|
||||
- 目标:
|
||||
- 非目标:
|
||||
|
||||
## 场景清单
|
||||
|
||||
### 功能: <能力>
|
||||
|
||||
```gherkin
|
||||
场景: <场景名>
|
||||
假如 ...
|
||||
当 ...
|
||||
那么 ...
|
||||
```
|
||||
|
||||
## 测试映射
|
||||
|
||||
| 场景 | 测试层级 | 目标文件 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| ... | component | ... | planned |
|
||||
|
||||
## 开放问题
|
||||
- ...
|
||||
```
|
||||
|
||||
注意:上面的 Markdown 模板中如果嵌套代码块,需要在真实文档里调整围栏长度,避免代码块提前闭合。
|
||||
|
||||
## 评审检查清单
|
||||
|
||||
- [ ] 每个场景都有清晰角色或业务上下文。
|
||||
- [ ] Given / When / Then 没有混入过多实现细节。
|
||||
- [ ] Then 都是可观察、可测试、可人工验收的结果。
|
||||
- [ ] 覆盖成功路径、失败路径、权限/登录态、边界条件。
|
||||
- [ ] 明确哪些场景需要自动化,哪些只需人工验收。
|
||||
- [ ] 自动化测试层级合理,没有把所有行为都塞进 E2E。
|
||||
- [ ] 中文文案、剧情、注释、文档没有被无意翻译或改写成英文。
|
||||
- [ ] 涉及中文文件修改时计划运行编码检查。
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. **把 BDD 写成 UI 操作流水账。** 例如“点击第一个按钮,再点第二个按钮”。应改为用户意图和业务结果。
|
||||
2. **Then 不可验证。** “体验更顺滑”不是验收标准;要写成加载状态、错误提示、数据状态、页面阶段等可观察结果。
|
||||
3. **一个场景塞太多断言。** 长流程应拆成多个小场景,避免失败时不知道真正坏在哪里。
|
||||
4. **只写 happy path。** Genarrative 常见风险在登录态、刷新恢复、异步失败、端口/后端不可用、旧数据兼容和移动端布局。
|
||||
5. **把实现方案当成业务规则。** “调用某函数”通常不是用户行为;除非是 API/技术验收,否则放到技术设计或测试实现里。
|
||||
6. **BDD 和 TDD 脱节。** 写完场景后要映射测试层级和目标文件,否则场景容易停留在文档层。
|
||||
7. **场景词汇不统一。** work、draft、session、runtime、publish 等概念要和项目现有文档/代码保持一致。
|
||||
8. **忽略文档先行约束。** 若 PRD 不足以编码落地,先补文档,再开始工程修改。
|
||||
|
||||
## 验证与收口
|
||||
|
||||
执行 BDD 相关任务后,至少确认:
|
||||
|
||||
- [ ] 已产出或更新 BDD 场景文档/计划。
|
||||
- [ ] 场景已映射到具体测试层级和目标文件。
|
||||
- [ ] 若进入编码,已按 TDD 或等价方式先补测试。
|
||||
- [ ] 已运行相关验证命令,例如:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
npm run test -- --run <相关测试文件>
|
||||
```
|
||||
|
||||
- [ ] 若涉及后端 Rust/API,按相关 DDD/SpacetimeDB 文档运行对应 cargo/npm/API smoke 验证。
|
||||
- [ ] 若产生长期有效经验,已同步到 `.hermes/shared-memory/` 或合适的仓库级 skill。
|
||||
225
.hermes/skills/genarrative-admin-backoffice/SKILL.md
Normal file
225
.hermes/skills/genarrative-admin-backoffice/SKILL.md
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
name: genarrative-admin-backoffice
|
||||
short_description: 在 Genarrative/百梦后台新增或修改管理页、后台只读/写接口、导出能力时使用。
|
||||
description: 在 Genarrative/百梦后台新增或修改管理页、后台 BFF 接口、shared-contracts/admin DTO、admin-web 路由导航、Excel/表格导出与验证发布时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, 百梦后台, admin-web, 后台接口, Excel导出, Rust, Axum, SpacetimeDB]
|
||||
related_skills: [genarrative-play-type-integration]
|
||||
---
|
||||
|
||||
# Genarrative / 百梦后台管理功能接入流程
|
||||
|
||||
用于在 Genarrative 项目中新增或修改百梦后台管理端能力,包括后台页面、后台 API、管理端 DTO、导航路由、表格明细、导出、鉴权与验证。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增百梦后台页面或导航项,例如“埋点数据”“任务配置”“邀请码”。
|
||||
- 新增 `/admin/api/*` 接口。
|
||||
- 修改 `apps/admin-web` 的后台页面、API client、路由、Shell 导航。
|
||||
- 在后台展示 SpacetimeDB 表明细或统计数据。
|
||||
- 新增“总览 → 单表查询”这类表统计跳转与查询页联动能力时,优先复用现有总览页的表统计作为入口,不另造第二套表目录。
|
||||
- 后台导出 CSV / Excel / `.xls` 表格文件。
|
||||
- 后台数据页中与业务事件、任务、登录等链路相关的问题,不能只看后台页面;要追到对应前台/API/reducer 写入点,确认“数据何时产生”。例如排查 `daily_login` 时,不要假设它一定由认证登录接口写入;先核对当前分支实现。历史实现曾在 `GET /api/profile/tasks` 打开任务中心时写入、`POST /api/profile/tasks/{task_id}/claim` 领奖时兜底写入;后续方案A把“任务中心读取写埋点”拆出为独立 procedure,任务中心只读取/刷新进度,登录成功链路应显式调用每日登录埋点入口。
|
||||
|
||||
## 标准落地顺序
|
||||
|
||||
### 0. 先确认现有后台入口
|
||||
|
||||
在新增后台页或回答“后台某个数据在哪里”前,先核对是否已有入口,避免重复造页:
|
||||
|
||||
- 数据库表统计当前在后台“总览”页,不是独立页面:`apps/admin-web/src/pages/AdminOverviewPage.tsx` 的“表统计”面板。
|
||||
- 表统计行可直接跳转到表查询页:点击后设置 `window.location.hash = #tables?table=<tableName>`,由单独的 `#tables` 页接收参数并查询。
|
||||
- `#tables` 页应在首次加载和 `hashchange` 时都重新读取 `table` 参数,避免只在初次 mount 时生效。
|
||||
- 前端通过 `apps/admin-web/src/api/adminApiClient.ts` 的 `getAdminOverview(token)` 请求 `GET /admin/api/overview`。
|
||||
- 后端路由在 `server-rs/crates/api-server/src/app.rs` 挂载 `/admin/api/overview`,handler 为 `admin_overview`。
|
||||
- 表统计逻辑在 `server-rs/crates/api-server/src/admin.rs` 的 `fetch_database_overview`:先读 SpacetimeDB schema 表名,再逐表执行 `SELECT COUNT(*) AS row_count FROM {table_name}`;private 或当前身份不可见会显示“不可统计(private 或当前身份不可见)”。
|
||||
- DTO 在 `server-rs/crates/shared-contracts/src/admin.rs` 的 `AdminOverviewResponse` / `AdminDatabaseOverviewPayload` / `AdminDatabaseTableStatPayload`,前端对应类型在 `apps/admin-web/src/api/adminApiTypes.ts`。
|
||||
- 如果本次需求是“每张表都能查”,优先新增 `GET /admin/api/database/tables` 与 `GET /admin/api/database/tables/{tableName}/rows` 两个只读接口,并在前端新建统一的表查询页,而不是把查询逻辑塞回总览页。
|
||||
|
||||
### 1. 先补技术方案文档
|
||||
|
||||
项目要求工程修改前先检查/补充落地文档。若没有明确文档,先写到 `docs/technical/`,至少说明:
|
||||
|
||||
- 后台页面目标。
|
||||
- 后端接口路径、鉴权、query/body、response。
|
||||
- 数据来源和是否修改 SpacetimeDB schema。
|
||||
- 前端页面字段、筛选项、导出格式。
|
||||
- 验收命令。
|
||||
|
||||
示例参考:
|
||||
|
||||
- `references/admin-tracking-events-export-2026-05-07.md`
|
||||
- `references/admin-database-table-query-2026-05-08.md`
|
||||
|
||||
### 2. 后端 DTO 放 shared-contracts/admin
|
||||
|
||||
文件:
|
||||
|
||||
- `server-rs/crates/shared-contracts/src/admin.rs`
|
||||
|
||||
做法:
|
||||
|
||||
- 新增 request/query/response DTO。
|
||||
- 使用 `#[serde(rename_all = "camelCase")]`。
|
||||
- 添加中文注释。
|
||||
- 字段名与前端管理端类型保持一致。
|
||||
|
||||
如果 `apps/admin-web` 当前没有直接消费 Rust shared-contracts 生成物,还要同步:
|
||||
|
||||
- `apps/admin-web/src/api/adminApiTypes.ts`
|
||||
|
||||
### 3. 后端 handler 放 api-server/admin.rs
|
||||
|
||||
文件:
|
||||
|
||||
- `server-rs/crates/api-server/src/admin.rs`
|
||||
- `server-rs/crates/api-server/src/app.rs`
|
||||
|
||||
要求:
|
||||
|
||||
- Handler 使用 `Extension(_admin): Extension<AuthenticatedAdmin>`,并在 router 中套 `require_admin_auth`。
|
||||
- 只读接口也必须走后台鉴权。
|
||||
- query 参数使用 `Query<T>`。
|
||||
- 返回 `json_success_body(Some(&request_context), payload)`。
|
||||
- 在 `app.rs` 挂到 `/admin/api/...`。
|
||||
|
||||
### 4. 读取 SpacetimeDB 表明细时优先 HTTP SQL 只读
|
||||
|
||||
适合后台只读运营页:
|
||||
|
||||
- 不改表结构。
|
||||
- 不新增 reducer。
|
||||
- API Server 通过 SpacetimeDB HTTP SQL 读取真实数据。
|
||||
|
||||
注意:
|
||||
|
||||
- SQL 字段固定白名单,不要 `SELECT *`。
|
||||
- 用户输入只允许有限筛选字段,手动 trim、白名单枚举、字符串转义。
|
||||
- limit 必须 clamp,例如默认 200、最大 1000。
|
||||
- SpacetimeDB 2.2 HTTP SQL 不支持 `ORDER BY`;如果后台需要倒序展示明细,SQL 中不要拼 `ORDER BY`,先查有限 `LIMIT`,再在 api-server 内按时间字段排序,否则会返回 `HTTP 400 Unsupported: SELECT ... ORDER BY ... LIMIT ...`。
|
||||
- 如果 HTTP SQL 返回 `no such table ... If the table exists, it may be marked private`,不要急着改表名或新增 reducer;先确认本地 CLI 是否以当前 standalone 的 identity/token 登录。清空本地数据库或重建 standalone 后,旧 CLI token 可能看不到 private table。按“本地 private table SQL 权限修复”流程用 `/v1/identity` 获取 token,再 `spacetime login --token` 登录。
|
||||
- SQL 解析要兼容 SpacetimeDB HTTP SQL 的 statement array + rows 形态。
|
||||
- SpacetimeDB HTTP SQL 读取 private table 时,enum / Option / Timestamp 可能以 SATS 原始 JSON 返回,例如 `scope_kind=[3,[]]`、`Some("user")=[0,"user"]`、`None=[1,[]]`、`Timestamp=[1778207451731746]`。后台列表、详情弹窗和 Excel 导出不要直接展示这些原始形态;应在 api-server 解析层或前端展示层转换为人可读值:enum 映射为业务字符串,Option 的 None 显示 `-`,微秒级 Timestamp 格式化为本地可读时间。
|
||||
- 可复用已有 `/v1/database/{db}/sql` 请求风格和 token 配置。
|
||||
|
||||
### 5. 前端接入 admin-web
|
||||
|
||||
常改文件:
|
||||
|
||||
- `apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- `apps/admin-web/src/api/adminApiClient.ts`
|
||||
- `apps/admin-web/src/app/adminRoutes.ts`
|
||||
- `apps/admin-web/src/app/AdminShell.tsx`
|
||||
- `apps/admin-web/src/app/AdminApp.tsx`
|
||||
- `apps/admin-web/src/pages/<AdminXxxPage>.tsx`
|
||||
- `apps/admin-web/src/styles/admin.css`
|
||||
|
||||
接入步骤:
|
||||
|
||||
1. 在 `adminApiTypes.ts` 增加 query/entry/list 类型。
|
||||
2. 在 `adminApiClient.ts` 增加 API 方法;用 `URLSearchParams` 拼非空 query。
|
||||
3. 在 `adminRoutes.ts` 增加 route id、label、hash。
|
||||
4. 在 `AdminShell.tsx` 增加 route icon,`routeIcons` 必须覆盖全部 `AdminRouteId`。
|
||||
5. 在 `AdminApp.tsx` import 并按 routeId 渲染页面。
|
||||
6. 新增页面组件,保持 UI 简洁,不写大段规则说明。
|
||||
7. 如果页面通过 hash 携带子参数,路由解析和页内参数解析要分开:`resolveAdminRoute()` 只负责路由片段,页面组件自己解析 `?table=` 之类的查询参数;同时要监听 `hashchange`,避免切页后参数不同步。
|
||||
8. 列表行点击跳转优先用 hash,不要额外引入全局路由库或重新发明一套页面状态系统。
|
||||
|
||||
## Excel 导出推荐做法
|
||||
|
||||
后台运营导出不一定要引入 `xlsx` 依赖;简单表格可用浏览器端 HTML table + `.xls`:
|
||||
|
||||
- Blob MIME:`application/vnd.ms-excel;charset=utf-8`
|
||||
- 文件扩展名:`.xls`
|
||||
- 文本前加 UTF-8 BOM / `<meta charset="UTF-8">`。
|
||||
- 所有单元格做 HTML escape。
|
||||
- ID、大数字、日期类字段使用 `mso-number-format:'\@';` 保持文本格式,避免 Excel 科学计数法。
|
||||
- 导出当前筛选结果,避免后端新增 Excel 库依赖。
|
||||
|
||||
## 本地启动与联调
|
||||
|
||||
后台改完后如需本地查看页面和接口,优先按本次联调范围选择脚本:
|
||||
|
||||
```bash
|
||||
# 只看后台页面 + api-server,不要求 SpacetimeDB 真实数据
|
||||
npm run api-server
|
||||
npm run admin-web:dev -- --host 127.0.0.1
|
||||
|
||||
# 完整 Rust 本地栈:SpacetimeDB + 发布模块 + api-server + 主站 + 后台
|
||||
npm run dev
|
||||
```
|
||||
|
||||
验证地址通常为:
|
||||
|
||||
- `npm run api-server` 单独启动:api-server `http://127.0.0.1:3100/healthz`,后台前端 `http://127.0.0.1:5173/admin/`。
|
||||
- `npm run dev` 完整栈:SpacetimeDB `http://127.0.0.1:3101/v1/ping`,api-server `http://127.0.0.1:8082/healthz`,主站 `http://127.0.0.1:3000/`,后台 `http://127.0.0.1:3102/admin/`。
|
||||
|
||||
注意:
|
||||
|
||||
- `npm run api-server` 首次启动可能先编译 Rust,后台进程短时间内无完整日志;等待编译完成后再查端口。
|
||||
- 不要默认用 `3200` 验证 api-server;当前脚本环境变量常见为 `GENARRATIVE_API_PORT=3100`。不确定时用 `ss -ltnp | grep api-server` 或读取进程环境核对,敏感值输出必须打码。
|
||||
- `admin-web` 的 `/` 可能返回 302 跳转到 `/admin/`;验证前端时直接请求 `/admin/`。
|
||||
- api-server 启动日志中 SpacetimeDB `127.0.0.1:3101` 连接被拒绝,不一定代表 api-server 没起来;只表示依赖的本地 SpacetimeDB 不可用。后台中需要读 SpacetimeDB 的页面(如埋点明细、表查询)要等 SpacetimeDB 可用后才能返回真实数据。
|
||||
- `npm run dev` 依赖 `spacetime` CLI;先用 `command -v spacetime && spacetime --version` 确认可用。
|
||||
- 本地和人工排障不再使用 `spacetime --root-dir`。如果看到 `bin/current/spacetimedb-cli` 缺失类错误,优先确认是否仍在运行旧脚本或旧发布包;本地开发应使用 `npm run dev` / `npm run dev:rust`,通过项目脚本和 `--data-dir` 隔离 SpacetimeDB 数据目录,不再把用户级 SpacetimeDB 安装同步到项目目录。
|
||||
|
||||
- `scripts/dev-rust-stack.sh` 默认 `api timeout: 300s`. 合并 master 后首次 Rust 依赖/工作区重编译可能超过 300s,导致完整 `npm run dev` 在 api-server 就绪前超时并回收 SpacetimeDB。先让 Rust 编译完成,或临时用 `bash scripts/dev-rust-stack.sh --skip-spacetime --skip-publish --api-timeout-seconds 900` 预热 api-server 编译;之后再重新跑完整 `npm run dev`。
|
||||
- 用户贴出的 Hermes background watch 通知可能来自已退出的旧 session。先用 `process poll` 查该 session 状态,再判断是否需要处理;不要把旧失败误判成当前服务失败。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# Rust 格式化检查
|
||||
cd server-rs
|
||||
cargo fmt -p api-server -p shared-contracts --check
|
||||
|
||||
# 后端相关测试,按测试名过滤
|
||||
cargo test -p api-server admin_tracking -- --nocapture
|
||||
|
||||
# 前端后台类型检查 / 构建
|
||||
cd ..
|
||||
npm run admin-web:typecheck
|
||||
npm run admin-web:build
|
||||
|
||||
# 中文/编码检查
|
||||
npm run check:encoding
|
||||
|
||||
# diff 空白检查
|
||||
git diff --check
|
||||
```
|
||||
|
||||
如果 `npm run admin-web:typecheck` 报 `Cannot find module .../node_modules/typescript/bin/tsc`,说明当前 worktree 未安装 npm 依赖;先运行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
不要把该错误误判成 TypeScript 代码错误。
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 只在 `app.rs` import handler 不够,必须实际 `.route(...)` 挂载,并套 `require_admin_auth`。
|
||||
2. `cargo fmt --manifest-path server-rs/Cargo.toml` 在该 workspace 可能报 `Failed to find targets`;进入 `server-rs` 后用 `cargo fmt --all` 或 `cargo fmt -p api-server -p shared-contracts --check`。
|
||||
3. `cargo fmt --all` 可能格式化不相关 Rust 文件;提交前用 `git status` 检查并 revert 非本任务文件。
|
||||
4. patch 工具对 Rust 单文件 lint 可能用 Rust 2015 edition 误报 `async fn is not permitted in Rust 2015`;以 `cargo test/check` 为准。
|
||||
5. `adminRoutes` 新增 route id 后,`AdminShell.routeIcons` 必须同步,否则 TypeScript 会因 `satisfies Record<AdminRouteId, ...>` 报错。
|
||||
- 后台页面中的中文和 JSON 预览要避免整文件重写导致编码问题;修改后运行 `npm run check:encoding`。
|
||||
- 后台数据页移动端要保证表格横向滚动,不要让整页布局撑坏。
|
||||
- 若用户追问“之前不是说要把 npm run dev 修好吗”这类已承诺的 dev 启动问题,不要只解释;先复现 `npm run dev`,再按启动日志修脚本并验证到服务就绪。WSL/Linux 下本地开发应走 `spacetime start --data-dir=server-rs/.spacetimedb/local/data` 这一类数据目录隔离,不再用项目级 `--root-dir`,详见 `references/dev-rust-stack-startup-2026-05-08.md`。
|
||||
- 涉及敏感配置、token、密码、连接串时,输出和文档中统一写 `[REDACTED]`。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- `references/admin-database-table-query-2026-05-08.md`:本次后台数据库表查询接入的实现要点、校验规则与验证结果。
|
||||
- `references/admin-tracking-events-export-2026-05-07.md`:本次新增后台“埋点数据”页、SpacetimeDB HTTP SQL 只读明细、前端 `.xls` 导出的实现细节。
|
||||
- `references/private-table-sql-token-refresh.md`:本地清库/重建 standalone 后,用 `/v1/identity` + `spacetime login --token` 刷新 CLI token,以便 HTTP SQL 读取 private table。
|
||||
- `references/spacetimedb-http-sql-sats-display.md`:通过 HTTP SQL 读取 private table 时,enum / Option / Timestamp 的 SATS 原始 rows 如何转换为后台列表、详情和 Excel 可读值。
|
||||
- `references/daily-login-tracking-trigger-points.md`:排查后台 `daily_login` 埋点为何不是登录接口写入,而是任务中心读取/领奖兜底写入的触发点记录。
|
||||
- `references/daily-login-auth-closure.md`:将方案A拆出的每日登录埋点入口接入真实认证成功链路时的推荐接入点、非阻断语义、测试和提交注意事项。
|
||||
- `references/dev-rust-stack-startup-2026-05-08.md`:`npm run dev` / `scripts/dev-rust-stack.sh` 在 WSL/Linux 下改用用户级 SpacetimeDB CLI、项目数据目录和显式 publish server,避免项目级 `--root-dir` 与冷编译超时的修复记录。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 本次后台表查询接入的可复用经验
|
||||
|
||||
## 需求落点
|
||||
- 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=<name>`。
|
||||
- 新增独立 `#tables` 页承载表选择、关键词搜索、JSON filters、limit、行详情弹窗。
|
||||
|
||||
## 后端实现要点
|
||||
- 新增只读接口:
|
||||
- `GET /admin/api/database/tables`
|
||||
- `GET /admin/api/database/tables/{table_name}/rows`
|
||||
- 表名必须来自 schema 白名单;再加一层 identifier 校验,避免任意 SQL 表名注入。
|
||||
- `limit` 必须 clamp;本次实现使用默认 100、最大 500。
|
||||
- `search` / `filters` 不进入 SQL 字符串:
|
||||
- SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}`
|
||||
- 返回后在 api-server 内存中过滤
|
||||
- `filters` 仅接受 JSON object,按列名匹配;非 object 直接 400
|
||||
- SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。
|
||||
|
||||
## 前端实现要点
|
||||
- `adminRoutes` 必须新增 `tables`,`AdminShell.routeIcons` 也要同步覆盖。
|
||||
- `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`。
|
||||
- worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。
|
||||
|
||||
## 验证结果
|
||||
- `cargo test -p api-server admin_database -- --nocapture` 通过。
|
||||
- `cargo fmt --manifest-path Cargo.toml -p api-server -p shared-contracts --check` 通过。
|
||||
- `npm run admin-web:typecheck` 通过。
|
||||
- `npm run admin-web:build` 通过。
|
||||
- `npm run check:encoding` 通过。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 后台埋点数据页与本地启动验证记录(2026-05-07)
|
||||
|
||||
## 背景
|
||||
|
||||
本次在 Genarrative/百梦后台新增“埋点数据”页:
|
||||
|
||||
- 后端新增 `GET /admin/api/tracking/events`。
|
||||
- shared-contracts 新增 admin tracking query/list/entry DTO。
|
||||
- 前端新增 `#tracking` 路由、导航、表格、详情面板与 `.xls` 导出。
|
||||
- 导出使用浏览器端 HTML table + Excel MIME,不引入 `xlsx` 依赖。
|
||||
|
||||
## 关键实现点
|
||||
|
||||
- 后台只读接口仍必须套 `require_admin_auth`。
|
||||
- SpacetimeDB 明细读取使用 HTTP SQL,不新增 reducer、不改 schema。
|
||||
- SQL 固定白名单列,不用 `SELECT *`。
|
||||
- Query 只允许 `eventKey/userId/scopeKind/scopeId/limit`。
|
||||
- `scopeKind` 只允许 `site/work/module/user`。
|
||||
- limit 默认 200,最大 1000。
|
||||
- SpacetimeDB HTTP SQL 响应要兼容 statement array + `rows`,Option 可能表现为 `{ "some": value }`。
|
||||
- 前端导出 `.xls` 时给单元格加 `mso-number-format:'\\@';`,防止 Excel 把 ID 转科学计数法。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
npm install # 若 node_modules 缺失
|
||||
npm run admin-web:typecheck
|
||||
npm run admin-web:build
|
||||
npm run check:encoding
|
||||
|
||||
cd server-rs
|
||||
cargo fmt -p api-server -p shared-contracts --check
|
||||
cargo test -p api-server admin_tracking -- --nocapture
|
||||
```
|
||||
|
||||
## 本地启动观察
|
||||
|
||||
启动命令:
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
npm run api-server
|
||||
npm run admin-web:dev -- --host 127.0.0.1
|
||||
```
|
||||
|
||||
实际验证:
|
||||
|
||||
- api-server 监听 `127.0.0.1:3100`,健康检查为 `http://127.0.0.1:3100/healthz`。
|
||||
- admin-web 监听 `127.0.0.1:5173`,后台地址为 `http://127.0.0.1:5173/admin/`。
|
||||
- 请求 `http://127.0.0.1:5173/` 会 302 到 `/admin/`。
|
||||
- 不能默认用 3200 检查 api-server;本地脚本通过 `GENARRATIVE_API_PORT=3100` 启动。
|
||||
- 如果启动日志出现 SpacetimeDB `127.0.0.1:3101` connection refused,api-server 仍可能已正常监听;这是依赖的本地 SpacetimeDB 未启动,埋点页读真实数据会受影响。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 真实登录成功链路接入每日登录埋点(2026-05-08)
|
||||
|
||||
## 背景
|
||||
|
||||
后台“埋点数据”页要能看到真实登录产生的 `daily_login`。此前已完成方案 A:把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure/client 方法,避免后台查看或刷新任务中心污染登录数据。
|
||||
|
||||
闭环时不要再把写入点放回任务中心读取流程;应在认证成功且会话签发后显式调用每日登录埋点入口。
|
||||
|
||||
## 推荐接入点
|
||||
|
||||
在 `api-server` 认证成功路径中,先创建/签发会话,再非阻断记录埋点,再同步认证快照并返回:
|
||||
|
||||
1. `create_auth_session` / `create_password_auth_session` 成功。
|
||||
2. 调用统一 helper:`record_daily_login_tracking_event_after_auth_success(...)`。
|
||||
3. helper 调用 `state.spacetime_client().record_daily_login_tracking_event(user_id.to_string()).await`。
|
||||
4. 成功写 `info`,失败写 `warn`,不能把埋点失败返回给用户。
|
||||
|
||||
已验证的真实登录链路包括:
|
||||
|
||||
- 手机验证码登录:`server-rs/crates/api-server/src/phone_auth.rs` 的 `phone_login`。
|
||||
- 密码登录入口:`server-rs/crates/api-server/src/password_entry.rs` 的 `password_entry`。
|
||||
- 重置密码后自动登录:`server-rs/crates/api-server/src/password_management.rs` 的 `reset_password`。
|
||||
- 微信 OAuth 回调登录:`server-rs/crates/api-server/src/wechat_auth.rs` 的 `handle_wechat_callback`。
|
||||
- 微信绑定手机号后自动登录:`server-rs/crates/api-server/src/wechat_auth.rs` 的 `bind_wechat_phone`。
|
||||
- refresh cookie 续期:`server-rs/crates/api-server/src/refresh_session.rs` 的 `refresh_session`。在 `rotate_session` 成功并签发新 access token 后记录,`login_method` 应使用 `rotated.session.issued_by_provider.clone()`,不要固定写成 Password。
|
||||
|
||||
## 关键实现约束
|
||||
|
||||
- 埋点是运营数据,必须保持非阻断:SpacetimeDB 调用失败只记录日志,不影响登录成功返回。
|
||||
- helper 建议放在 `auth_session.rs`,避免各登录 handler 重复错误处理。
|
||||
- refresh cookie 续期也被产品视为一次每日登录触发;接入 `refresh_session.rs` 时必须放在 token rotate 和 access token 签发成功之后,且保持非阻断,避免刷新失败或缺 cookie 时误写埋点。
|
||||
- `handle_wechat_callback` 如果要记录 `request_id/operation`,需要在 handler 参数中补 `Extension<RequestContext>`;确认路由层已注入 RequestContext。
|
||||
- 单元测试默认不启动 SpacetimeDB。若直接调用真实 `spacetime_client` 会让既有认证测试依赖外部服务;可在 `#[cfg(test)]` 下让 helper no-op,仅用编译和现有登录成功测试覆盖调用点不破坏返回。
|
||||
- 后续如需要严格断言“helper 被调用”,应优先为 Spacetime client 引入可注入 trait/mock,而不是让 API 单测连接真实 SpacetimeDB。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
cd server-rs
|
||||
cargo fmt -p api-server --check
|
||||
cargo check -p api-server
|
||||
cargo check -p spacetime-client
|
||||
cargo test -p api-server auth_session -- --nocapture
|
||||
cargo test -p api-server refresh_session_rotates_cookie_and_returns_new_access_token -- --nocapture
|
||||
cargo test -p api-server password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie -- --nocapture
|
||||
cargo test -p api-server phone_login_creates_user_and_sets_refresh_cookie -- --nocapture
|
||||
cd ..
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 提交注意
|
||||
|
||||
- 不要提交 `.env.local`、`.env.secrets.local` 或任何 token/密码/连接串。
|
||||
- 若工作区里有本地敏感文件,只提交明确改动的 Rust 文件和 `docs/technical/*` 文档。
|
||||
@@ -0,0 +1,97 @@
|
||||
# Genarrative daily_login 埋点触发点排查记录
|
||||
|
||||
## 背景
|
||||
|
||||
用户在后台“埋点数据”页看到 `daily_login` 事件后询问:为什么每日登录埋点看起来只有在用户领取每日登录任务奖励后才记录,而不是登录时记录。
|
||||
|
||||
## 结论
|
||||
|
||||
当前代码口径里,`daily_login` 不是认证登录成功瞬间写入的事件。它挂在个人任务链路:
|
||||
|
||||
- `GET /api/profile/tasks`:读取任务中心时会记录当日 `daily_login`,并刷新任务进度。
|
||||
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励时,如果任务配置是 daily_login,会兜底记录当日 `daily_login`。
|
||||
|
||||
因为 `record_daily_login_tracking_event` 用 `daily-login:<user_id>:<day_key>` 作为 event id,并先查重,所以同一用户同一北京自然日最多写一条。
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`
|
||||
- 第 47 行左右写明:用户打开任务中心时后端幂等记录当日 `daily_login`;点击领取时校验进度和领奖记录。
|
||||
- 接口说明中写明 `GET /api/profile/tasks` 会读取任务中心并记录当日登录埋点。
|
||||
- `server-rs/crates/api-server/src/runtime_profile.rs`
|
||||
- `get_profile_task_center` 调用 `state.spacetime_client().get_profile_task_center(user_id)`。
|
||||
- `claim_profile_task_reward` 调用 `state.spacetime_client().claim_profile_task_reward(user_id, task_id)`。
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- `get_profile_task_center_snapshot(..., record_login_event: bool)` 在 `record_login_event` 为 true 时调用 `record_daily_login_tracking_event`。
|
||||
- `claim_profile_task_reward_record` 对 daily_login 任务调用 `record_daily_login_tracking_event` 作为兜底。
|
||||
- `record_daily_login_tracking_event` 负责生成 event id、查重、写入 `tracking_event` 和更新 `tracking_daily_stat`。
|
||||
- `server-rs/crates/api-server/src/phone_auth.rs`
|
||||
- 手机号登录成功后做验证码校验、新用户奖励、邀请码绑定、session 签发、认证快照同步;当前没有写入 `daily_login`,也没有调用任务中心接口。
|
||||
|
||||
## 排查方法
|
||||
|
||||
1. 不要只看后台埋点页。先搜事件 key 和任务接口:
|
||||
|
||||
```bash
|
||||
git grep -n "daily_login\|tracking_event\|get_profile_task_center\|claim_profile_task_reward" -- server-rs apps docs
|
||||
```
|
||||
|
||||
2. 对照设计文档中的事件口径:
|
||||
|
||||
```bash
|
||||
sed -n '35,58p' docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md
|
||||
```
|
||||
|
||||
3. 追 API handler 到 SpacetimeDB reducer:
|
||||
|
||||
- `api-server/src/runtime_profile.rs`
|
||||
- `spacetime-client` 对应 procedure wrapper
|
||||
- `spacetime-module/src/runtime/profile.rs`
|
||||
|
||||
4. 再看真实登录接口是否写入同一事件。手机号登录入口是:
|
||||
|
||||
- `server-rs/crates/api-server/src/phone_auth.rs::phone_login`
|
||||
|
||||
## 常见误判
|
||||
|
||||
- 后台只是在展示 `tracking_event`,不是事件产生点。
|
||||
- “每日登录”这个中文名容易让人以为它必然在 auth 登录成功时写入;当前实现不是这样。
|
||||
- 如果用户登录后没有打开“我的/任务中心”,只在领奖时触发 claim 接口,就会表现为“领奖时才出现埋点”。
|
||||
- 领取接口里的写入是兜底,避免用户直接点击领取时因为未先打开任务中心而无法完成每日任务。
|
||||
|
||||
## 后续方案A落地记录
|
||||
|
||||
在后续修复中,采用“方案A”:把“读取任务中心时顺手记录每日登录埋点”拆成独立 SpacetimeDB procedure,使任务中心读取只负责读取/刷新进度,避免后台查看或刷新任务中心时污染埋点数据。
|
||||
|
||||
关键变化:
|
||||
|
||||
- `server-rs/crates/module-runtime/src/domain.rs`
|
||||
- 新增 `RuntimeTrackingEventProcedureResult { ok, error_message }`,用于返回纯事件写入结果。
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 新增 `record_daily_login_tracking_event_and_return(ctx, input)` procedure。
|
||||
- `get_profile_task_center` 注释和行为调整为只读取/刷新任务进度,不再作为每日登录埋点产生点。
|
||||
- `server-rs/crates/spacetime-client/src/runtime.rs`
|
||||
- 新增 `record_daily_login_tracking_event(user_id)` client 方法,调用新 procedure。
|
||||
- `server-rs/crates/spacetime-client/src/mapper.rs`
|
||||
- 新增 `map_runtime_tracking_event_procedure_result`,把 `ok=false` 映射为 `procedure_failed`。
|
||||
|
||||
落地注意:
|
||||
|
||||
- 这一步只拆出后端写入入口,不等于所有登录方式已经接入;接入手机号/微信/密码等认证成功链路前,需确认产品口径:统计登录成功是否应覆盖所有登录方式,以及事件失败是否阻断登录。
|
||||
- 修改 `spacetime-module` procedure 后,通常需要重新生成/同步 SpacetimeDB 绑定;若直接手补 `spacetime-client/src/module_bindings`,要非常谨慎,因为该目录声明为自动生成。
|
||||
- patch 工具可能对 Rust 单文件使用 2015 edition lint,看到 `async fn is not permitted in Rust 2015` 时不要立即按该误报改代码,应以 `cd server-rs && cargo test/check ...` 为准。
|
||||
|
||||
验证记录:
|
||||
|
||||
```bash
|
||||
cd server-rs
|
||||
cargo test -p module-runtime runtime_profile_task_status_matches_progress_and_claim -- --nocapture
|
||||
```
|
||||
|
||||
该测试通过可验证任务中心领域进度/领取逻辑未被破坏;完整接入认证链路后还应补 api-server 层登录成功埋点测试。
|
||||
|
||||
## 后续设计建议
|
||||
|
||||
如果产品口径要求“登录成功就算每日登录”,应把 `daily_login` 写入点前移到统一 auth 登录成功链路,并覆盖手机号/微信/密码等登录方式;任务中心只读取进度或最多保留幂等兜底。
|
||||
|
||||
如果需要同时分析真实登录和任务完成,建议新增独立事件,例如 `auth_login_success` / `user_login_success`,让 `daily_login` 继续表示每日任务完成条件。
|
||||
@@ -0,0 +1,46 @@
|
||||
# `npm run dev` / `scripts/dev-rust-stack.sh` 启动修复记录
|
||||
|
||||
## 症状
|
||||
- 多个 worktree 同时本地开发时,SpacetimeDB 数据库名可能相同,早期曾用项目级 CLI root 隔离 CLI 状态来规避冲突。
|
||||
- 实测后确认:真正需要隔离的是 standalone 的 `data-dir`,不需要把 publish 也绑到项目级 CLI root。
|
||||
- 早期脚本曾通过把用户级 SpacetimeDB 可执行文件目录同步到 `server-rs/.spacetimedb/local/bin/current` 来满足 standalone 回调需求,但这会把整套可执行文件复制进项目本地目录,维护成本高,也容易和用户级 CLI 版本漂移。
|
||||
- 多个 worktree 同时启动时,SpacetimeDB 端口可能冲突;CLI 会询问是否使用最近的可用端口。
|
||||
- 若 `npm run dev` 从仓库根目录直接执行 `spacetime publish --module-path server-rs/crates/spacetime-module`,内部 Cargo 不一定读取 `server-rs/.cargo/config.toml`,可能绕过 sccache/linker/target 缓存策略,表现为 spacetime-module 每次像全量重编译。
|
||||
- `api-server` 首次冷编译时,默认 300 秒超时不够,容易在就绪前被回收。
|
||||
|
||||
## 当前方案
|
||||
1. SpacetimeDB 可执行文件继续使用用户环境里的 `spacetime` 命令
|
||||
- 启动 standalone 时不再复制 `spacetimedb-cli`、版本目录或 `bin/current`。
|
||||
- `spacetime start` 不再通过工程内 CLI root 寻找可执行文件。
|
||||
2. 数据目录显式指定到项目本地
|
||||
- 默认 `SPACETIME_DATA_DIR=${SERVER_RS_DIR}/.spacetimedb/local/data`。
|
||||
- 启动命令使用 `spacetime start --data-dir "${SPACETIME_DATA_DIR}" --listen-addr ...`。
|
||||
- 如需临时切换数据目录,可传 `--spacetime-data-dir <path>`。
|
||||
3. 端口冲突时自动接受 SpacetimeDB 建议端口
|
||||
- 启动时不传 `--non-interactive`。
|
||||
- 脚本向 `spacetime start` 发送回车,接受“最近可用端口”的默认建议。
|
||||
- 随后从启动日志中的 `Starting SpacetimeDB listening on ...` 解析实际端口。
|
||||
- 解析出的实际端口会覆盖 `SPACETIME_SERVER`,后续 publish、api-server、前端代理统一使用这个端口。
|
||||
4. publish 不再使用项目级 CLI root,但要从 `server-rs` 目录执行
|
||||
- 发布模块改为在 `server-rs` 下执行 `spacetime publish ... --server "${SPACETIME_SERVER}" ...`。
|
||||
- 这样 publish 使用用户级 CLI 默认身份/配置,不再依赖 worktree 内 CLI root。
|
||||
- 同时确保内部 Cargo 能读取 `server-rs/.cargo/config.toml`,复用项目级 sccache/linker/target 缓存策略,避免 `npm run dev` 比手动 publish 更容易触发慢速重编译。
|
||||
5. 提高 api-server 就绪等待时间
|
||||
- `API_SERVER_TIMEOUT_SECONDS` 保持 600,降低首次冷编译误判失败概率。
|
||||
|
||||
## 复现 / 验证
|
||||
- 运行脚本语法检查:`bash -n scripts/dev-rust-stack.sh`。
|
||||
- 运行帮助检查:`bash scripts/dev-rust-stack.sh --help`,确认有 `--spacetime-data-dir`。
|
||||
- 运行 `npm run dev` 后观察日志:
|
||||
- 输出 `spacetime data: .../server-rs/.spacetimedb/local/data`。
|
||||
- 不再出现同步/复制本机 SpacetimeDB 安装到项目 CLI root 的日志。
|
||||
- SpacetimeDB 能正常监听,并输出 `spacetime actual: http://127.0.0.1:<实际端口>`。
|
||||
- 若默认端口被占用,脚本应自动接受 SpacetimeDB 建议端口,并用实际端口发布模块、启动 api-server 和前端代理。
|
||||
- 模块发布成功。
|
||||
- 第二次无改动 publish 应接近手动 `cd server-rs && spacetime publish ...` 的增量速度。
|
||||
- api-server 进入健康检查等待并最终可访问 `/healthz`。
|
||||
|
||||
## 相关文件
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- `server-rs/.spacetimedb/local/data/`
|
||||
- `server-rs/.cargo/config.toml`
|
||||
@@ -0,0 +1,37 @@
|
||||
# 本地 private table SQL 权限修复
|
||||
|
||||
场景:
|
||||
- 后台或 api-server 通过 SpacetimeDB HTTP SQL 读取 `tracking_event` 这类 private table。
|
||||
- 本地清库、重建 standalone 或重新发布模块后,原 CLI token 失效,SQL 可能报 `no such table ... If the table exists, it may be marked private`。
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 清空本地 SpacetimeDB 数据目录
|
||||
- 使用项目脚本停止本地实例后,备份或删除 `server-rs/.spacetimedb/local/data`。
|
||||
- 只清本地开发环境,不要误伤远端或其他 worktree。
|
||||
|
||||
2. 启动本地 standalone
|
||||
- 用项目约定的 `scripts/dev-rust-stack.sh` 或等价命令启动 `spacetime`。
|
||||
- 确认 `/v1/ping` 可访问后再取 identity。
|
||||
|
||||
3. 通过 `/v1/identity` 获取新 token 和 identity
|
||||
- 使用 `POST http://127.0.0.1:3101/v1/identity`
|
||||
- 只记录 identity,不要在日志中打印 token 明文。
|
||||
|
||||
4. 用新 token 登录 CLI
|
||||
- 运行:`spacetime login --token <token>`
|
||||
- 这会把 token 写到本地 CLI 配置,后续 HTTP SQL 可读 private table。
|
||||
|
||||
5. 重新验证 SQL
|
||||
- 使用带 token 的 `POST /v1/database/<db>/sql`
|
||||
- 先尝试 `SELECT ... FROM tracking_event LIMIT 1`
|
||||
- 若成功,再让 api-server 走同样 token。
|
||||
|
||||
6. 如果 api-server 需要复用 token
|
||||
- 优先读取项目内本地 CLI 配置中的 token,而不是硬编码或回填到 `.env`。
|
||||
- 输出日志时统一 `[REDACTED]`。
|
||||
|
||||
排查要点:
|
||||
- `ORDER BY` 和 private table 是两个独立问题,先分开修。
|
||||
- 清库后旧 token 很可能不再能看见 private table,不代表表不存在。
|
||||
- 若 `/v1/identity` 返回的 token 没权限,再检查当前 standalone 是否就是刚启动的本地实例、database 名是否一致、模块是否已重新发布。
|
||||
@@ -0,0 +1,43 @@
|
||||
# SpacetimeDB HTTP SQL SATS 值后台展示处理
|
||||
|
||||
本参考用于 Genarrative 后台通过 SpacetimeDB HTTP SQL 读取表明细并展示/导出时,处理 SQL rows 中的 SATS 原始 JSON 值。
|
||||
|
||||
## 典型现象
|
||||
|
||||
读取 private table(例如 `tracking_event`)后,HTTP SQL 可能返回如下原始形态:
|
||||
|
||||
- enum:`RuntimeTrackingScopeKind::User` 返回 `[3, []]`
|
||||
- `Option<String>::Some("user_00000001")` 返回 `[0, "user_00000001"]`
|
||||
- `Option<String>::None` 返回 `[1, []]`
|
||||
- `Timestamp` 返回 `[1778207451731746]`
|
||||
|
||||
如果直接 `value.to_string()` 展示,后台会出现 `[3,[]]`、`[0,"..."]`、`[1,[]]`、`[1778207451731746]`,运营不可读。
|
||||
|
||||
## 推荐处理
|
||||
|
||||
1. 后端解析层优先标准化:
|
||||
- Option:`[0, value] -> value`,`[1, []] -> None`
|
||||
- enum:按生成 binding 的 variant 顺序映射,例如 `RuntimeTrackingScopeKind` 为 `site/work/module/user`,索引 `0/1/2/3` 分别对应这些字符串
|
||||
- Timestamp:单元素数组 `[micros] -> "micros"`
|
||||
2. 前端展示层再格式化时间:
|
||||
- 纯数字时间戳按微秒处理:`Date(Math.floor(micros / 1000))`
|
||||
- ISO 字符串用 `new Date(value)`
|
||||
- 展示为 `YYYY-MM-DD HH:mm:ss`
|
||||
3. 列表、详情弹窗、Excel 导出必须使用同一套格式化结果,避免导出仍残留 SATS 原始值。
|
||||
4. 增加单测覆盖 SATS 原始 rows,至少断言:
|
||||
- `[3, []] -> user`
|
||||
- `[0, "user"] -> Some("user")`
|
||||
- `[1, []] -> None`
|
||||
- `[1778207451731746] -> "1778207451731746"`
|
||||
|
||||
## 验收建议
|
||||
|
||||
- `cargo test -p api-server admin_tracking -- --nocapture`
|
||||
- `npm run admin-web:typecheck`
|
||||
- `npm run admin-web:build`
|
||||
- `npm run check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
## 注意
|
||||
|
||||
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
|
||||
132
.hermes/skills/genarrative-auth-session-flow/SKILL.md
Normal file
132
.hermes/skills/genarrative-auth-session-flow/SKILL.md
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: genarrative-auth-session-flow
|
||||
description: 在 Genarrative 中排查或修改登录、access token、refresh cookie、AuthGate 会话恢复、登录态刷新、认证埋点链路时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, auth, session, cookie, refresh-token, AuthGate, tracking]
|
||||
related_skills: [systematic-debugging, test-driven-development, genarrative-profile-features]
|
||||
---
|
||||
|
||||
# Genarrative 认证会话与登录埋点链路
|
||||
|
||||
用于 Genarrative 中登录、会话恢复、refresh cookie 续期、access token 补票、AuthGate 恢复登录态,以及每日登录/认证相关埋点的排查与修改。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户反馈登录态、cookie、自动续期、刷新页面后状态异常。
|
||||
- 修改 `AuthGate`、`apiClient`、`authService` 或 Rust `api-server` 认证接口。
|
||||
- 排查“已登录但打开网页没有触发登录埋点”等 session restore 场景。
|
||||
- 修改手机验证码登录、密码登录、微信登录、重置密码后自动登录、refresh session rotate。
|
||||
- 需要判断某个前端动作是否真正调用了后端 refresh/session 或埋点 procedure。
|
||||
|
||||
## 关键代码路径
|
||||
|
||||
前端:
|
||||
|
||||
- `src/components/auth/AuthGate.tsx`
|
||||
- 登录态 hydrate / restore 的入口。
|
||||
- 监听 `AUTH_STATE_EVENT` 后重新 hydrate。
|
||||
- 是否先 refresh、再 `/api/auth/me`,决定打开页面是否进入后端 refresh 链路。
|
||||
- `src/services/apiClient.ts`
|
||||
- access token 本地保存、`ensureStoredAccessToken()`、`refreshStoredAccessToken()`、`fetchWithApiAuth()`。
|
||||
- `ensureStoredAccessToken()` 有 token 时会直接复用,不一定触发后端 refresh。
|
||||
- `refreshStoredAccessToken()` 应直接调用 refresh 接口,用于必须轮换 cookie / 写续期埋点的场景。
|
||||
- `src/services/authService.ts`
|
||||
- `getCurrentAuthUser()` 请求 `/api/auth/me`。
|
||||
- 登录、登出、账号安全相关 API client。
|
||||
|
||||
后端:
|
||||
|
||||
- `server-rs/crates/api-server/src/auth_session.rs`
|
||||
- 创建 refresh cookie / access token。
|
||||
- `record_daily_login_tracking_event_after_auth_success(...)` 统一写每日登录埋点;失败 warning,不阻断认证流程。
|
||||
- `server-rs/crates/api-server/src/refresh_session.rs`
|
||||
- `POST /api/auth/session/refresh`。
|
||||
- rotate refresh session、签发新 access token、记录每日登录埋点。
|
||||
- `server-rs/crates/api-server/src/auth_me.rs`
|
||||
- `/api/auth/me` 只读取当前 access token 对应用户,不应假设它会触发 refresh 或登录埋点。
|
||||
- `server-rs/crates/api-server/src/phone_auth.rs`
|
||||
- `server-rs/crates/api-server/src/password_entry.rs`
|
||||
- `server-rs/crates/api-server/src/password_management.rs`
|
||||
- `server-rs/crates/api-server/src/wechat_auth.rs`
|
||||
- 各真实认证成功入口。
|
||||
- `server-rs/crates/spacetime-client/src/runtime.rs`
|
||||
- `record_daily_login_tracking_event(user_id)` 调用 SpacetimeDB procedure。
|
||||
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- `record_daily_login_tracking_event_and_return` procedure。
|
||||
- 任务中心读取不应污染每日登录埋点;如看到 `get_profile_task_center` 顺手写 `daily_login`,优先复核是否回归。
|
||||
|
||||
## 调试顺序
|
||||
|
||||
1. 先明确用户场景属于哪类:
|
||||
- 新登录成功。
|
||||
- cookie/access token 已过期后的自动刷新。
|
||||
- 已登录且 cookie/access token 未过期时打开网页。
|
||||
- 只调用 `/api/auth/me` 或某个受保护业务接口。
|
||||
2. 查前端实际调用链,不要只看后端埋点点位:
|
||||
- `AuthGate` hydrate 是否调用 `refreshStoredAccessToken()`?
|
||||
- 是否只是 `ensureStoredAccessToken()` + `/api/auth/me`?
|
||||
- `fetchWithApiAuth()` 是否因为已有 access token 而跳过 refresh?
|
||||
3. 查后端实际埋点点位:
|
||||
- 登录成功入口是否在 session 创建后调用 helper。
|
||||
- refresh session 是否在 rotate 与 access token 签发成功后调用 helper。
|
||||
- 失败策略是否只 warning、不阻断响应。
|
||||
4. 如涉及 SpacetimeDB procedure/table/binding,按项目 SpacetimeDB skills 与文档同步检查绑定生成、`migration.rs`、private table 限制。
|
||||
5. 修改前补齐 `docs/technical/` 中对应方案/根因;修改后同步更新。
|
||||
|
||||
## 关键经验:已登录打开网页也要主动 refresh 才能写登录埋点
|
||||
|
||||
常见误判:后端已经在 refresh cookie 续期时写每日登录埋点,就以为“打开网页”会触发埋点。
|
||||
|
||||
实际链路中,如果用户已经登录且本地 access token 还有效:
|
||||
|
||||
1. `ensureStoredAccessToken()` 会直接返回已有 token。
|
||||
2. `AuthGate` 随后请求 `/api/auth/me`。
|
||||
3. `/api/auth/me` 只校验/读取用户,不会 rotate refresh session。
|
||||
4. 因此后端 refresh/session 埋点不会触发。
|
||||
|
||||
若产品要求“已登录且 cookie 没过期时打开网页也记录登录埋点”,`AuthGate` 的 restore/hydrate 应主动调用 `refreshStoredAccessToken()`,再调用 `getCurrentAuthUser()`。
|
||||
|
||||
## 每日登录埋点原则
|
||||
|
||||
- 真实登录成功:在 refresh session / access token 创建成功后记录。
|
||||
- cookie refresh 续期:在 rotate refresh session 成功且新 access token 签发成功后记录。
|
||||
- 已登录打开网页:前端必须主动走 refresh 续期链路,不能只请求 `/api/auth/me`。
|
||||
- `login_method` 对于 refresh 场景使用 refresh session 保存的 `issued_by_provider`。
|
||||
- 埋点失败不阻断登录、续期、会话恢复或 token 返回,只记录 warning。
|
||||
- 任务中心读取不应作为登录埋点来源,避免后台查看/刷新任务中心污染登录数据。
|
||||
|
||||
## 测试与验证命令
|
||||
|
||||
按改动范围选择:
|
||||
|
||||
```bash
|
||||
npm run test -- AuthGate.test.tsx
|
||||
npm run typecheck
|
||||
cd server-rs && cargo test -p api-server auth_session -- --nocapture
|
||||
cd server-rs && cargo test -p api-server refresh_session_rotates_cookie_and_returns_new_access_token -- --nocapture
|
||||
cd server-rs && cargo check -p api-server
|
||||
cd server-rs && cargo check -p spacetime-client
|
||||
cd server-rs && cargo check -p spacetime-module
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
注意:Vitest 0.34 不支持 Jest 的 `--runInBand`;不要把 `--runInBand` 加到 `npm run test -- AuthGate.test.tsx` 后面。
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 把 `/api/auth/me` 当作 refresh:它只读当前 access token,不会写 refresh 埋点。
|
||||
2. 只在后端 refresh handler 加埋点,但前端有有效 access token 时根本不调用 refresh。
|
||||
3. `ensureStoredAccessToken()` 有 token 时会直接返回;需要强制 refresh 时应使用 `refreshStoredAccessToken()`。
|
||||
4. 在埋点 helper 中返回错误并阻断登录/续期,会破坏认证主链路。
|
||||
5. refresh 场景把 `login_method` 写死为 password,会丢失手机/微信来源。
|
||||
6. 修改中文文件后忘记 `npm run check:encoding`。
|
||||
7. `cargo fmt -p api-server` 或前端测试可能让 `.env.local`、`.gitignore` 出现非业务改动;提交前用 `git status --short` 检查并撤回无关敏感/环境文件。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- `references/session-restore-daily-login-tracking-2026-05-08.md`:已登录且 cookie 未过期时打开网页未触发每日登录埋点的根因与修复案例。
|
||||
@@ -0,0 +1,75 @@
|
||||
# Session restore 每日登录埋点案例(2026-05-08)
|
||||
|
||||
## 现象
|
||||
|
||||
用户反馈:已经登录且 cookie 没过期时,打开网页没有触发每日登录埋点。
|
||||
|
||||
## 根因
|
||||
|
||||
当本地 access token 仍有效时,前端 `AuthGate` 恢复登录态只会复用现有 token 并请求 `/api/auth/me`。`/api/auth/me` 只读取当前用户,不会进入 `POST /api/auth/session/refresh`,因此后端 refresh handler 中的每日登录埋点不会执行。
|
||||
|
||||
关键误判点:后端已经在 refresh cookie 续期写埋点,不等于“打开网页”一定会触发。前端必须实际调用 refresh/session 接口。
|
||||
|
||||
## 修复模式
|
||||
|
||||
1. 在 `src/services/apiClient.ts` 暴露强制 refresh 方法,例如:
|
||||
|
||||
```ts
|
||||
export async function refreshStoredAccessToken() {
|
||||
return refreshAccessToken();
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `src/components/auth/AuthGate.tsx` 的 hydrate/restore 中,使用:
|
||||
|
||||
```ts
|
||||
await refreshStoredAccessToken();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
```
|
||||
|
||||
而不是只调用:
|
||||
|
||||
```ts
|
||||
await ensureStoredAccessToken();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
```
|
||||
|
||||
3. 保留 `ensureStoredAccessToken()` 给普通受保护请求兜底;不要把所有请求都改成强制 refresh。
|
||||
|
||||
4. 确认 `server-rs/crates/api-server/src/refresh_session.rs` 在 rotate refresh session 成功且新 access token 签发成功后调用每日登录埋点 helper。
|
||||
|
||||
5. 确认 `server-rs/crates/spacetime-module/src/runtime/profile.rs` 中 `get_profile_task_center` 不再顺手写 `daily_login`,避免任务中心读取污染登录埋点。
|
||||
|
||||
## 测试
|
||||
|
||||
前端测试重点:
|
||||
|
||||
- `AuthGate` 会等待 `refreshStoredAccessToken()` 完成后才暴露已恢复用户内容。
|
||||
- `AUTH_STATE_EVENT` 触发 hydrate 时仍保持已挂载平台内容和本地 tab 状态。
|
||||
|
||||
命令:
|
||||
|
||||
```bash
|
||||
npm run test -- AuthGate.test.tsx
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
后端/SpacetimeDB 编译:
|
||||
|
||||
```bash
|
||||
cd server-rs && cargo check -p spacetime-module
|
||||
cd server-rs && cargo check -p api-server
|
||||
```
|
||||
|
||||
全局检查:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 注意
|
||||
|
||||
- Vitest 0.34 不支持 Jest 的 `--runInBand` 参数;命令里不要加。
|
||||
- 埋点失败只能 warning,不能阻断登录态恢复。
|
||||
- 如果后续发现打开页面产生过多 refresh 请求,需要在产品口径和埋点口径之间重新设计节流;但不能退回“只读 `/api/auth/me` 却期待写登录埋点”的状态。
|
||||
127
.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md
Normal file
127
.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: genarrative-dev-stack-port-routing
|
||||
short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。
|
||||
description: 在 Genarrative 中修改 npm run dev / npm run dev:rust / npm run dev:web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, dev-stack, 端口探测, Vite, api-server, SpacetimeDB, npm-run-dev]
|
||||
related_skills: [genarrative-admin-backoffice]
|
||||
---
|
||||
|
||||
# Genarrative 本地 dev 启动端口与代理目标串联流程
|
||||
|
||||
用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口检查、端口漂移和后续流程目标传递。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 修改 `scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`scripts/dev-stack-port-utils.mjs`。
|
||||
- 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。
|
||||
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
|
||||
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
|
||||
- 修改本地联调文档或 `.hermes/shared-memory/pitfalls.md` 中的 dev 启动口径。
|
||||
|
||||
## 当前端口职责
|
||||
|
||||
默认优先端口:
|
||||
|
||||
1. 主站 Vite:`3000`,对浏览器通常展示为 `http://127.0.0.1:<web-port>/`。
|
||||
2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:<api-port>/healthz`。
|
||||
3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:<spacetime-port>/v1/ping`。
|
||||
4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:<admin-web-port>/admin/`。
|
||||
|
||||
端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。
|
||||
|
||||
## 实现入口
|
||||
|
||||
- `package.json`
|
||||
- `dev` 和 `dev:rust`:执行 `node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh`。
|
||||
- `dev:web`:执行 `node scripts/dev-web-rust.mjs`。
|
||||
- `scripts/dev-stack-port-utils.mjs`
|
||||
- `isPortAvailable(...)`:探测端口是否可监听。
|
||||
- `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。
|
||||
- `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。
|
||||
- CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- 解析 CLI 参数后,先计算 `API_TARGET_HOST` 与 `ADMIN_WEB_TARGET_HOST`。
|
||||
- 调用 `resolve_dev_stack_ports` 覆盖 `SPACETIME_PORT`、`API_PORT`、`WEB_PORT`、`ADMIN_WEB_PORT`。
|
||||
- 再构造 `SPACETIME_SERVER` 和 `RUST_SERVER_TARGET`。
|
||||
- `scripts/dev-web-rust.mjs`
|
||||
- 单独启动主站前端时,也先用 `findAvailablePort` 检查 `WEB_PORT` / 默认 `3000`。
|
||||
|
||||
## 必须保持的传递链路
|
||||
|
||||
`npm run dev` / `npm run dev:rust` 中端口解析后,必须同步到以下位置:
|
||||
|
||||
1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`。
|
||||
2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`。
|
||||
3. Rust api-server:`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。
|
||||
4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`。
|
||||
5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。
|
||||
6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。
|
||||
7. 控制台日志:`[dev:ports]` 和 `[dev:rust] web/admin web/rust api/spacetime` 必须显示最终实际地址。
|
||||
|
||||
如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。
|
||||
|
||||
## 修改流程
|
||||
|
||||
1. 先读当前脚本和文档:
|
||||
- `scripts/dev-stack-port-utils.mjs`
|
||||
- `scripts/dev-rust-stack.sh`
|
||||
- `scripts/dev-web-rust.mjs`
|
||||
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
|
||||
- `.hermes/shared-memory/pitfalls.md`
|
||||
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
|
||||
3. 对 Bash 脚本只做局部补丁,避免整文件重写导致中文注释或换行大面积变化。
|
||||
4. 修改 `dev-rust-stack.sh` 时确认变量顺序:
|
||||
- 先有 `REPO_ROOT`。
|
||||
- 再计算 `API_TARGET_HOST` / `ADMIN_WEB_TARGET_HOST`。
|
||||
- 再调用端口解析工具。
|
||||
- 再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`。
|
||||
5. 修改 `dev:web` 时不要自动改后端目标策略;`dev:web` 只负责主站 Vite 端口可用性与已有后端目标选择。
|
||||
6. 同步更新技术文档和团队共享记忆。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
最小验证:
|
||||
|
||||
```bash
|
||||
bash -n scripts/dev-rust-stack.sh
|
||||
npm run test -- scripts/dev-stack-port-utils.test.ts
|
||||
npm run check:encoding
|
||||
node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0
|
||||
```
|
||||
|
||||
端口冲突回归测试建议:
|
||||
|
||||
1. 用测试或临时 Node server 占用某个优先端口。
|
||||
2. 调用 `findAvailablePort`,断言结果大于被占用端口。
|
||||
3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。
|
||||
4. 如果实际启动完整栈,观察控制台:
|
||||
- `[dev:ports] ... 不可用,改用 ...`
|
||||
- `[dev:rust] rust api: http://...:<actual-api-port>`
|
||||
- `[dev:rust] spacetime: http://...:<actual-spacetime-port>`
|
||||
- 主站和后台 Vite 启动端口与日志一致。
|
||||
|
||||
完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz`、`/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. **只让 Vite 自己漂移端口。** 这样终端可能出现可访问前端,但脚本和文档仍认为是 `3000`,后台目标或日志会错。
|
||||
2. **只改 SpacetimeDB start,不改 publish。** standalone 可能监听新端口,但 publish 仍连旧 `3101`。
|
||||
3. **只改 `GENARRATIVE_API_PORT`,不改 `RUST_SERVER_TARGET`。** api-server 已在新端口监听,但 Vite 代理仍打旧端口。
|
||||
4. **使用 `0.0.0.0` 作为浏览器访问地址。** 监听可以是 `0.0.0.0`,展示给用户和健康检查通常用 `127.0.0.1`。
|
||||
5. **端口探测和实际启动之间存在竞态。** 已经探测可用的端口仍可能被外部进程抢占;SpacetimeDB 启动后仍要解析实际监听地址,api-server 和 Vite 失败时要打印清晰日志。
|
||||
6. **运行全仓库 lint 误判。** 当前仓库可能有既有 lint 问题。验证本功能时优先运行定向测试、Bash 语法检查、编码检查,并在最终说明中区分既有 lint 失败与本次改动。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- [ ] 端口工具有测试覆盖端口被占用和多端口互斥解析。
|
||||
- [ ] `dev-rust-stack.sh` 通过 `bash -n`。
|
||||
- [ ] `npm run dev` / `npm run dev:rust` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
|
||||
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
|
||||
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。
|
||||
- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`。
|
||||
- [ ] 修改中文文件后运行 `npm run check:encoding`。
|
||||
348
.hermes/skills/genarrative-play-type-integration/SKILL.md
Normal file
348
.hermes/skills/genarrative-play-type-integration/SKILL.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
name: genarrative-play-type-integration
|
||||
description: 在 Genarrative 中新增一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、结果页、可选 runtime 与作品架的顺序接入。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, 玩法接入, 创作入口, 前端, 后端, contracts, runtime]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Genarrative 新增玩法类型接入流程
|
||||
|
||||
用于在 Genarrative 中新增一个创作入口/玩法类型,而不是单纯说明用户如何从入口创建作品。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 新增一个游戏玩法入口
|
||||
- 让某个玩法从“敬请期待”变为可创建
|
||||
- 为新玩法补齐创作工作台、结果页、发布与试玩链路
|
||||
- 将新玩法接入创作中心作品架与广场
|
||||
|
||||
## 先判断接入级别
|
||||
|
||||
### 1. 只做入口占位
|
||||
|
||||
只需要新增入口配置,不接 session/workspace/result/runtime。
|
||||
|
||||
适合:
|
||||
- 敬请期待
|
||||
- 灰度占位
|
||||
|
||||
### 2. 可进入创作工作台
|
||||
|
||||
需要补齐前端分流、session、工作台、结果页,至少能生成草稿。
|
||||
|
||||
### 3. 完整玩法闭环
|
||||
|
||||
需要补齐:
|
||||
- 创作入口
|
||||
- 工作台
|
||||
- 草稿生成
|
||||
- 结果页
|
||||
- 发布
|
||||
- 试玩 runtime
|
||||
- 作品架 / 广场 / 分享
|
||||
|
||||
## 推荐接入顺序
|
||||
|
||||
### Step 1: 先定玩法 ID 和能力边界
|
||||
|
||||
先明确:
|
||||
- `id` 是什么
|
||||
- 入口是否可见
|
||||
- 是否可点击创建
|
||||
- 是否需要对话式创作
|
||||
- 是否需要生成中页面
|
||||
- 是否需要 result/runtime/gallery/share
|
||||
|
||||
不要先随便起临时 ID 再改名。
|
||||
|
||||
### Step 2: 新增入口配置
|
||||
|
||||
文件:
|
||||
- `src/config/newWorkEntryConfig.ts`
|
||||
|
||||
在 `NEW_WORK_ENTRY_CONFIG.creationTypes` 中新增或调整:
|
||||
- `id`
|
||||
- `title`
|
||||
- `subtitle`
|
||||
- `badge`
|
||||
- `visible`
|
||||
- `open`
|
||||
|
||||
字段语义:
|
||||
- `visible: true`:在创作页签 / 新建作品入口中展示。
|
||||
- `visible: false`:不在平台入口展示,但不删除既有玩法路由和能力。
|
||||
- `open: true`:可点击进入创作流程。
|
||||
- `open: false`:展示为锁定 / 敬请期待,不应进入创建流程。
|
||||
|
||||
如果只是占位:
|
||||
- `visible: true`
|
||||
- `open: false`
|
||||
|
||||
相关渲染与过滤位置:
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.ts`:将 `NEW_WORK_ENTRY_CONFIG.creationTypes` 映射为平台入口卡片,`getVisiblePlatformCreationTypes()` 会过滤隐藏项,并把可创建模板排在敬请期待模板前面。
|
||||
- `src/components/custom-world-home/CustomWorldCreationStartCard.tsx`:创作页签首屏模板入口卡片的实际渲染位置。
|
||||
- `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`:选择创作类型弹层的渲染位置。
|
||||
|
||||
注意:当前项目工作区通常已经是 `<repo-root>`,路径不要再额外拼接 `./Genarrative/`。
|
||||
|
||||
### Step 3: 确认类型过滤逻辑
|
||||
|
||||
文件:
|
||||
- `./Genarrative/src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
|
||||
检查:
|
||||
- `getVisiblePlatformCreationTypes()` 是否能展示新类型
|
||||
- `isPlatformCreationTypeVisible()` 是否能识别新类型
|
||||
- `locked` / `hidden` 是否正确映射
|
||||
|
||||
### Step 4: 扩展页面阶段
|
||||
|
||||
文件:
|
||||
- `./Genarrative/src/components/platform-entry/platformEntryTypes.ts`
|
||||
|
||||
为新玩法补充 `SelectionStage`:
|
||||
- `*-agent-workspace`
|
||||
- `*-generating`(可选)
|
||||
- `*-result`
|
||||
- `*-runtime`(可选)
|
||||
- `*-gallery-detail`(可选)
|
||||
|
||||
### Step 5: 在总流程中加类型分流
|
||||
|
||||
文件:
|
||||
- `./Genarrative/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
在 `handleCreationHubCreateType(type)` 中新增分支,确保:
|
||||
- 能进入对应工作台
|
||||
- 能设置对应 `selectionStage`
|
||||
- 能关闭类型弹层
|
||||
|
||||
同时补:
|
||||
- `open<Play>AgentWorkspace()`
|
||||
- `leave<Play>Flow()`
|
||||
- `submit<Play>Message()`(对话式玩法)
|
||||
- `execute<Play>Action()`
|
||||
|
||||
### Step 6: 接入通用 Agent flow controller
|
||||
|
||||
文件:
|
||||
- `./Genarrative/src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
|
||||
|
||||
如果是 Agent 型玩法,复用通用控制器:
|
||||
- `createSession`
|
||||
- `getSession`
|
||||
- `streamMessage`
|
||||
- `executeAction`
|
||||
- `isBusy`
|
||||
- `error`
|
||||
- `streamingReplyText`
|
||||
- `selectionStage` 切换
|
||||
|
||||
### Step 7: 定义 shared contracts
|
||||
|
||||
前端:
|
||||
- `./Genarrative/packages/shared/src/contracts/`
|
||||
|
||||
后端:
|
||||
- `./Genarrative/server-rs/crates/shared-contracts/src/`
|
||||
|
||||
至少补齐:
|
||||
- session snapshot
|
||||
- create session request/response
|
||||
- message request/response
|
||||
- action request/response
|
||||
- draft/result 结构
|
||||
- work summary / gallery 结构(如果需要)
|
||||
- runtime 结构(如果需要)
|
||||
|
||||
### Step 8: 实现前端 service client
|
||||
|
||||
目录参考:
|
||||
- `./Genarrative/src/services/`
|
||||
|
||||
按玩法补:
|
||||
- creation client
|
||||
- runtime client(可选)
|
||||
- works client(可选)
|
||||
- gallery client(可选)
|
||||
|
||||
建议保持和现有玩法一致的 API base 与命名风格。
|
||||
|
||||
### Step 9: 接后端 API
|
||||
|
||||
文件参考:
|
||||
- `./Genarrative/server-rs/crates/api-server/src/puzzle.rs`
|
||||
- `./Genarrative/server-rs/crates/api-server/src/puzzle_agent_turn.rs`
|
||||
- `./Genarrative/server-rs/crates/api-server/src/match3d.rs`
|
||||
|
||||
通常需要:
|
||||
- create session
|
||||
- get session
|
||||
- send message
|
||||
- stream message
|
||||
- execute action
|
||||
- publish / save / delete
|
||||
- runtime start / action(可选)
|
||||
- gallery / detail(可选)
|
||||
|
||||
后端设计优先按 Genarrative 的 DDD 分层拆开,不要把玩法规则、数据库事务、LLM 调用和 HTTP handler 混在一个文件里:
|
||||
- `module-<play>`:纯领域规则、状态机、draft/runtime 校验,不依赖 Axum、SpacetimeDB 或外部平台。
|
||||
- `shared-contracts`:前后端 DTO、请求/响应、session snapshot、draft/result/runtime 结构。
|
||||
- `spacetime-module`:表定义、reducer/procedure、事务编排、migration;表结构变化要同步生成绑定。
|
||||
- `spacetime-client`:api-server 到 SpacetimeDB 的 facade,隐藏 reducer 调用细节。
|
||||
- `api-server`:Axum 路由、鉴权、SSE/stream、应用层编排。
|
||||
- `platform-*`:LLM、资产上传、鉴权、第三方服务等副作用。
|
||||
|
||||
建议按四条线设计后端能力:
|
||||
- Agent 创作线:session、turn、stream、compile action。
|
||||
- Works 作品线:保存、发布、删除、草稿恢复。
|
||||
- Gallery 广场线:公开列表、详情、like/remix/share。
|
||||
- Runtime 运行态线:开始试玩、提交动作、读取状态。
|
||||
|
||||
### Step 10: 新增工作台组件
|
||||
|
||||
目录建议:
|
||||
- `./Genarrative/src/components/<play>-creation/<Play>AgentWorkspace.tsx`
|
||||
|
||||
两种形态:
|
||||
|
||||
#### 对话式
|
||||
适合设定逐轮补齐。
|
||||
|
||||
参考:
|
||||
- `BigFishAgentWorkspace.tsx`
|
||||
- `Match3DAgentWorkspace.tsx`
|
||||
|
||||
#### 表单式
|
||||
适合输入结构明确的玩法。
|
||||
|
||||
参考:
|
||||
- `PuzzleAgentWorkspace.tsx`
|
||||
|
||||
### Step 11: 在渲染树中挂载新页面
|
||||
|
||||
文件:
|
||||
- `./Genarrative/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
补齐:
|
||||
- workspace 分支
|
||||
- generating 分支(如需要)
|
||||
- result 分支
|
||||
- runtime 分支(如需要)
|
||||
|
||||
### Step 12: 新增结果页
|
||||
|
||||
目录建议:
|
||||
- `./Genarrative/src/components/<play>-result/<Play>ResultView.tsx`
|
||||
|
||||
结果页至少支持:
|
||||
- 展示 draft
|
||||
- 返回编辑
|
||||
- 发布
|
||||
- 试玩
|
||||
- 错误展示
|
||||
|
||||
### Step 13: 需要试玩就补 runtime
|
||||
|
||||
目录建议:
|
||||
- `./Genarrative/src/components/<play>-runtime/<Play>RuntimeShell.tsx`
|
||||
|
||||
如果玩法是游戏类,建议补完整 runtime 闭环。
|
||||
|
||||
### Step 14: 接入作品架 / 广场 / 分享
|
||||
|
||||
需要改:
|
||||
- `./Genarrative/src/components/custom-world-home/creationWorkShelf.ts`
|
||||
- `./Genarrative/src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
- `./Genarrative/src/services/publicWorkCode.ts`
|
||||
|
||||
如果玩法支持发布,还要补:
|
||||
- public work code
|
||||
- public detail
|
||||
- publish share modal
|
||||
- like/remix(可选)
|
||||
|
||||
### Step 15: 处理登录态与草稿恢复
|
||||
|
||||
要考虑:
|
||||
- 刷新恢复草稿
|
||||
- 退出登录清空私有状态
|
||||
- result/draft 缺失时回退
|
||||
- busy / generating / runtime 中断恢复
|
||||
|
||||
### Step 16: 补测试
|
||||
|
||||
至少覆盖:
|
||||
- 入口展示
|
||||
- 类型分流
|
||||
- 工作台打开
|
||||
- session 创建
|
||||
- compile action
|
||||
- result 页切换
|
||||
- 发布后刷新作品架
|
||||
- runtime 进入与退出
|
||||
|
||||
## 最小改动清单
|
||||
|
||||
### 只做占位
|
||||
|
||||
只改:
|
||||
- `./Genarrative/src/config/newWorkEntryConfig.ts`
|
||||
|
||||
### 做到可进入工作台
|
||||
|
||||
至少改:
|
||||
- `newWorkEntryConfig.ts`
|
||||
- `platformEntryTypes.ts`
|
||||
- `PlatformEntryFlowShellImpl.tsx`
|
||||
- 新玩法 service client
|
||||
- 新玩法工作台组件
|
||||
- shared contracts
|
||||
- 后端 API
|
||||
|
||||
### 做到完整闭环
|
||||
|
||||
还要补:
|
||||
- result 页
|
||||
- runtime
|
||||
- works / gallery
|
||||
- public code
|
||||
- share
|
||||
- 作品架聚合
|
||||
- 测试
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 只加入口配置不够,类型分流和页面阶段也要补。
|
||||
2. `SelectionStage` 不扩展,前端无法安全切页。
|
||||
3. 新玩法如果要出现在作品架,必须改聚合逻辑,不只是加入口。
|
||||
4. 发布后不刷新 works/gallery,用户会看不到新作品。
|
||||
5. 如果走 SpacetimeDB,表结构变化要同步 migration 和绑定;`spacetime-client/src/module_bindings/` 通常是生成物,不要为了修编译或格式化而手改,优先改 module 源 schema/reducer/procedure 后重新生成。
|
||||
6. 做 analytics/tracking 这类 runtime 能力时,不要只补 API DTO;先在 `module-runtime` 写纯函数测试(例如 day/week/month/quarter/year bucket 聚合、scope/event 过滤),RED 后再补领域类型与聚合函数。
|
||||
7. 时间粒度聚合建议复用已有 date dimension 逻辑,把 daily stat 映射到 day/week/month/quarter/year bucket;bucket 输出要有稳定排序,并显式携带 `bucketKey`、`bucketStartDateKey`、`bucketEndDateKey`、`value`。
|
||||
8. 后端 shared-contracts 与前端 `packages/shared/src/contracts/runtime.ts` 要同步补 request/response/type union;admin-web 若有独立 `api/adminApiTypes.ts`,也要同步,避免共享包已更新但管理端本地类型缺失。
|
||||
9. 退出登录时要清空新玩法私有状态,避免串用户。
|
||||
10. 移动端入口卡片增多后要检查布局和滚动体验。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- `references/genarrative-analytics-tracking-runtime.md`:analytics/tracking runtime 粒度聚合、contracts 同步与 SpacetimeDB 生成物注意事项。
|
||||
|
||||
## 验证标准
|
||||
|
||||
一个玩法算真正接入成功,至少要满足:
|
||||
|
||||
- 入口能展示
|
||||
- 能进入对应工作台
|
||||
- 能创建 session
|
||||
- 能生成草稿
|
||||
- 能进入结果页
|
||||
- 能返回编辑
|
||||
- 如果需要,可试玩
|
||||
- 如果需要,可发布
|
||||
- 发布后能回到作品架 / 广场 / 分享链路
|
||||
@@ -0,0 +1,57 @@
|
||||
# Genarrative analytics/tracking runtime 接入经验
|
||||
|
||||
本参考来自 analytics time dimension / tracking daily stat 粒度聚合实现与一次“只提交了 bindings/tests、后端链路未补齐”的纠偏。
|
||||
|
||||
## 推荐顺序
|
||||
|
||||
1. 先读仓库 `README.md`、`AGENTS.md`、相关 `/docs/technical` 与 `.hermes/plans`,确认当前阶段范围。
|
||||
2. 遵循 TDD:先在 `server-rs/crates/module-runtime/tests/` 写纯函数测试,验证缺失类型/函数导致 RED。
|
||||
3. 在 `module-runtime/src/domain.rs` 增加领域类型,例如:
|
||||
- `AnalyticsGranularity`:`day | week | month | quarter | year`
|
||||
- daily stat snapshot
|
||||
- bucket metric
|
||||
- query request/response/input
|
||||
- Spacetime procedure result(例如 `AnalyticsMetricQueryProcedureResult { ok, buckets, error_message }`),否则 module procedure 无法生成 client bindings。
|
||||
4. 在 `module-runtime/src/application.rs` 复用已有 `build_analytics_date_dimension_from_date_key`,实现 daily stat 到 day/week/month/quarter/year bucket 的聚合。
|
||||
5. 输出 bucket 应稳定排序,可用 `BTreeMap`;聚合前按 `event_key + scope_kind + scope_id` 过滤。
|
||||
6. 在 `module-runtime/src/commands.rs` 增加 query input builder,复用现有字段错误(如 missing event key、missing scope id)。
|
||||
7. 同步 contracts:
|
||||
- Rust:`server-rs/crates/shared-contracts/src/runtime.rs`
|
||||
- 前端共享:`packages/shared/src/contracts/runtime.ts`
|
||||
- 管理端若有本地 API 类型,也同步 `apps/admin-web/src/api/adminApiTypes.ts`
|
||||
8. 接 Spacetime module 源头:在 `server-rs/crates/spacetime-module/src/runtime/profile.rs` 增加只读 procedure(例如 `query_analytics_metric`),从 `tracking_daily_stat` 读 rows,映射成 `RuntimeAnalyticsDailyStatSnapshot` 后调用领域聚合函数。
|
||||
9. 重新生成 `server-rs/crates/spacetime-client/src/module_bindings/`。如果当前环境没有 `spacetime`/`spacetimedb` CLI,可临时按现有生成物风格手补 type/procedure/mod.rs,但必须在文档和最终说明中标注“临时手补生成物”,并要求后续在有 CLI 的机器用项目脚本重新生成覆盖。
|
||||
10. 接 `spacetime-client`:
|
||||
- `src/mapper.rs`:module binding procedure result → `module_runtime` record/response;补 tracking scope/granularity 映射。
|
||||
- `src/runtime.rs`:新增 facade 方法(例如 `SpacetimeClient::query_analytics_metric`),调用生成的 procedure。
|
||||
- `src/lib.rs`:若 facade/mapper 需要领域类型或 builder,补导入;注意同名 binding 类型会造成误解析。
|
||||
11. 接 `api-server`:
|
||||
- `src/runtime_profile.rs`:Query params / parser / handler / response builder。
|
||||
- `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint;选择路径前确认产品定位。
|
||||
12. 最后更新 docs/plan,并确认 diff 不只是生成物。
|
||||
|
||||
## 验证命令示例
|
||||
|
||||
```bash
|
||||
cd <repo-root>/server-rs
|
||||
cargo test -p module-runtime --test analytics_granularity
|
||||
cargo check -p shared-contracts
|
||||
cargo check -p spacetime-module
|
||||
cargo check -p spacetime-client
|
||||
cargo check -p api-server
|
||||
```
|
||||
|
||||
If terminal output is compacted by the tool, rerun the specific command directly (without `head`) or capture full output before concluding; `cargo check` exit code 0 with warnings is acceptable when warnings are pre-existing and documented.
|
||||
|
||||
## 坑
|
||||
|
||||
- 不要手改 `server-rs/crates/spacetime-client/src/module_bindings/` 生成物;若缺 procedure/type,回源头改 Spacetime module 后重新生成。若当前环境没有 `spacetime`/`spacetimedb` CLI 且必须临时手补生成物,要在最终说明中明确这是临时替代,并尽快在有 CLI 的环境重新生成。
|
||||
- `spacetime-client/src/runtime.rs` 同时能看到 `module_bindings::*` 和领域层 `module_runtime` 类型。新增 facade 方法参数要使用领域别名(如 `DomainRuntimeTrackingScopeKind`、`module_runtime::AnalyticsGranularity`),不要误用 binding 里的 `RuntimeTrackingScopeKind`;否则 `build_*_input` 会报 “expected module_runtime::..., found binding ...”。
|
||||
- 如果缺少 SpacetimeDB CLI,手补 bindings 的最小集合通常包括:`*_type.rs`(input、enum、bucket、procedure result)、`*_procedure.rs`、`module_bindings/mod.rs` 的 `pub mod`/`pub use`/procedure re-export。完成后必须跑 `cargo check -p spacetime-client` 验证 SDK trait、procedure 名称与 result 字段是否匹配。
|
||||
- `spacetime-client/src/mapper.rs` 新增 query 链路时通常要同时补四类映射:领域 input → binding input、binding enum 映射、binding procedure result → 领域 response、binding bucket/item → 领域 bucket/item。只在 `runtime.rs` 加 facade 会出现 unused import 或类型不匹配。
|
||||
- 修改 Rust import 时注意 `serde::Deserialize` 与 `serde_json` 的排序/使用:如果只加了 `Query`/`Deserialize` 但 handler 尚未实现,`cargo check -p api-server` 会暴露 unused import;不要把 import 作为完成信号。
|
||||
- 只补 shared-contracts 不够;`packages/shared` 和 admin-web 本地类型可能各有一份。
|
||||
- 周粒度应按 ISO week/date dimension,而不是简单 `day_key / 7`。
|
||||
- bucket response 建议显式包含 start/end date key,避免前端再推导时间边界。
|
||||
- `spacetime-module` 可能能编译通过,但如果没有重新生成 bindings,`spacetime-client` 仍不会有新 procedure 方法;必须以 client facade/API handler 可调用为完成标准。
|
||||
- api-server 中新增 `Query`、`Deserialize` 等 import 后要立即补 handler,否则容易留下 unused import。
|
||||
99
.hermes/skills/genarrative-profile-features/SKILL.md
Normal file
99
.hermes/skills/genarrative-profile-features/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: genarrative-profile-features
|
||||
description: 在 Genarrative “我的”页签新增或修改个人中心入口、独立 profile 路由、反馈/记录/设置类页面时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, profile, 我的页签, 前端, 路由, 反馈]
|
||||
related_skills: [writing-plans, test-driven-development]
|
||||
---
|
||||
|
||||
# Genarrative “我的”页签功能接入
|
||||
|
||||
用于在 Genarrative 平台“我的”页签新增或修改入口,以及把入口接到独立页面阶段/路由。例如:帮助与反馈、反馈记录、个人设置、账号相关轻量页面。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 在“我的”页签新增快捷入口或卡片按钮。
|
||||
- 点击入口后进入独立页面,而不是在当前面板下方展开内容。
|
||||
- 新增 `/profile/...` 路由或 `SelectionStage`。
|
||||
- 新增移动端优先的个人中心子页面组件。
|
||||
- 修改 `RpgEntryHomeView`、`PlatformEntryFlowShellImpl`、`appPageRoutes` 等前端 profile 链路。
|
||||
|
||||
## 必读约束
|
||||
|
||||
1. 按项目约束:先检查/补齐文档,再落地工程修改。
|
||||
2. UI 面板保持清爽,不要默认堆功能说明文案。
|
||||
3. 点击按钮弹出/进入独立面板的设计,不要实现成在当前面板下方追加内容。
|
||||
4. 移动端优先,同时兼顾网页端容器宽度。
|
||||
5. 包含中文的文件优先局部补丁,修改后运行编码检查。
|
||||
6. 非必要不新建系统;优先复用现有平台入口、阶段和路由机制。
|
||||
|
||||
## 代码接入路径
|
||||
|
||||
常见文件:
|
||||
|
||||
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
- “我的”页签 UI 主入口通常在此。
|
||||
- 新增入口时优先扩展 props,例如 `onOpenFeedback?: () => void`。
|
||||
- 在现有快捷入口区新增 `ProfileShortcutButton`,保持图标、label、subLabel 风格一致。
|
||||
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
- 若需要独立页面阶段,扩展 `SelectionStage` union。
|
||||
- 例如新增 `'profile-feedback'`。
|
||||
|
||||
- `src/routing/appPageRoutes.ts`
|
||||
- 在 `STAGE_ROUTE_ENTRIES` 添加 `/profile/...` 路由映射。
|
||||
- 验证 `resolveSelectionStageFromPath()` 与 `resolvePathForSelectionStage()` 双向一致。
|
||||
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- 引入新页面组件。
|
||||
- 新增打开函数:必要时先检查登录态,未登录调用 `authUi?.openLoginModal()`。
|
||||
- 打开 profile 子页时同步 `setPlatformTab('profile')`,再 `setSelectionStage(...)`。
|
||||
- 当 `selectionStage` 直接由路由进入 profile 子页时,用 `useEffect` 同步当前 tab 到 `profile`。
|
||||
- 在主渲染分支中为新阶段渲染独立 `<motion.div>` 页面;返回时回到 `platform` 阶段并保持 `profile` tab。
|
||||
|
||||
- `src/components/platform-entry/<FeatureView>.tsx`
|
||||
- 页面组件可放在 platform-entry 下,与 shell 阶段渲染保持一致。
|
||||
- 表单首版没有后端接口时,可通过可选 `onSubmit` prop 暴露提交 payload,并在组件内展示成功/失败态;注释说明后续替换为 API 调用。
|
||||
|
||||
## 推荐实施顺序
|
||||
|
||||
1. 读取 `.hermes/plans/...` 或产品文档,确认入口、路由、页面行为。
|
||||
2. 若现有文档不足,先在 `docs/prd/` 增加可编码落地的 PRD。
|
||||
3. 增加 `SelectionStage` 与 `appPageRoutes` 映射,并先跑 `npm run typecheck`。
|
||||
4. 新建独立页面组件,尽量通过 props 暴露 `onBack`/`onSubmit`,避免直接耦合全局状态。
|
||||
5. 在 `RpgEntryHomeView.tsx` 增加入口 prop 与按钮。
|
||||
6. 在 `PlatformEntryFlowShellImpl.tsx` 串联导航、登录态、阶段渲染和返回逻辑。
|
||||
7. 增加基础测试:路由解析、页面字段渲染、关键交互/校验、返回按钮。
|
||||
8. 跑编码检查、类型检查和相关 vitest。
|
||||
9. 分阶段 commit;用户要求更新工作区时再 push。
|
||||
|
||||
## 测试与验证命令
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
npx vitest run src/routing/appPageRoutes.test.ts src/components/platform-entry/<FeatureView>.test.tsx
|
||||
# 或项目脚本:
|
||||
npm run test -- --run src/routing/appPageRoutes.test.ts src/components/platform-entry/<FeatureView>.test.tsx
|
||||
```
|
||||
|
||||
如果新增/修改中文文档或中文 UI,`check:encoding` 必跑。
|
||||
|
||||
## 参考案例
|
||||
|
||||
- `references/profile-feedback-entry-2026-05-08.md`:帮助与反馈入口案例,覆盖文档、路由阶段、独立页面组件、“我的”页签按钮、shell 导航、测试和验证命令。
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 只在 `RpgEntryHomeView` 新增按钮但没有接 shell 导航,导致点击无效果。
|
||||
2. 只新增 `SelectionStage` 但忘记 `appPageRoutes`,导致刷新/直达路由不能恢复页面。
|
||||
3. 直达 `/profile/...` 时没有同步 `setPlatformTab('profile')`,底部 tab 状态与页面不一致。
|
||||
4. 把反馈/设置表单插到“我的”面板下方,违背独立页面体验。
|
||||
5. 没有测试 `resolveSelectionStageFromPath`/`resolvePathForSelectionStage`,后续路由改动容易回归。
|
||||
6. 中文页面或文档改动后忘记编码检查。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 帮助与反馈入口案例(2026-05-08)
|
||||
|
||||
本案例来自 Genarrative “我的”页签新增反馈入口与独立反馈页实现。
|
||||
|
||||
## 目标
|
||||
|
||||
- 在“我的”页签新增“反馈”快捷入口。
|
||||
- 点击后进入独立路由 `/profile/feedback`。
|
||||
- 页面按移动端参考图实现“帮助与反馈”表单:问题描述、上传凭证、联系电话、提交、查看反馈与投诉记录。
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
- `src/routing/appPageRoutes.ts`
|
||||
- `src/components/platform-entry/PlatformFeedbackView.tsx`
|
||||
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- `src/routing/appPageRoutes.test.ts`
|
||||
- `src/components/platform-entry/PlatformFeedbackView.test.tsx`
|
||||
|
||||
## 落地顺序
|
||||
|
||||
1. 先补 PRD,避免从参考图直接猜代码细节。
|
||||
2. 在 `SelectionStage` 中新增 `'profile-feedback'`。
|
||||
3. 在 `STAGE_ROUTE_ENTRIES` 添加:
|
||||
- `['profile-feedback', '/profile/feedback']`
|
||||
4. 新建 `PlatformFeedbackView`:
|
||||
- `onBack: () => void`
|
||||
- `onSubmit?: (payload) => void | Promise<void>`
|
||||
- 内部维护描述、联系电话、上传图片预览、错误态、成功态。
|
||||
- 首版没有后端接口时,`onSubmit` 为后续 API 接入点。
|
||||
5. 在 `RpgEntryHomeViewProps` 添加 `onOpenFeedback?: () => void`。
|
||||
6. 在“我的”页签快捷入口区新增 `ProfileShortcutButton`:
|
||||
- `label="反馈"`
|
||||
- `subLabel="帮助与反馈"`
|
||||
- `onClick={onOpenFeedback}`
|
||||
7. 在 `PlatformEntryFlowShellImpl`:
|
||||
- import `PlatformFeedbackView`
|
||||
- 新增 `openProfileFeedback`
|
||||
- 未登录则 `authUi?.openLoginModal()`
|
||||
- 已登录则 `setPlatformTab('profile')` + `setSelectionStage('profile-feedback')`
|
||||
- 直达阶段时 `useEffect` 同步 `profile` tab
|
||||
- 渲染 `selectionStage === 'profile-feedback'` 的独立 `<motion.div>`
|
||||
8. 增加测试:
|
||||
- 路由双向解析
|
||||
- 页面字段渲染
|
||||
- 描述低于 10 字不提交
|
||||
- 提交时 trim payload
|
||||
- 顶部返回按钮调用 `onBack`
|
||||
|
||||
## UI 参考要点
|
||||
|
||||
- 移动端优先,页面最大宽度约 30rem。
|
||||
- 浅灰背景,白色圆角卡片。
|
||||
- 顶部标题:`帮助与反馈`。
|
||||
- 分区标题:`反馈问题`。
|
||||
- 问题描述 placeholder:提示填写 10 字以上,勿填身份证号等隐私。
|
||||
- 字数计数:`0/200`。
|
||||
- 上传凭证:最多四张,上传占位显示 `上传凭证` 和 `(最多四张)`。
|
||||
- 联系电话:选填。
|
||||
- 主按钮:蓝色圆角 `提交`。
|
||||
- 底部链接:`查看反馈与投诉记录`。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
npx vitest run src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
|
||||
# 或
|
||||
npm run test -- --run src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
|
||||
```
|
||||
|
||||
## 已踩坑/经验
|
||||
|
||||
- `appPageRoutes.test.ts` 若已有历史内容,写入测试时注意不要误删旧用例;最终 commit 里出现 “insertions + deletions” 时要检查是否覆盖了既有测试。
|
||||
- 页面组件里的图片预览需要在移除或卸载时 `URL.revokeObjectURL`,避免泄漏。
|
||||
- 对隐藏 file input 的测试可以先不强行覆盖,首版覆盖表单字段、校验和 payload 更稳定。
|
||||
- 如果用 `authUi` 对象作为 callback 依赖,需确认引用稳定性;现有 shell 中可接受,但复杂化时考虑拆出具体字段依赖。
|
||||
149
.hermes/skills/genarrative-profile-invite-flow/SKILL.md
Normal file
149
.hermes/skills/genarrative-profile-invite-flow/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: genarrative-profile-invite-flow
|
||||
description: 在 Genarrative 中排查或修改邀请码、邀请好友、首次登录后填写邀请码、我的页签邀请码兑换链路时使用。
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Genarrative, 邀请码, referral, auth, profile, query-params, 前端]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Genarrative 邀请码与邀请好友流程
|
||||
|
||||
用于排查或修改 Genarrative 的邀请码读取、填写、兑换、邀请中心与“我的”页签相关能力。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 判断 URL query 参数中的邀请码是否被读取。
|
||||
- 修改邀请码填写入口、首次登录后引导或“我的”页签兑换入口。
|
||||
- 排查邀请码预填、兑换、已填写状态、邀请好友复制链接。
|
||||
- 修改邀请中心 API client 或前端 referral UI。
|
||||
- 回答用户关于“邀请码在哪里填 / 从哪里配置 / query 是否支持”的问题。
|
||||
|
||||
## 先做代码核对,不要只凭旧记忆回答
|
||||
|
||||
邀请码流程近期发生过迁移:不要默认认为登录窗口可填写邀请码。回答前优先搜索并核对当前代码,尤其是:
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
root=Path('src')
|
||||
terms=['RegistrationInviteModal','readInviteCodeFromLocation','referralRedeemCode','redeemRpgProfileReferralInviteCode','邀请码','inviteCode']
|
||||
for term in terms:
|
||||
print('\n---', term)
|
||||
for p in root.rglob('*'):
|
||||
if p.is_file() and p.suffix in ['.ts', '.tsx']:
|
||||
try:
|
||||
txt=p.read_text('utf-8')
|
||||
except Exception:
|
||||
continue
|
||||
if term in txt:
|
||||
for i, line in enumerate(txt.splitlines(), 1):
|
||||
if term in line:
|
||||
print(f'{p}:{i}:{line.strip()[:180]}')
|
||||
```
|
||||
|
||||
## 当前前端链路口径
|
||||
|
||||
### 1. AuthGate 中仍有旧 query 读取逻辑
|
||||
|
||||
文件:
|
||||
- `src/components/auth/AuthGate.tsx`
|
||||
|
||||
重点函数 / 状态:
|
||||
- `readInviteCodeFromLocation()`
|
||||
- `pendingInviteCode`
|
||||
- `showRegistrationInviteModal`
|
||||
- `RegistrationInviteModal`
|
||||
|
||||
当前旧逻辑会读取:
|
||||
- `?inviteCode=...`
|
||||
- `?invite_code=...`
|
||||
|
||||
并把值清洗为大写字母数字形式。
|
||||
|
||||
### 2. 登录窗口本身不再填写邀请码
|
||||
|
||||
不要回答“登录窗口可填写邀请码”。当前登录弹窗 `LoginScreen` 只负责登录 / 注册账号;邀请码填写已迁移到登录后的流程。
|
||||
|
||||
### 3. 新版“我的”页签兑换入口在 RpgEntryHomeView
|
||||
|
||||
文件:
|
||||
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
|
||||
重点常量 / 函数 / 状态:
|
||||
- `PROFILE_INVITE_QUERY_KEYS`:新版 query 支持 `inviteCode` / `invite_code`。
|
||||
- `normalizeProfileInviteQueryCode()`:去掉非字母数字并转大写。
|
||||
- `readProfileInviteCodeFromLocationSearch()`:从 `window.location.search` 读取并 normalize。
|
||||
- `pendingProfileInviteCode`:组件初始化时读取 query 邀请码。
|
||||
- `referralCenter`
|
||||
- `referralRedeemCode`
|
||||
- `setReferralRedeemCode`
|
||||
- `openProfilePopupPanel('redeem')`
|
||||
- `submitReferralRedeemCode()`
|
||||
- `canShowReferralRedeemShortcut`
|
||||
- `isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt)`
|
||||
|
||||
UI 中“填邀请码”面板会使用 `referralRedeemCode` 作为输入值,并通过 `submitReferralRedeemCode()` 提交。当前新版实现会在首次打开“填邀请码”面板时用 `pendingProfileInviteCode` 预填输入框;例如 `/?inviteCode=spring-2026` 会预填为 `SPRING2026`。
|
||||
|
||||
### 4. 新版兑换 API client
|
||||
|
||||
文件:
|
||||
- `src/services/rpg-entry/rpgProfileClient.ts`
|
||||
|
||||
函数:
|
||||
- `getRpgProfileReferralInviteCenter()` -> `GET /profile/referrals/invite-center`
|
||||
- `redeemRpgProfileReferralInviteCode(inviteCode)` -> `POST /profile/referrals/redeem-code`
|
||||
|
||||
## 判断 query 参数是否真正接入新版流程
|
||||
|
||||
回答这类问题时要区分两层:
|
||||
|
||||
1. “是否存在旧 query 读取代码”:看 `AuthGate.tsx` 的 `readInviteCodeFromLocation()`。
|
||||
2. “query 是否接到新版填写入口”:看 `RpgEntryHomeView.tsx` 是否存在 `pendingProfileInviteCode` / `readProfileInviteCodeFromLocationSearch()`,以及打开 `openProfilePopupPanel('redeem')` 时是否把该值写回 `referralRedeemCode`。
|
||||
|
||||
当前新版流程已经支持 `inviteCode` / `invite_code` query 预填“我的”页签的“填邀请码”弹窗;登录窗口仍不填写邀请码。
|
||||
|
||||
如果未来代码只看到 AuthGate 读 query,但没有看到 `RpgEntryHomeView` 的 `referralRedeemCode` 从 query 初始化,就应回答:
|
||||
|
||||
> 代码里仍支持读取 `inviteCode` / `invite_code`,但新版“第一次登录后 / 我的页签”的填写入口未必已经完整接入该 query 值;需要继续把 query 值传入新版 profile referral redeem 流程。
|
||||
|
||||
## 修改建议顺序
|
||||
|
||||
如果要把 query 邀请码完整接入新版流程,建议按这个顺序做:
|
||||
|
||||
1. 先确定 query 参数规范:继续支持 `inviteCode` / `invite_code`,并统一 normalize。
|
||||
2. 在 `RpgEntryHomeView.tsx` 内用 `readProfileInviteCodeFromLocationSearch(window.location.search)` 初始化 `pendingProfileInviteCode`。
|
||||
3. 用 `pendingProfileInviteCode` 初始化 `referralRedeemCode`,并在 `openProfilePopupPanel('redeem')` 时重新写回,避免关闭后再次打开被清空。
|
||||
4. 如产品要求自动弹出:
|
||||
- 有 `pendingProfileInviteCode` 且未登录时,自动调用 `authUi?.openLoginModal()` 打开登录窗口;登录窗口仍不承接邀请码输入。
|
||||
- 有 `pendingProfileInviteCode` 且已登录时,自动将 `referralRedeemCode` 设为该 query 邀请码,并 `setProfilePopupPanel('redeem')` 直接打开“填邀请码”面板。
|
||||
- 用 `useRef` 记录是否已处理过当前 query,避免组件重渲染或 `authUi` 对象变化导致重复弹窗。
|
||||
- 当前项目实现已从“只预填、不自动弹”调整为上述行为。
|
||||
5. 兑换成功后清理输入态;是否清理 URL query 需由产品决定,避免破坏分享链接归因。
|
||||
6. 补测试覆盖:未登录访问带 query、已登录访问带 query 自动打开填写面板、我的页签手动打开、已填写邀请码、过期窗口、空/非法 query。当前已有 `RpgEntryHomeView.recharge.test.tsx` 覆盖:
|
||||
- `invite query opens login modal for logged out users`
|
||||
- `invite query opens redeem modal directly for logged in users`
|
||||
- `profile redeem invite modal reads query invite code after login`
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 不要把旧 `RegistrationInviteModal` 误认为当前唯一入口。
|
||||
2. 不要说“登录窗口可以填写邀请码”,除非当前代码重新把邀请码输入放回 `LoginScreen`。
|
||||
3. `AuthGate` 读到 query 不等于新版 `RpgEntryHomeView` 已经预填。
|
||||
4. “第一次登录后”与“我的页签”可能是两个入口;修改时要同时检查自动引导和手动入口。
|
||||
5. `canShowReferralRedeemShortcut` 受登录态、创建时间窗口、邀请中心初始化、已兑换状态共同影响。
|
||||
6. 邀请码 URL 通常由 `inviteLinkPath` 生成,复制逻辑在 `copyInviteInfo()`,不要只改兑换入口而忘记分享链接格式。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- `references/query-invite-code-flow-2026-05-07.md`:本次会话确认的邀请码 query 与新版 profile referral 入口关系。
|
||||
|
||||
## 验证标准
|
||||
|
||||
- 能明确回答当前 query 参数读取位置与参数名。
|
||||
- 能区分旧 AuthGate 邀请弹窗与新版“我的”页签 referral redeem。
|
||||
- 若实现改动,测试覆盖带 query 的登录后预填/弹窗行为,以及已填写邀请码时不再提示。
|
||||
@@ -0,0 +1,101 @@
|
||||
# Query 邀请码与新版 profile referral 入口关系(2026-05-07)
|
||||
|
||||
## 会话结论
|
||||
|
||||
用户指出:邀请码填写流程已经修改,登录窗口目前填写不了邀请码;邀请码填写被挪到了第一次登录后以及“我的”页签中。
|
||||
|
||||
因此后续回答或修改时不能只根据 `AuthGate` 里的旧逻辑判断“已支持”。
|
||||
|
||||
## 当前代码观察
|
||||
|
||||
### 旧 AuthGate 逻辑
|
||||
|
||||
文件:`src/components/auth/AuthGate.tsx`
|
||||
|
||||
- `readInviteCodeFromLocation()` 读取 `window.location.search`。
|
||||
- 支持 `inviteCode` / `invite_code`。
|
||||
- 会 normalize 为大写字母数字。
|
||||
- 写入 `pendingInviteCode`,传给 `RegistrationInviteModal`。
|
||||
|
||||
这只能说明“旧层仍有 query 读取”。
|
||||
|
||||
### 新版 profile referral 入口
|
||||
|
||||
文件:`src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
|
||||
- “我的”页签标签为 `profile`。
|
||||
- 兑换输入状态:`referralRedeemCode`。
|
||||
- 打开填邀请码面板:`openProfilePopupPanel('redeem')`。
|
||||
- 提交兑换:`submitReferralRedeemCode()`。
|
||||
- 可显示快捷入口受 `canShowReferralRedeemShortcut` 控制。
|
||||
|
||||
文件:`src/services/rpg-entry/rpgProfileClient.ts`
|
||||
|
||||
- `getRpgProfileReferralInviteCenter()` -> `GET /profile/referrals/invite-center`
|
||||
- `redeemRpgProfileReferralInviteCode(inviteCode)` -> `POST /profile/referrals/redeem-code`
|
||||
|
||||
## 回答口径
|
||||
|
||||
如果被问“是否支持 query 参数读取邀请码”,应回答:
|
||||
|
||||
- 代码里仍有 query 读取,支持 `inviteCode` / `invite_code`。
|
||||
- 但登录窗口不再填写邀请码。
|
||||
- 新版入口在第一次登录后 / 我的页签;需要检查 `referralRedeemCode` 是否从 query 初始化。
|
||||
- 若没有该连接,就不能说新版流程完整支持 query 预填。
|
||||
|
||||
## 本次实现后的状态
|
||||
|
||||
已将 query 邀请码读取接入新版 `RpgEntryHomeView`:
|
||||
|
||||
- `PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code']`
|
||||
- `normalizeProfileInviteQueryCode()`:去除非字母数字并转大写。
|
||||
- `readProfileInviteCodeFromLocationSearch(window.location.search)`:读取 query 邀请码。
|
||||
- `pendingProfileInviteCode`:组件初始化时读取 query。
|
||||
- `referralRedeemCode`:用 `pendingProfileInviteCode` 初始化。
|
||||
- `openProfilePopupPanel('redeem')`:打开“填邀请码”时重新写入 `pendingProfileInviteCode`,避免首次打开或重新打开时丢失 query 预填。
|
||||
|
||||
当前行为:只预填,不自动弹出“填邀请码”面板;用户仍需在“我的”页签点击“填邀请码”。
|
||||
|
||||
验证测试:
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
npm test -- --run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
|
||||
npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx --max-warnings=0
|
||||
node scripts/check-encoding.mjs
|
||||
```
|
||||
|
||||
测试用例:`profile redeem invite modal reads query invite code after login` 覆盖 `/?inviteCode=spring-2026` 预填为 `SPRING2026`。
|
||||
|
||||
## 后续调整:带邀请码链接自动开窗
|
||||
|
||||
用户进一步明确期望:
|
||||
|
||||
- 如果用户未登录,直接打开登录窗口。
|
||||
- 如果用户已登录,直接打开邀请码填写窗口。
|
||||
|
||||
实现要点:
|
||||
|
||||
- 在 `RpgEntryHomeView.tsx` 中保留 `pendingProfileInviteCode` 作为 query 邀请码来源。
|
||||
- 新增 `autoOpenedInviteQueryRef = useRef(false)`,防止 effect 重复触发弹窗。
|
||||
- 新增 `useEffect`:
|
||||
- 无 query 邀请码或已处理过则 return。
|
||||
- 未登录:调用 `authUi?.openLoginModal()`。
|
||||
- 已登录:设置 `referralRedeemCode`、清空 referral 错误/成功提示、`setProfilePopupPanel('redeem')`。
|
||||
- 登录窗口仍不接收邀请码;邀请码只在登录后的 profile referral redeem 面板显示。
|
||||
- 仍然只自动打开和预填,不自动提交兑换。
|
||||
|
||||
补充测试:
|
||||
|
||||
- `invite query opens login modal for logged out users`
|
||||
- `invite query opens redeem modal directly for logged in users`
|
||||
- 原 `profile redeem invite modal reads query invite code after login` 同步调整为直接断言自动打开后的输入值。
|
||||
|
||||
验证命令仍为:
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
npm test -- --run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
|
||||
npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx --max-warnings=0
|
||||
node scripts/check-encoding.mjs
|
||||
```
|
||||
@@ -40,12 +40,14 @@
|
||||
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||
- 修改后端代码后,按对应 DDD 文档中的验收命令执行测试;涉及 API smoke 时使用 `npm run api-server` 重新拉起后端并执行相应自动测试,同时确认 `/healthz`。
|
||||
- `maincloud` / `Maincloud` / `MAINCLOUD` 相关脚本、环境变量、测试、文档要求和命名全部视为历史残留,禁止新增、运行或引用;若旧文档仍要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`,以 [`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`](docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md) 和本文件为准,并先修正文档口径。
|
||||
- 除 CI/CD 脚本内部受控用法外,人工命令、本地联调、排障步骤和文档示例禁止继续使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`,发布目标必须显式传 `--server` / `--server-url`,身份问题通过同一 CLI 登录态、专用运行用户或显式 token 处理;若旧文档仍推荐 `--root-dir`,先修正文档口径再执行。
|
||||
- 凡是涉及 SpacetimeDB 的设计、实现、脚本、调试、前端绑定接入,统一显式使用以下 skill 作为执行依据:
|
||||
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
|
||||
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
|
||||
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
||||
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
|
||||
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。
|
||||
- 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev: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) 执行。
|
||||
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。
|
||||
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。
|
||||
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type {
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
AdminDisableProfileTaskConfigRequest,
|
||||
AdminDatabaseTableListResponse,
|
||||
AdminDatabaseTableRowsQuery,
|
||||
AdminDatabaseTableRowsResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -129,6 +134,23 @@ export function getAdminOverview(token: string) {
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', {token});
|
||||
}
|
||||
|
||||
export function getAdminDatabaseTables(token: string) {
|
||||
return request<AdminDatabaseTableListResponse>('/admin/api/database/tables', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminDatabaseTableRows(
|
||||
token: string,
|
||||
tableName: string,
|
||||
query: AdminDatabaseTableRowsQuery = {},
|
||||
) {
|
||||
return request<AdminDatabaseTableRowsResponse>(
|
||||
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
|
||||
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
|
||||
method: 'POST',
|
||||
@@ -147,6 +169,28 @@ export function listAdminTrackingEvents(
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertAdminCreationEntryConfig(
|
||||
token: string,
|
||||
payload: AdminUpsertCreationEntryTypeConfigRequest,
|
||||
) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
@@ -257,6 +301,17 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'search', query.search);
|
||||
appendQueryParam(params, 'filters', query.filters);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function appendQueryParam(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
|
||||
@@ -72,6 +72,30 @@ export interface AdminDatabaseOverviewPayload {
|
||||
fetchErrors: string[];
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableListResponse {
|
||||
tables: string[];
|
||||
fetchErrors: string[];
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableRowsQuery {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
filters?: string;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableRowPayload {
|
||||
cells: Record<string, unknown>;
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableRowsResponse {
|
||||
tableName: string;
|
||||
columns: string[];
|
||||
rows: AdminDatabaseTableRowPayload[];
|
||||
totalReturned: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableStatPayload {
|
||||
tableName: string;
|
||||
rowCount: number | null;
|
||||
@@ -117,6 +141,34 @@ export interface AdminTrackingEventListQuery {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
}
|
||||
|
||||
export interface AdminCreationEntryTypeConfigPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
sortOrder: number;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminUpsertCreationEntryTypeConfigRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge: string;
|
||||
imageSrc: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
@@ -130,6 +182,7 @@ export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
export interface AdminUpsertProfileInviteCodeRequest {
|
||||
inviteCode: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
grantedUserTags: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
@@ -176,6 +229,7 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
userId: string;
|
||||
inviteCode: string;
|
||||
metadata: Record<string, unknown>;
|
||||
grantedUserTags: string[];
|
||||
startsAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
status: 'pending' | 'active' | 'expired';
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
getStoredAdminToken,
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
@@ -160,6 +162,12 @@ export function AdminApp() {
|
||||
{routeId === 'overview' ? (
|
||||
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'tables' ? (
|
||||
<AdminDatabaseTablesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'debug' ? (
|
||||
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
@@ -185,6 +193,12 @@ export function AdminApp() {
|
||||
onResultChange={setInviteResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'creation-entry' ? (
|
||||
<AdminCreationEntrySwitchPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'tasks' ? (
|
||||
<AdminTaskConfigPage
|
||||
result={taskConfigResult}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
SlidersHorizontal,
|
||||
Database,
|
||||
Table2,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
@@ -24,11 +26,13 @@ interface AdminShellProps {
|
||||
|
||||
const routeIcons = {
|
||||
overview: LayoutDashboard,
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
tasks: ListChecks,
|
||||
'creation-entry': SlidersHorizontal,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'tracking' | 'redeem' | 'invite' | 'tasks';
|
||||
export type AdminRouteId =
|
||||
| 'overview'
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'redeem'
|
||||
| 'invite'
|
||||
| 'tasks'
|
||||
| 'creation-entry';
|
||||
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
@@ -8,15 +16,17 @@ export interface AdminRouteDefinition {
|
||||
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'overview', label: '总览', hash: '#overview'},
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
|
||||
];
|
||||
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase();
|
||||
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
|
||||
return (
|
||||
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
|
||||
'overview'
|
||||
|
||||
42
apps/admin-web/src/config/trackingEventDefinitions.test.ts
Normal file
42
apps/admin-web/src/config/trackingEventDefinitions.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
import {
|
||||
adminProfileTaskTrackingEventDefinitions,
|
||||
adminTrackingEventDefinitions,
|
||||
filterAdminProfileTaskTrackingEventDefinitions,
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from './trackingEventDefinitions';
|
||||
|
||||
describe('admin tracking event definitions', () => {
|
||||
test('后台埋点筛选候选包含后端通用埋点清单', () => {
|
||||
const keys = adminTrackingEventDefinitions.map((definition) => definition.key);
|
||||
|
||||
expect(keys.length).toBeGreaterThan(40);
|
||||
expect(keys).toContain('daily_login');
|
||||
expect(keys).toContain('auth_login_options_view');
|
||||
expect(keys).toContain('task_center_view');
|
||||
expect(keys).toContain('asset_upload_ticket_create');
|
||||
expect(keys).toContain('creative_agent_route_success');
|
||||
expect(keys).toContain('work_play_start');
|
||||
});
|
||||
|
||||
test('任务配置候选只开放适合个人任务的事件', () => {
|
||||
expect(adminProfileTaskTrackingEventDefinitions.map(({key}) => key)).toEqual([
|
||||
'daily_login',
|
||||
]);
|
||||
expect(filterAdminProfileTaskTrackingEventDefinitions('').map(({key}) => key)).toEqual([
|
||||
'daily_login',
|
||||
]);
|
||||
});
|
||||
|
||||
test('后台埋点筛选支持按中文名称和 key 搜索', () => {
|
||||
expect(filterAdminTrackingEventDefinitions('上传票据').map(({key}) => key)).toEqual([
|
||||
'asset_upload_ticket_create',
|
||||
]);
|
||||
expect(filterAdminTrackingEventDefinitions('work_play').map(({key}) => key)).toEqual([
|
||||
'work_play_start',
|
||||
]);
|
||||
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe('每日登录');
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,400 @@ export interface AdminTrackingEventDefinition {
|
||||
title: string;
|
||||
scopeKind: TrackingScopeKind;
|
||||
remark: string;
|
||||
taskConfigEligible?: boolean;
|
||||
}
|
||||
|
||||
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
{
|
||||
key: 'auth_login_options_view',
|
||||
title: '登录方式查看',
|
||||
scopeKind: 'site',
|
||||
remark: '读取当前可用登录方式时记录,用于观察登录入口曝光。',
|
||||
},
|
||||
{
|
||||
key: 'auth_phone_code_send',
|
||||
title: '发送手机验证码',
|
||||
scopeKind: 'site',
|
||||
remark: '提交手机验证码发送请求成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'daily_login',
|
||||
title: '每日登录',
|
||||
scopeKind: 'user',
|
||||
remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。',
|
||||
remark: '认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。',
|
||||
taskConfigEligible: true,
|
||||
},
|
||||
{
|
||||
key: 'auth_phone_login_success',
|
||||
title: '手机号登录成功',
|
||||
scopeKind: 'user',
|
||||
remark: '手机号验证码登录成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_me_view',
|
||||
title: '当前账号查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取当前登录账号信息成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_sessions_view',
|
||||
title: '登录会话查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取当前账号登录会话列表成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_refresh_success',
|
||||
title: '登录续期成功',
|
||||
scopeKind: 'site',
|
||||
remark: 'refresh cookie 续期成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_logout',
|
||||
title: '退出登录',
|
||||
scopeKind: 'user',
|
||||
remark: '退出当前登录会话成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_logout_all',
|
||||
title: '退出全部会话',
|
||||
scopeKind: 'user',
|
||||
remark: '退出全部登录会话成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'auth_wechat_bind_phone_success',
|
||||
title: '微信绑定手机成功',
|
||||
scopeKind: 'user',
|
||||
remark: '微信账号绑定手机号成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'profile_identity_update',
|
||||
title: '资料更新',
|
||||
scopeKind: 'user',
|
||||
remark: '用户资料更新成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'profile_dashboard_view',
|
||||
title: '个人看板查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人看板成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'wallet_ledger_view',
|
||||
title: '钱包流水查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取光点钱包流水成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'recharge_center_view',
|
||||
title: '充值中心查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取充值中心信息成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'recharge_order_create',
|
||||
title: '充值订单创建',
|
||||
scopeKind: 'user',
|
||||
remark: '创建充值订单成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'feedback_submit',
|
||||
title: '反馈提交',
|
||||
scopeKind: 'user',
|
||||
remark: '资料页反馈提交成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'invite_center_view',
|
||||
title: '邀请中心查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取邀请中心成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'referral_invite_code_redeem',
|
||||
title: '邀请码绑定',
|
||||
scopeKind: 'user',
|
||||
remark: '绑定推荐邀请码成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'redeem_code_submit',
|
||||
title: '兑换码提交',
|
||||
scopeKind: 'user',
|
||||
remark: '提交运营兑换码成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'task_center_view',
|
||||
title: '任务中心查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人任务中心成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'task_reward_claim',
|
||||
title: '任务奖励领取',
|
||||
scopeKind: 'user',
|
||||
remark: '领取个人任务奖励成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'save_archive_list_view',
|
||||
title: '存档列表查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人存档列表成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'save_archive_detail_view',
|
||||
title: '存档详情查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人存档详情成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_view',
|
||||
title: '浏览历史查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_record',
|
||||
title: '浏览历史写入',
|
||||
scopeKind: 'user',
|
||||
remark: '记录浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'browse_history_clear',
|
||||
title: '浏览历史清空',
|
||||
scopeKind: 'user',
|
||||
remark: '清空浏览历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'play_stats_view',
|
||||
title: '游玩统计查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人游玩统计成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'profile_analytics_metric_view',
|
||||
title: '个人指标查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取个人埋点指标成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_create',
|
||||
title: 'AI 任务创建',
|
||||
scopeKind: 'user',
|
||||
remark: '创建 AI 任务成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_start',
|
||||
title: 'AI 任务启动',
|
||||
scopeKind: 'user',
|
||||
remark: '启动 AI 任务成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_stage_start',
|
||||
title: 'AI 阶段启动',
|
||||
scopeKind: 'user',
|
||||
remark: '启动 AI 任务阶段成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_chunk_append',
|
||||
title: 'AI 分片追加',
|
||||
scopeKind: 'user',
|
||||
remark: '追加 AI 文本分片成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_stage_complete',
|
||||
title: 'AI 阶段完成',
|
||||
scopeKind: 'user',
|
||||
remark: '完成 AI 任务阶段成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_reference_attach',
|
||||
title: 'AI 结果引用绑定',
|
||||
scopeKind: 'user',
|
||||
remark: '绑定 AI 结果引用成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_complete',
|
||||
title: 'AI 任务完成',
|
||||
scopeKind: 'user',
|
||||
remark: '完成 AI 任务成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_fail',
|
||||
title: 'AI 任务失败标记',
|
||||
scopeKind: 'user',
|
||||
remark: '标记 AI 任务失败成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'ai_task_cancel',
|
||||
title: 'AI 任务取消',
|
||||
scopeKind: 'user',
|
||||
remark: '取消 AI 任务成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_upload_ticket_create',
|
||||
title: '资产上传票据创建',
|
||||
scopeKind: 'user',
|
||||
remark: '创建直传票据成功后记录,metadata 包含低敏资产定位字段。',
|
||||
},
|
||||
{
|
||||
key: 'asset_sts_credentials_create',
|
||||
title: '资产 STS 凭证创建',
|
||||
scopeKind: 'user',
|
||||
remark: '创建临时上传凭证成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_upload_confirm',
|
||||
title: '资产上传确认',
|
||||
scopeKind: 'user',
|
||||
remark: '确认 OSS 对象为平台资产成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_bind',
|
||||
title: '资产绑定',
|
||||
scopeKind: 'user',
|
||||
remark: '把平台资产绑定到业务实体成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_visual_generate',
|
||||
title: '角色形象生成',
|
||||
scopeKind: 'user',
|
||||
remark: '角色主图生成请求成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_visual_publish',
|
||||
title: '角色形象发布',
|
||||
scopeKind: 'user',
|
||||
remark: '角色主图发布成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_animation_generate',
|
||||
title: '角色动画生成',
|
||||
scopeKind: 'user',
|
||||
remark: '角色动画生成请求成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_animation_publish',
|
||||
title: '角色动画发布',
|
||||
scopeKind: 'user',
|
||||
remark: '角色动画发布成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_animation_import',
|
||||
title: '角色动画视频导入',
|
||||
scopeKind: 'user',
|
||||
remark: '角色动画导入视频成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_character_workflow_cache_save',
|
||||
title: '角色工作流缓存保存',
|
||||
scopeKind: 'user',
|
||||
remark: '保存角色工作流缓存成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asset_history_view',
|
||||
title: '资产历史查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取资产历史成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'llm_request',
|
||||
title: 'LLM 请求',
|
||||
scopeKind: 'user',
|
||||
remark: '调用后端 LLM 门面成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'speech_config_view',
|
||||
title: '语音配置查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取语音配置成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'asr_stream_start',
|
||||
title: 'ASR 流启动',
|
||||
scopeKind: 'user',
|
||||
remark: '启动语音识别流成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'tts_bidirection_start',
|
||||
title: 'TTS 双向流启动',
|
||||
scopeKind: 'user',
|
||||
remark: '启动双向语音合成流成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'tts_sse_start',
|
||||
title: 'TTS SSE 启动',
|
||||
scopeKind: 'user',
|
||||
remark: '启动 SSE 语音合成成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_settings_view',
|
||||
title: '运行设置查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取运行设置成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_settings_update',
|
||||
title: '运行设置更新',
|
||||
scopeKind: 'user',
|
||||
remark: '更新运行设置成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_view',
|
||||
title: '运行快照查看',
|
||||
scopeKind: 'user',
|
||||
remark: '读取运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_save',
|
||||
title: '运行快照保存',
|
||||
scopeKind: 'user',
|
||||
remark: '保存运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'runtime_snapshot_delete',
|
||||
title: '运行快照删除',
|
||||
scopeKind: 'user',
|
||||
remark: '删除运行快照成功后记录。',
|
||||
},
|
||||
{
|
||||
key: 'puzzle_route_success',
|
||||
title: '拼图路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '拼图运行或创作接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'match3d_route_success',
|
||||
title: '抓大鹅路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'square_hole_route_success',
|
||||
title: '方洞路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '方洞创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'custom_world_route_success',
|
||||
title: '自定义世界路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '自定义世界运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'creative_agent_route_success',
|
||||
title: '创意 Agent 路由成功',
|
||||
scopeKind: 'user',
|
||||
remark: '创意 Agent 接口成功响应后兜底记录;GET 入口可能按 site 统计。',
|
||||
},
|
||||
{
|
||||
key: 'work_play_start',
|
||||
title: '作品开始游玩',
|
||||
scopeKind: 'work',
|
||||
remark: '拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
|
||||
},
|
||||
];
|
||||
|
||||
export const adminProfileTaskTrackingEventDefinitions =
|
||||
adminTrackingEventDefinitions.filter((definition) => definition.taskConfigEligible);
|
||||
|
||||
export function findAdminTrackingEventDefinition(eventKey: string) {
|
||||
const normalizedEventKey = eventKey.trim();
|
||||
return (
|
||||
@@ -26,12 +409,26 @@ export function findAdminTrackingEventDefinition(eventKey: string) {
|
||||
}
|
||||
|
||||
export function filterAdminTrackingEventDefinitions(query: string) {
|
||||
return filterTrackingEventDefinitions(adminTrackingEventDefinitions, query);
|
||||
}
|
||||
|
||||
export function filterAdminProfileTaskTrackingEventDefinitions(query: string) {
|
||||
return filterTrackingEventDefinitions(
|
||||
adminProfileTaskTrackingEventDefinitions,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
function filterTrackingEventDefinitions(
|
||||
definitions: AdminTrackingEventDefinition[],
|
||||
query: string,
|
||||
) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return adminTrackingEventDefinitions;
|
||||
return definitions;
|
||||
}
|
||||
|
||||
return adminTrackingEventDefinitions.filter((definition) => {
|
||||
return definitions.filter((definition) => {
|
||||
const haystack = [
|
||||
definition.key,
|
||||
definition.title,
|
||||
|
||||
260
apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
Normal file
260
apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import {RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useState} from 'react';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {AdminCreationEntryTypeConfigPayload} from '../api/adminApiTypes';
|
||||
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
interface AdminCreationEntrySwitchPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
export function AdminCreationEntrySwitchPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminCreationEntrySwitchPageProps) {
|
||||
const [entries, setEntries] = useState<AdminCreationEntryTypeConfigPayload[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('puzzle');
|
||||
const [title, setTitle] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
const [badge, setBadge] = useState('');
|
||||
const [imageSrc, setImageSrc] = useState('');
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [sortOrder, setSortOrder] = useState('30');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEntries();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
async function refreshEntries() {
|
||||
setIsLoading(true);
|
||||
setListErrorMessage('');
|
||||
try {
|
||||
const response = await getAdminCreationEntryConfig(token);
|
||||
const nextEntries = sortEntries(response.entries);
|
||||
setEntries(nextEntries);
|
||||
fillForm(
|
||||
nextEntries.find((entry) => entry.id === selectedId) ?? nextEntries[0] ?? null,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setListErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = selectedId.trim();
|
||||
setErrorMessage('');
|
||||
const confirmed = await confirmWrite({
|
||||
action: '保存创作入口开关',
|
||||
target: targetId,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await upsertAdminCreationEntryConfig(token, {
|
||||
id: targetId,
|
||||
title: title.trim(),
|
||||
subtitle: subtitle.trim(),
|
||||
badge: badge.trim(),
|
||||
imageSrc: imageSrc.trim(),
|
||||
visible,
|
||||
open,
|
||||
sortOrder: parseInteger(sortOrder),
|
||||
});
|
||||
const nextEntries = sortEntries(response.entries);
|
||||
setEntries(nextEntries);
|
||||
fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(entry.id);
|
||||
setTitle(entry.title);
|
||||
setSubtitle(entry.subtitle);
|
||||
setBadge(entry.badge);
|
||||
setImageSrc(entry.imageSrc);
|
||||
setVisible(entry.visible);
|
||||
setOpen(entry.open);
|
||||
setSortOrder(String(entry.sortOrder));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>创作入口开关</h2>
|
||||
<p>控制创作中心入口展示与运行态路由可用性</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshEntries}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{listErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>入口 ID</span>
|
||||
<input
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={visible}
|
||||
type="checkbox"
|
||||
onChange={(event) => setVisible(event.target.checked)}
|
||||
/>
|
||||
<span>展示</span>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={open}
|
||||
type="checkbox"
|
||||
onChange={(event) => setOpen(event.target.checked)}
|
||||
/>
|
||||
<span>开放</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>标题</span>
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>角标</span>
|
||||
<input value={badge} onChange={(event) => setBadge(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>副标题</span>
|
||||
<input value={subtitle} onChange={(event) => setSubtitle(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>图片路径</span>
|
||||
<input value={imageSrc} onChange={(event) => setImageSrc(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>排序</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="admin-primary-button" disabled={isSaving} type="submit">
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存入库'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>入口</th>
|
||||
<th>展示</th>
|
||||
<th>开放</th>
|
||||
<th>排序</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-link-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.title || entry.id}
|
||||
</button>
|
||||
</td>
|
||||
<td>{entry.visible ? '是' : '否'}</td>
|
||||
<td>{entry.open ? '是' : '否'}</td>
|
||||
<td>{entry.sortOrder}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: AdminCreationEntryTypeConfigPayload[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (left.sortOrder !== right.sortOrder) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
107
apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx
Normal file
107
apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminDatabaseTableRows,
|
||||
getAdminDatabaseTables,
|
||||
} from '../api/adminApiClient';
|
||||
import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminDatabaseTableRows: vi.fn(),
|
||||
getAdminDatabaseTables: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.hash = '#tables?table=profile_referral_relation';
|
||||
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
|
||||
fetchErrors: [],
|
||||
tables: ['profile_referral_relation'],
|
||||
});
|
||||
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
|
||||
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
|
||||
limit: 100,
|
||||
rows: [
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-02T00:00:00Z',
|
||||
invitee_user_id: 'u-b',
|
||||
invite_code: 'INV-1001',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: [
|
||||
'u-b',
|
||||
'u-a',
|
||||
'INV-1001',
|
||||
'2026-05-02T00:00:00Z',
|
||||
],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-01T00:00:00Z',
|
||||
invitee_user_id: 'u-a',
|
||||
invite_code: 'INV-1002',
|
||||
inviter_user_id: 'u-c',
|
||||
},
|
||||
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
|
||||
},
|
||||
{
|
||||
cells: {
|
||||
bound_at: '2026-05-03T00:00:00Z',
|
||||
invitee_user_id: 'u-c',
|
||||
invite_code: 'INV-1003',
|
||||
inviter_user_id: 'u-a',
|
||||
},
|
||||
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
|
||||
},
|
||||
],
|
||||
tableName: 'profile_referral_relation',
|
||||
totalReturned: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {container} = render(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('u-b');
|
||||
|
||||
const tableWrap = container.querySelector('.admin-table-wrap');
|
||||
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
|
||||
expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe(
|
||||
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||
);
|
||||
expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe(
|
||||
'原始表名:profile_referral_relation。邀请关系记录表。',
|
||||
);
|
||||
expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe(
|
||||
'原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。',
|
||||
);
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||
await waitFor(() => {
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
|
||||
await waitFor(() => {
|
||||
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
|
||||
});
|
||||
});
|
||||
|
||||
function readFirstColumnValues(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('tbody tr')).map(
|
||||
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
|
||||
);
|
||||
}
|
||||
1361
apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx
Normal file
1361
apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ export function AdminInviteCodePage({
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [startsAt, setStartsAt] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [grantedTagsText, setGrantedTagsText] = useState('');
|
||||
const [metadataText, setMetadataText] = useState('{}');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
@@ -80,6 +81,7 @@ export function AdminInviteCodePage({
|
||||
const payload: AdminUpsertProfileInviteCodeRequest = {
|
||||
inviteCode: inviteCode.trim(),
|
||||
metadata: parseMetadata(metadataText),
|
||||
grantedUserTags: parseUserTags(grantedTagsText),
|
||||
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
|
||||
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
|
||||
};
|
||||
@@ -115,6 +117,7 @@ export function AdminInviteCodePage({
|
||||
setInviteCode(entry.inviteCode);
|
||||
setStartsAt(toDateTimeLocalValue(entry.startsAt));
|
||||
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
|
||||
setGrantedTagsText(entry.grantedUserTags.join('、'));
|
||||
setMetadataText(JSON.stringify(entry.metadata, null, 2));
|
||||
}
|
||||
|
||||
@@ -174,6 +177,15 @@ export function AdminInviteCodePage({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>用户标签</span>
|
||||
<input
|
||||
autoComplete="off"
|
||||
value={grantedTagsText}
|
||||
onChange={(event) => setGrantedTagsText(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>Metadata JSON</span>
|
||||
<textarea
|
||||
@@ -222,6 +234,7 @@ export function AdminInviteCodePage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>邀请码</th>
|
||||
<th>标签</th>
|
||||
<th>有效期</th>
|
||||
<th>创建</th>
|
||||
</tr>
|
||||
@@ -238,6 +251,9 @@ export function AdminInviteCodePage({
|
||||
{entry.inviteCode}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<TagList tags={entry.grantedUserTags} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-status ${inviteValidityClass(entry)}`}>
|
||||
{inviteValidityLabel(entry)}
|
||||
@@ -272,6 +288,12 @@ export function AdminInviteCodePage({
|
||||
<dt>有效期</dt>
|
||||
<dd>{formatValidityWindow(result)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>标签</dt>
|
||||
<dd>
|
||||
<TagList tags={result.grantedUserTags} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建</dt>
|
||||
<dd>{result.createdAt}</dd>
|
||||
@@ -300,6 +322,33 @@ export function AdminInviteCodePage({
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({tags}: {tags: string[]}) {
|
||||
if (!tags.length) {
|
||||
return <span className="admin-muted-text">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="admin-tag-list">
|
||||
{tags.map((tag) => (
|
||||
<span className="admin-tag" key={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function parseUserTags(value: string) {
|
||||
const tags: string[] = [];
|
||||
for (const raw of value.split(/[\n,,;;、]+/)) {
|
||||
const tag = raw.trim();
|
||||
if (tag && !tags.includes(tag)) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function parseMetadata(value: string): Record<string, unknown> {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -155,7 +155,17 @@ function InfoPanel({
|
||||
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{stat.tableName}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#tables?table=${encodeURIComponent(stat.tableName)}`;
|
||||
}}
|
||||
>
|
||||
{stat.tableName}
|
||||
</button>
|
||||
</td>
|
||||
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
|
||||
<td>
|
||||
{stat.errorMessage ? (
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from '../api/adminApiTypes';
|
||||
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
|
||||
import {
|
||||
filterAdminTrackingEventDefinitions,
|
||||
filterAdminProfileTaskTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import {handlePageError} from './pageUtils';
|
||||
@@ -68,7 +68,7 @@ export function AdminTaskConfigPage({
|
||||
[eventKey],
|
||||
);
|
||||
const filteredEventDefinitions = useMemo(
|
||||
() => filterAdminTrackingEventDefinitions(eventKeySearch),
|
||||
() => filterAdminProfileTaskTrackingEventDefinitions(eventKeySearch),
|
||||
[eventKeySearch],
|
||||
);
|
||||
|
||||
|
||||
@@ -302,13 +302,54 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(220px, 1.2fr) minmax(96px, 0.45fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-table tbody tr[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-table tbody tr[data-clickable="true"]:hover {
|
||||
background: #f5fafb;
|
||||
}
|
||||
|
||||
.admin-text-button:hover,
|
||||
.admin-text-button:focus-visible {
|
||||
color: #126e82;
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-query-action-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-query-summary,
|
||||
.admin-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-query-summary {
|
||||
color: #667682;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.admin-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@@ -557,6 +598,13 @@ button:disabled {
|
||||
background: #eef3f6;
|
||||
}
|
||||
|
||||
.admin-ghost-button.admin-query-reset-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.admin-text-button {
|
||||
display: inline;
|
||||
border: 0;
|
||||
@@ -604,7 +652,10 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
@@ -633,6 +684,32 @@ button:disabled {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-muted-text {
|
||||
color: #86939c;
|
||||
}
|
||||
|
||||
.admin-tag-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-tag {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
border: 1px solid #cbdfe6;
|
||||
border-radius: 999px;
|
||||
background: #eef7f8;
|
||||
color: #0f5666;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table-compact {
|
||||
min-width: 360px;
|
||||
}
|
||||
@@ -641,6 +718,65 @@ button:disabled {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.admin-database-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-database-table th,
|
||||
.admin-database-table td {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.admin-database-table th:last-child,
|
||||
.admin-database-table td:last-child {
|
||||
width: 112px;
|
||||
min-width: 112px;
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
.admin-table-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
color: #667682;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-table-sort-button span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-table-sort-button svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-table-cell-ellipsis {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table-sort-button:hover,
|
||||
.admin-table-sort-button:focus-visible,
|
||||
.admin-table-sort-button[data-active="true"] {
|
||||
color: #0f5666;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-json-preview {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
@@ -811,7 +947,8 @@ button:disabled {
|
||||
.admin-two-column,
|
||||
.admin-two-column-wide,
|
||||
.admin-form-row,
|
||||
.admin-filter-grid {
|
||||
.admin-filter-grid,
|
||||
.admin-table-query-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
20
deploy/env/api-server.env.example
vendored
20
deploy/env/api-server.env.example
vendored
@@ -37,7 +37,25 @@ GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
|
||||
|
||||
APIMART_BASE_URL=
|
||||
APIMART_API_KEY=
|
||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||
|
||||
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
|
||||
VECTOR_ENGINE_API_KEY=
|
||||
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000
|
||||
|
||||
HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2
|
||||
HYPER3D_API_KEY=
|
||||
HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000
|
||||
|
||||
VOLCENGINE_SPEECH_API_KEY=
|
||||
VOLCENGINE_SPEECH_APP_ID=
|
||||
VOLCENGINE_SPEECH_ACCESS_KEY=
|
||||
VOLCENGINE_SPEECH_ASR_RESOURCE_ID=volc.seedasr.sauc.concurrent
|
||||
VOLCENGINE_SPEECH_TTS_RESOURCE_ID=seed-tts-2.0
|
||||
VOLCENGINE_SPEECH_REQUEST_TIMEOUT_MS=180000
|
||||
VOLCENGINE_SPEECH_ASR_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
|
||||
VOLCENGINE_SPEECH_TTS_BIDIRECTION_WS_URL=wss://openspeech.bytedance.com/api/v3/tts/bidirection
|
||||
VOLCENGINE_SPEECH_TTS_SSE_URL=https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse
|
||||
|
||||
ARK_CHARACTER_VIDEO_BASE_URL=
|
||||
ARK_CHARACTER_VIDEO_API_KEY=
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
|
||||
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
|
||||
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
|
||||
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。
|
||||
- [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的百梦 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
|
||||
@@ -23,6 +23,14 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
|
||||
|
||||
`maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run api-server` 与 `/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)。
|
||||
|
||||
基于 LangChain-Rust,以“感知—思考—记忆—行动—反思—协作”闭环完成图文创意理解、拼图模板选择、积分范围确认、拼图草稿字段填充、立即试玩和自然语言修订草稿字段的 Agent 方案见 [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md)。
|
||||
|
||||
创意互动内容 Agent Phase 1 的产品边界、实现细节、SpacetimeDB 落点、前端接入和可并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md)。
|
||||
|
||||
视觉小说模板接入以 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为最新口径:只吸收外部 TXT 玩法的模板创作与运行经验,禁止迁入外部平台功能,并删除回放。
|
||||
|
||||
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
- [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。
|
||||
- [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md):RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。
|
||||
- [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md):RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。
|
||||
- [VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md](./VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md):视觉小说 VN-11 回放删除与外部平台功能误入负向扫描报告。
|
||||
- [VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md](./VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md):视觉小说 VN-12 全链路联调与自动化验收报告。
|
||||
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md):RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。
|
||||
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md):RPG 前端脚本后端迁移完成度复核,标明开局、快照、story engine / prompt context、`camp_travel_home_scene`、战斗、NPC、背包/锻造、结果页保存 normalize 与角色资产 prompt 主链均已收口。
|
||||
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。
|
||||
|
||||
377
docs/audits/SECURITY_VULNERABILITY_SCAN_2026-05-11.md
Normal file
377
docs/audits/SECURITY_VULNERABILITY_SCAN_2026-05-11.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# 安全漏洞扫描报告 2026-05-11
|
||||
|
||||
## 扫描范围
|
||||
|
||||
- 工作区:`C:/proj/Genarrative/.worktrees/hermes-3337436a`
|
||||
- 分支:`hermes/hermes-3337436a`
|
||||
- Git 基线:扫描时存在一个未跟踪计划文件 `.hermes/plans/2026-05-11_205658-security-vulnerability-scan.md`。
|
||||
- 扫描对象:根 Node/Vite/React 依赖、`server-rs` Rust workspace 依赖入口、仓库已跟踪文件中的敏感配置、JS/TS/Rust 源码安全热点。
|
||||
|
||||
## 扫描命令与工具状态
|
||||
|
||||
已执行:
|
||||
|
||||
```bash
|
||||
pwd
|
||||
git branch --show-current
|
||||
git rev-parse --show-toplevel
|
||||
git status --short
|
||||
node --version
|
||||
npm --version
|
||||
cargo --version
|
||||
rustc --version
|
||||
rg --version
|
||||
npm audit --json
|
||||
npm audit --audit-level=moderate
|
||||
git ls-files -z | xargs -0 grep -nIE "(api[_-]?key|secret|password|passwd|token|private[_-]?key|BEGIN (RSA|OPENSSH|EC|DSA)? ?PRIVATE KEY|AKIA[0-9A-Z]{16}|xox[baprs]-|sk-[A-Za-z0-9_-]{20,})"
|
||||
rg -n "\beval\(|new Function\(|dangerouslySetInnerHTML|innerHTML\s*=|document\.write\(" src apps scripts packages
|
||||
rg -n "exec\(|execSync\(|spawn\(|spawnSync\(|shell:\s*true|child_process" scripts src apps packages
|
||||
rg -n "Command::new|std::process|\.unwrap\(|\.expect\(|fs::|File::open|PathBuf|set_header|cors|CorsLayer" server-rs/crates
|
||||
rg -n "allow_origin|Any|cookie|Authorization|Bearer|refresh|access_token|set_cookie|SameSite|Secure|HttpOnly" server-rs/crates src apps scripts
|
||||
```
|
||||
|
||||
工具版本:
|
||||
|
||||
- Node:`v22.22.2`
|
||||
- npm:`10.9.7`
|
||||
- Cargo:`cargo 1.95.0 (f2d3ce0bd 2026-03-21)`
|
||||
- Rustc:`rustc 1.95.0 (59807616e 2026-04-14)`
|
||||
- ripgrep:`15.1.0`
|
||||
- `gitleaks`:未安装,本次未执行。
|
||||
- `cargo-audit`:未安装,本次未执行;未擅自安装到用户环境。
|
||||
|
||||
原始扫描输出保存于:
|
||||
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.json`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/npm-audit.txt`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/js-xss-dynamic.txt`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/node-command-exec.txt`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/rust-hotspots.txt`
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/auth-cors-hotspots.txt`
|
||||
|
||||
注意:`secret-grep.txt` 可能包含敏感片段,提交前应删除或改为脱敏摘要,不建议直接进入 Git。
|
||||
|
||||
## 摘要
|
||||
|
||||
| 等级 | 数量 | 说明 |
|
||||
| --- | ---: | --- |
|
||||
| Critical | 1 | `.env.local` 被 Git 跟踪且含多项非空真实密钥/凭据形态配置。 |
|
||||
| High | 2 | npm 依赖存在 8 个 high advisory 聚合项;Vite dev server 任意文件读取类漏洞需要优先升级。另有 TypeScript ESLint 链路 ReDoS 风险。 |
|
||||
| Medium | 2 | esbuild dev server 请求读取、PostCSS CSS stringify XSS。 |
|
||||
| Low | 3 | jsdom/http-proxy-agent/@tootallnate/once 低危链路。 |
|
||||
| Unknown | 1 | Rust 依赖漏洞未完成扫描,因为本机未安装 `cargo-audit`。 |
|
||||
| Informational | 多项 | 源码热点扫描命中大量测试/脚本/unwrap/expect,需要按入口人工复核。 |
|
||||
|
||||
## Critical
|
||||
|
||||
### C-1:仓库跟踪了 `.env.local`,且包含多项非空真实密钥形态配置
|
||||
|
||||
**证据:**
|
||||
|
||||
`git ls-files --error-unmatch .env.local` 显示 `.env.local` 已被 Git 跟踪。扫描确认该文件包含多项非空密钥/凭据形态变量,包括但不限于:
|
||||
|
||||
- `LLM_API_KEY`
|
||||
- `ARK_API_KEY`
|
||||
- `ARK_CHARACTER_VIDEO_API_KEY`
|
||||
- `DASHSCOPE_API_KEY`
|
||||
- `VOLCENGINE_ACCESS_KEY_ID`
|
||||
- `VOLCENGINE_SECRET_ACCESS_KEY`
|
||||
- `ALIYUN_SMS_ACCESS_KEY_ID`
|
||||
- `ALIYUN_SMS_ACCESS_KEY_SECRET`
|
||||
- `GENARRATIVE_LLM_API_KEY`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_ID`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
|
||||
- `GENARRATIVE_ADMIN_PASSWORD`
|
||||
|
||||
报告中不记录具体值。
|
||||
|
||||
**影响:**
|
||||
|
||||
如果该文件已进入远端仓库或被团队成员拉取,相关外部服务密钥、OSS/SMS/LLM/后台密码均应视为已泄露。即使后续从当前工作树删除,也不能撤销历史泄露风险。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
1. 立即轮换 `.env.local` 中出现过的所有真实密钥、访问密钥、后台密码和 token。
|
||||
2. 从 Git 跟踪中移除 `.env.local`,但不要删除本地私有文件:
|
||||
|
||||
```bash
|
||||
git rm --cached .env.local
|
||||
```
|
||||
|
||||
3. 按项目约束,不要在 `.gitignore` 中新增 `.env.local`;如果仓库已有其他机制管理本地私密 env,应遵循既有约定。若没有,应先补一份安全说明文档,而不是提交真实 `.env.local`。
|
||||
4. 将必要的占位示例保留在 `.env.example` 或 `deploy/env/api-server.env.example`,确保示例值不是可用密钥。
|
||||
5. 如该文件已推送到远端历史,评估是否需要历史清理;无论是否清历史,密钥轮换都是必须步骤。
|
||||
|
||||
**验证:**
|
||||
|
||||
```bash
|
||||
git ls-files --error-unmatch .env.local
|
||||
# 预期:返回非 0,表示不再跟踪
|
||||
|
||||
git diff --cached -- .env.local
|
||||
# 预期:只显示从索引移除,不输出真实值到公开报告
|
||||
```
|
||||
|
||||
## High
|
||||
|
||||
### H-1:Vite 依赖存在高危 dev server 任意文件读取/路径遍历类 advisory
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- package:`vite`
|
||||
- direct dependency:是
|
||||
- installed vulnerable range:`<=6.4.1`
|
||||
- severity:`high`
|
||||
- 相关 advisory 包括:
|
||||
- `GHSA-p9ff-h696-f583`:Vite dev server WebSocket 任意文件读取,高危。
|
||||
- `GHSA-4w7w-66w2-5vf9`:optimized deps `.map` 处理路径遍历,中危。
|
||||
- 多个 `server.fs` / public directory 相关低中危问题。
|
||||
- `fixAvailable=true`。
|
||||
|
||||
**影响:**
|
||||
|
||||
主要影响开发服务器和预览环境。如果开发机、测试机或内网联调环境将 Vite dev server 暴露给不可信网络,攻击者可能读取工作区文件或旁路 `server.fs` 限制。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
1. 优先将 `vite` 升级到 npm audit 推荐的安全版本范围。
|
||||
2. 升级后执行:
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. 检查 `scripts/vite-cli.mjs`、`scripts/dev-web-rust.mjs`、Vite 配置中的 dev server host 暴露范围,开发环境避免绑定 `0.0.0.0` 或暴露到公网。
|
||||
|
||||
### H-2:`@typescript-eslint/*` 链路经 `minimatch` 存在 ReDoS 高危 advisory
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- direct packages:
|
||||
- `@typescript-eslint/eslint-plugin`,当前范围 `6.16.0 - 7.5.0`,high。
|
||||
- `@typescript-eslint/parser`,当前范围 `6.16.0 - 7.5.0`,high。
|
||||
- transitive packages:
|
||||
- `@typescript-eslint/type-utils`
|
||||
- `@typescript-eslint/typescript-estree`
|
||||
- `@typescript-eslint/utils`
|
||||
- `minimatch`
|
||||
- `minimatch` advisory:
|
||||
- `GHSA-3ppc-4f35-3m26`
|
||||
- `GHSA-7r86-cg39-jmmj`
|
||||
- `GHSA-23c5-xmqv-rm74`
|
||||
- npm 建议升级到 `@typescript-eslint/* 8.59.3`,属于 SemVer major。
|
||||
|
||||
**影响:**
|
||||
|
||||
主要影响 lint/构建工具链。如果 CI 或开发命令处理不可信 glob pattern,可能造成 ReDoS。生产运行时直接影响较低,但 CI 可用性和供应链安全仍应修复。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
1. 单独开依赖升级分支,将 `@typescript-eslint/eslint-plugin` 与 `@typescript-eslint/parser` 升级到兼容 ESLint 8/TypeScript 5.8 的安全版本。
|
||||
2. 因为是 major 升级,先阅读迁移说明并运行 ESLint 全量检查。
|
||||
3. 验证:
|
||||
|
||||
```bash
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
```
|
||||
|
||||
### H-3:`picomatch` ReDoS / glob matching 高危 advisory
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- package:`picomatch`
|
||||
- severity:`high`
|
||||
- vulnerable range:`4.0.0 - 4.0.3`
|
||||
- advisory:
|
||||
- `GHSA-c2c7-rcm5-vvqj`:extglob quantifiers ReDoS,高危。
|
||||
- `GHSA-3v7f-55p6-f55p`:POSIX character classes method injection,中危。
|
||||
- `fixAvailable=true`。
|
||||
|
||||
**影响:**
|
||||
|
||||
主要影响依赖 picomatch 的构建、测试、文件匹配工具链。生产直接影响取决于是否在服务端运行时用它处理用户输入 glob;当前未在扫描摘要中发现明显业务入口直接使用。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
通过升级引入它的 direct dependency 来消除,不建议手工改 lockfile。
|
||||
|
||||
## Medium
|
||||
|
||||
### M-1:`esbuild <=0.24.2` dev server 允许任意网站请求并读取响应
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- package:`esbuild`
|
||||
- severity:`moderate`
|
||||
- advisory:`GHSA-67mh-4wv8-2f99`
|
||||
- vulnerable range:`<=0.24.2`
|
||||
- `fixAvailable=true`。
|
||||
|
||||
**影响:**
|
||||
|
||||
主要影响开发服务器场景。若本地开发服务暴露到不可信网络,风险上升。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
随 Vite / 构建链路升级一并修复,升级后跑前端检查与构建。
|
||||
|
||||
### M-2:`postcss <8.5.10` CSS stringify XSS advisory
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- package:`postcss`
|
||||
- severity:`moderate`
|
||||
- advisory:`GHSA-qx2v-qp2m-jg93`
|
||||
- vulnerable range:`<8.5.10`
|
||||
- `fixAvailable=true`。
|
||||
|
||||
**影响:**
|
||||
|
||||
如果系统把不可信 CSS 内容 stringify 后注入页面,可能触发 XSS。当前项目是否存在这类业务入口需人工复核;从依赖角度建议升级。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
升级 Tailwind/Vite/PostCSS 链路带出的安全版本,并执行前端构建验证。
|
||||
|
||||
## Low
|
||||
|
||||
### L-1:`jsdom` 链路低危 advisory
|
||||
|
||||
**证据:**
|
||||
|
||||
`npm audit` 显示:
|
||||
|
||||
- `jsdom` direct dependency,severity low。
|
||||
- transitive:`http-proxy-agent`、`@tootallnate/once`。
|
||||
- npm 建议升级到 `jsdom 29.1.1`,SemVer major。
|
||||
|
||||
**影响:**
|
||||
|
||||
通常影响测试环境。若测试工具处理不可信 URL/代理输入,风险上升。
|
||||
|
||||
**建议修复:**
|
||||
|
||||
不要和 Vite/TypeScript ESLint 大升级混在一个提交里。单独升级 jsdom 后运行:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Unknown / 未完成项
|
||||
|
||||
### U-1:Rust 依赖漏洞未完成扫描
|
||||
|
||||
**原因:**
|
||||
|
||||
本机没有 `cargo-audit`,本次没有擅自安装用户级 Cargo 工具。
|
||||
|
||||
**建议:**
|
||||
|
||||
如确认允许安装:
|
||||
|
||||
```bash
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
或在 CI/具备工具的环境执行并回填结果。
|
||||
|
||||
## Informational / 源码热点
|
||||
|
||||
### I-1:JS/TS XSS / 动态执行热点
|
||||
|
||||
扫描命中 1 行:
|
||||
|
||||
- `src/routing/RouteImageReadyGate.test.ts` 中测试代码使用 `root.innerHTML`。
|
||||
|
||||
初步判断为测试环境构造 DOM,不是生产漏洞。若后续发现生产代码使用 `dangerouslySetInnerHTML` 或直接 `innerHTML = userInput`,应升级为 High。
|
||||
|
||||
### I-2:Node 脚本命令执行热点
|
||||
|
||||
扫描命中 21 行,主要集中在:
|
||||
|
||||
- `scripts/spacetime-migration-common.mjs`
|
||||
- `scripts/run-bash-script.mjs`
|
||||
- `scripts/generate-spacetime-bindings.mjs`
|
||||
- `scripts/dev-web-rust.mjs`
|
||||
|
||||
初步判断为项目脚本启动 `spacetime`、bash、Vite 等工具的正常行为。后续人工复核重点:
|
||||
|
||||
- 是否使用固定命令和参数数组,而不是拼接 shell 字符串。
|
||||
- 是否把用户输入直接作为命令或 shell 参数。
|
||||
- 是否设置 `shell: true`。
|
||||
|
||||
### I-3:Rust unwrap/expect、文件路径、CORS/Auth 热点较多
|
||||
|
||||
扫描命中:
|
||||
|
||||
- `rust-hotspots.txt`:1348 行。
|
||||
- `auth-cors-hotspots.txt`:1157 行。
|
||||
|
||||
这些是热点,不等于漏洞。建议后续按模块分批人工复核:
|
||||
|
||||
1. `server-rs/crates/api-server/src/admin.rs`
|
||||
2. `server-rs/crates/api-server/src/app.rs`
|
||||
3. `server-rs/crates/platform-auth/src/**`
|
||||
4. `server-rs/crates/platform-oss/src/**`
|
||||
5. `server-rs/crates/platform-llm/src/**`
|
||||
6. `server-rs/crates/spacetime-client/src/**`
|
||||
|
||||
重点看:生产 CORS、Cookie 安全属性、token 日志、路径拼接、外部 URL 下载、Data URL 大小限制、OSS 签名边界。
|
||||
|
||||
## 推荐修复顺序
|
||||
|
||||
1. 立即处理 C-1:轮换 `.env.local` 里所有真实密钥,并从 Git 索引移除 `.env.local`。
|
||||
2. 升级 `vite` 相关依赖,优先消除 dev server 任意文件读取/路径遍历 advisory。
|
||||
3. 升级 `@typescript-eslint/*`,消除 minimatch 链路 ReDoS;因 major 升级,单独提交。
|
||||
4. 升级 `postcss` / `esbuild` / `picomatch` 的来源依赖。
|
||||
5. 单独评估 `jsdom` major 升级。
|
||||
6. 用户确认后安装或使用 CI 执行 `cargo audit`,补齐 Rust 依赖漏洞结论。
|
||||
7. 对 `auth-cors-hotspots.txt` 和 `rust-hotspots.txt` 做模块级人工审计。
|
||||
|
||||
## 修复后的验证命令
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run lint:eslint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
```
|
||||
|
||||
如修改后端安全、Auth、Cookie、CORS 或 API:
|
||||
|
||||
```bash
|
||||
cd server-rs && cargo test --workspace
|
||||
npm run api-server
|
||||
# 检查 /healthz,并执行相关 API/auth smoke
|
||||
```
|
||||
|
||||
如补齐 Rust audit:
|
||||
|
||||
```bash
|
||||
cargo audit --manifest-path server-rs/Cargo.toml
|
||||
```
|
||||
|
||||
## 备注
|
||||
|
||||
- 本报告没有输出任何真实密钥值。
|
||||
- `.hermes/plans/assets/security-scan-2026-05-11/secret-grep.txt` 可能包含敏感内容,仅用于本地排查;提交前应删除或替换为脱敏报告。
|
||||
- 由于 `gitleaks` 未安装,本次密钥扫描只是 grep 兜底,不等价于完整 secrets audit。
|
||||
32
docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md
Normal file
32
docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# VN-11 负向扫描报告
|
||||
|
||||
生成日期:2026-05-07
|
||||
|
||||
## 扫描范围
|
||||
|
||||
- 工程代码:`src/`、`packages/shared/src/`、`server-rs/crates/`
|
||||
- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`
|
||||
- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径
|
||||
|
||||
## 扫描结论
|
||||
|
||||
- 工程代码回放类直出命中:0
|
||||
- 文档 / 共享记忆回放类命中:222
|
||||
- 视觉小说实现路径外部平台能力疑似误入命中:0
|
||||
|
||||
## 处理记录
|
||||
|
||||
- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。
|
||||
- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。
|
||||
- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。
|
||||
|
||||
## 文档命中说明
|
||||
|
||||
- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。
|
||||
|
||||
## 门禁命令
|
||||
|
||||
```bash
|
||||
npm run check:visual-novel-vn11
|
||||
```
|
||||
|
||||
92
docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md
Normal file
92
docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# VN-12 全链路联调与自动化验收报告
|
||||
|
||||
生成日期:2026-05-07
|
||||
|
||||
## 结论
|
||||
|
||||
- 状态:通过
|
||||
- 失败项:0
|
||||
- 收口说明:VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。
|
||||
|
||||
## 自动化验收清单
|
||||
|
||||
- docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md
|
||||
- docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md
|
||||
- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx
|
||||
- src/components/visual-novel-result/VisualNovelResultView.test.tsx
|
||||
- src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx
|
||||
- src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts
|
||||
- src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
|
||||
- server-rs/crates/api-server/src/visual_novel.rs
|
||||
- server-rs/crates/module-visual-novel/src/application.rs
|
||||
- server-rs/crates/shared-contracts/src/visual_novel.rs
|
||||
- package.json
|
||||
- server-rs/crates/api-server/src/app.rs
|
||||
- src/services/visual-novel-runtime/visualNovelRuntimeClient.ts
|
||||
- src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts
|
||||
- src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
|
||||
- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx
|
||||
- src/components/visual-novel-result/VisualNovelResultView.test.tsx
|
||||
- src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx
|
||||
|
||||
## API smoke
|
||||
|
||||
- `/api/creation/visual-novel/sessions`
|
||||
- `/api/creation/visual-novel/works`
|
||||
- `/api/runtime/visual-novel/gallery`
|
||||
- `/api/runtime/visual-novel/works/{profile_id}/runs`
|
||||
- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`
|
||||
- `/api/runtime/visual-novel/runs/{run_id}/history`
|
||||
- `/api/runtime/visual-novel/runs/{run_id}/regenerate`
|
||||
- `/api/profile/save-archives`
|
||||
- `/api/profile/save-archives/{world_key}`
|
||||
- `/api/runtime/save/snapshot`
|
||||
|
||||
本次实测:
|
||||
|
||||
- `npm run api-server` 可启动 Rust `api-server`。
|
||||
- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。
|
||||
- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。
|
||||
|
||||
## 前端关键路径
|
||||
|
||||
- 创作工作台:`VisualNovelAgentWorkspace`
|
||||
- 结果页:`VisualNovelResultView`
|
||||
- 运行时:`VisualNovelRuntimeShell`
|
||||
- 运行时 SSE:`visualNovelRuntimeSse` / `visualNovelRuntimeClient`
|
||||
|
||||
## 桌面 / 移动端检查
|
||||
|
||||
- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png`。
|
||||
- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png`。
|
||||
- in-app browser 插件本次未发现可用 IAB backend,截图使用本机 Edge headless 兜底完成。
|
||||
|
||||
## 校验摘要
|
||||
|
||||
- package.json scripts: 通过
|
||||
- api-server visual novel routes: 通过
|
||||
- visual novel runtime client routes: 通过
|
||||
- visual novel runtime client tests: 通过
|
||||
- visual novel SSE tests: 通过
|
||||
- visual novel creation tests: 通过
|
||||
- visual novel result tests: 通过
|
||||
- visual novel runtime tests: 通过
|
||||
|
||||
## 执行命令
|
||||
|
||||
```bash
|
||||
npm run check:visual-novel-vn12 -- --write-report
|
||||
npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
cd server-rs
|
||||
cargo test -p shared-contracts
|
||||
cargo test -p module-visual-novel
|
||||
cargo check -p api-server
|
||||
```
|
||||
|
||||
## 未覆盖风险
|
||||
|
||||
- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。
|
||||
- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。
|
||||
|
||||
BIN
docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png
Normal file
BIN
docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png
Normal file
BIN
docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
1. 产品名称:百梦。
|
||||
2. 产品愿景:百梦AI团队致力于打造AI互动内容UGC平台。
|
||||
3. 产品slogan:每个人都可以在10分钟内轻松创作出一款精品互动作品。
|
||||
3. 产品slogan:不用代码,不用美术,10分钟把脑洞变成有趣的体验。
|
||||
4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。
|
||||
5. 关键技术:Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。
|
||||
6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。
|
||||
@@ -28,16 +28,14 @@
|
||||
|
||||
```text
|
||||
百梦
|
||||
AI互动内容UGC平台
|
||||
把想玩的世界,亲手做出来
|
||||
10分钟做自己的互动内容
|
||||
```
|
||||
|
||||
第二层:远读slogan
|
||||
|
||||
```text
|
||||
每个人都可以在10分钟内
|
||||
轻松创作出一款
|
||||
精品互动作品
|
||||
不用代码,不用美术,
|
||||
10分钟把脑洞变成有趣的体验
|
||||
```
|
||||
|
||||
第三层:产品特点
|
||||
@@ -79,7 +77,7 @@ AI原生游戏框架:
|
||||
|
||||
## 4. 生成方式
|
||||
|
||||
主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成:
|
||||
主视觉底图使用仓库内 `gpt-image-2` 工作流生成;2026-05-09 起同类工作流走 VectorEngine:
|
||||
|
||||
```text
|
||||
model: gpt-image-2
|
||||
@@ -88,6 +86,8 @@ reference image: 百梦气泡共创logo方向图
|
||||
output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png
|
||||
```
|
||||
|
||||
2026-05-08 根据新文案重新调用 `gpt-image-2` 生成新版底图。新版底图在中上部保留更干净的两行 slogan 留白,并在下半部增加轻量内容卡、创作路径和 AI 辅助创作氛围,最终再叠加精确中文排版。
|
||||
|
||||
因为图片模型直接生成中文长文案存在错字风险,最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成:
|
||||
|
||||
```text
|
||||
|
||||
@@ -247,11 +247,11 @@ python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.p
|
||||
--size 1024x1024
|
||||
```
|
||||
|
||||
若继续使用仓库现有 APIMart 路由,则需要配置:
|
||||
若继续使用仓库现有 VectorEngine GPT-image-2 路由,则需要配置:
|
||||
|
||||
```text
|
||||
APIMART_BASE_URL=https://api.apimart.ai/v1
|
||||
APIMART_API_KEY=...
|
||||
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
|
||||
VECTOR_ENGINE_API_KEY=...
|
||||
```
|
||||
|
||||
## 13. 04/07 气泡方向单独优化补充
|
||||
|
||||
529
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
529
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# 儿童动作识别互动玩法 Demo 热身关开发文档
|
||||
|
||||
> 日期:2026-05-09
|
||||
> 适用范围:儿童动作识别互动玩法 Demo 的固定启动热身关
|
||||
> 文档性质:玩法 Demo 开发设计文档
|
||||
> 说明:本文整理当前已确认的热身关内容、体验、流程和热身数据记录要求。
|
||||
|
||||
## 1. 热身关定位
|
||||
|
||||
热身关是 Demo 启动后的固定流程,用于在正式进入后续趣味学习关前完成以下事项:
|
||||
|
||||
- 调用摄像头;
|
||||
- 识别用户和环境;
|
||||
- 引导用户来到建议互动位置;
|
||||
- 教学基础交互方式;
|
||||
- 确认用户可在互动空间内完成左右移动、挥手和跳跃;
|
||||
- 记录用户左右移动距离、挥动手臂空间和跳跃空间,作为后续关卡的空间边界与行为坐标;
|
||||
- 完成后进入关卡选择。
|
||||
|
||||
热身关不接入创作模块,不作为可配置玩法模板提供给创作者。
|
||||
|
||||
## 2. 屏幕与设备适配
|
||||
|
||||
本产品适用于电视屏幕、电脑屏幕等环境。
|
||||
|
||||
热身关制作表达使用横屏比例。
|
||||
|
||||
## 3. 画面基础表现
|
||||
|
||||
用户进入热身关后,摄像头被调用,并开始识别用户和环境。
|
||||
|
||||
画面基础表现如下:
|
||||
|
||||
1. 在屏幕中央位置的地面生成预设的绿色圆环,作为建议位置的指引。
|
||||
2. 将用户的实际位置生成角色剪影,作为用户在画面中的标识。
|
||||
3. 只对摄像头背景做虚化处理,用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。
|
||||
|
||||
## 4. 通用检测与引导规则
|
||||
|
||||
### 4.1 不允许跳过
|
||||
|
||||
热身关每个步骤都必须由用户完成,不允许跳过,也不允许系统自动进入下一步。
|
||||
|
||||
### 4.2 引导动画播放规则
|
||||
|
||||
每个动作等待 3 秒后可以播放引导动画。
|
||||
|
||||
当前不设置最长等待时间。
|
||||
|
||||
### 4.3 绿色圆环完成规则
|
||||
|
||||
用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。
|
||||
|
||||
用户需要在绿色圆环内保持停留 2 秒,才算完成该圆环位置检测。
|
||||
|
||||
### 4.4 左右距离映射规则
|
||||
|
||||
“约半米”的左右移动距离,技术上以角色剪影移动距离为准。
|
||||
|
||||
该距离后续会根据实际体验继续调校。
|
||||
|
||||
### 4.5 手势区分规则
|
||||
|
||||
招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。
|
||||
|
||||
手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
### 4.6 手势引导规则
|
||||
|
||||
挥动哪只手,就使用对应手的引导。
|
||||
|
||||
## 5. 热身关完整流程
|
||||
|
||||
### 5.1 进入热身关
|
||||
|
||||
#### 画面表现
|
||||
|
||||
- 摄像头被调用。
|
||||
- 系统识别用户和环境。
|
||||
- 屏幕中央位置的地面出现预设绿色圆环。
|
||||
- 用户实际位置以角色剪影形式显示。
|
||||
- 只对摄像头背景做虚化处理,保留空间感。
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
屏幕中上方浮现文字,同时语音播报:
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
```
|
||||
|
||||
随后继续播报:
|
||||
|
||||
```text
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
```
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达屏幕中央绿色圆环位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成中央圆环位置检测后:
|
||||
|
||||
- 播放圆圈消失特效;
|
||||
- 进入招手手势教学步骤。
|
||||
|
||||
---
|
||||
|
||||
### 5.2 招手教学
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放招手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成招手 / 摆手手势。
|
||||
|
||||
该动作与后续挥动左手、挥动右手需要有动作区分,但仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成招手 / 摆手手势后,进入下一步。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 热身说明
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
```
|
||||
|
||||
播放完成后进入左右移动热身步骤。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 向左一步
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
向左一步
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
屏幕中心向左一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||
|
||||
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达该绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。
|
||||
|
||||
完成后进入“回到中间来”。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 回到中间来(一)
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
完成后进入“向右一步”。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 向右一步
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
向右一步
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
屏幕中心向右一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||
|
||||
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达该绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。
|
||||
|
||||
完成后进入“回到中间来”。
|
||||
|
||||
---
|
||||
|
||||
### 5.7 回到中间来(二)
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
完成后进入左手挥动教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.8 挥动左手
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
挥动左手
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放伸展手臂挥动左手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成挥动左手手势。
|
||||
|
||||
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
完成后进入右手挥动教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.9 挥动右手
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
挥动右手
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放伸展手臂挥动右手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成挥动右手手势。
|
||||
|
||||
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
完成后进入跳跃教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.10 原地跳一下
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
原地跳一下
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放跳跃姿势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成跳跃姿势。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后:
|
||||
|
||||
- 记录用户跳跃空间,保存为该用户对应的行为坐标;
|
||||
- 播放热身结束特效、上浮字幕和语音:
|
||||
|
||||
```text
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
```
|
||||
|
||||
随后继续播放:
|
||||
|
||||
```text
|
||||
别走开,现在开始我们的游戏吧
|
||||
```
|
||||
|
||||
热身关结束,进入关卡选择。
|
||||
|
||||
## 6. 流程状态表
|
||||
|
||||
| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 |
|
||||
|---:|---|---|---|---|---|
|
||||
| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;请你来到圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户角色剪影;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 |
|
||||
| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 |
|
||||
| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 |
|
||||
| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 |
|
||||
| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||
| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 |
|
||||
| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||
| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 |
|
||||
| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间 |
|
||||
| 10 | 原地跳一下 | 原地跳一下 | 跳跃姿势引导;等待 3 秒可播放引导动画 | 跳跃姿势 | 记录跳跃空间;真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧;进入关卡选择 |
|
||||
|
||||
## 7. 固定文案与语音清单
|
||||
|
||||
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
向左一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
向右一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
挥动左手
|
||||
真棒
|
||||
挥动右手
|
||||
真棒
|
||||
原地跳一下
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
别走开,现在开始我们的游戏吧
|
||||
```
|
||||
|
||||
## 8. 需要开发支持的识别能力
|
||||
|
||||
热身关当前流程需要支持以下识别能力:
|
||||
|
||||
1. 摄像头调用;
|
||||
2. 用户识别;
|
||||
3. 环境识别;
|
||||
4. 用户实际位置识别;
|
||||
5. 用户是否到达中央绿色圆环位置;
|
||||
6. 用户是否在绿色圆环内持续保持 2 秒;
|
||||
7. 用户是否到达左侧约半米绿色圆环位置;
|
||||
8. 用户是否到达右侧约半米绿色圆环位置;
|
||||
9. 招手 / 摆手手势识别;
|
||||
10. 挥动左手识别;
|
||||
11. 挥动右手识别;
|
||||
12. 原地跳跃姿势识别;
|
||||
13. 用户左右移动距离记录;
|
||||
14. 用户挥动手臂空间记录;
|
||||
15. 用户跳跃空间记录。
|
||||
|
||||
## 9. 需要开发支持的表现能力
|
||||
|
||||
热身关当前流程需要支持以下表现能力:
|
||||
|
||||
1. 横屏比例显示;
|
||||
2. 摄像头背景虚化;
|
||||
3. 用户位置生成角色剪影;
|
||||
4. 屏幕中央地面绿色圆环;
|
||||
5. 左侧约半米地面绿色圆环;
|
||||
6. 右侧约半米地面绿色圆环;
|
||||
7. 绿色圆环 2 秒选中状态;
|
||||
8. 圆圈消失特效;
|
||||
9. 招手手势引导;
|
||||
10. 伸展手臂挥动左手手势引导;
|
||||
11. 伸展手臂挥动右手手势引导;
|
||||
12. 跳跃姿势引导;
|
||||
13. 热身结束特效;
|
||||
14. 上浮字幕;
|
||||
15. 语音播报。
|
||||
|
||||
## 10. 热身数据记录要求
|
||||
|
||||
热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。
|
||||
|
||||
### 10.1 左右空间边界
|
||||
|
||||
用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。
|
||||
|
||||
用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。
|
||||
|
||||
后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||
|
||||
后续关卡中,当用户身体主体超出安全边界线时:
|
||||
|
||||
1. 关卡内容暂停;
|
||||
2. 屏幕虚化;
|
||||
3. 屏幕中央地面出现绿色圆圈;
|
||||
4. 屏幕提示文案:
|
||||
|
||||
```text
|
||||
小朋友,要注意安全哦
|
||||
```
|
||||
|
||||
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
|
||||
|
||||
### 10.2 手臂挥动空间
|
||||
|
||||
用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
### 10.3 跳跃空间
|
||||
|
||||
用户完成原地跳一下后,记录用户跳跃空间,保存为该用户对应的行为坐标。
|
||||
|
||||
## 11. 热身关完成条件
|
||||
|
||||
热身关完成条件为用户按顺序完成以下流程:
|
||||
|
||||
1. 到达中央圆环位置并保持 2 秒;
|
||||
2. 完成招手 / 摆手手势;
|
||||
3. 到达左侧约半米圆环位置并保持 2 秒;
|
||||
4. 记录左侧空间边界;
|
||||
5. 回到中央圆环位置并保持 2 秒;
|
||||
6. 到达右侧约半米圆环位置并保持 2 秒;
|
||||
7. 记录右侧空间边界;
|
||||
8. 回到中央圆环位置并保持 2 秒;
|
||||
9. 完成挥动左手;
|
||||
10. 记录左手挥动空间;
|
||||
11. 完成挥动右手;
|
||||
12. 记录右手挥动空间;
|
||||
13. 完成原地跳一下;
|
||||
14. 记录跳跃空间;
|
||||
15. 播放热身结束特效和结束语音;
|
||||
16. 进入关卡选择。
|
||||
|
||||
## 12. 数据保存方式
|
||||
|
||||
左右空间边界、手臂挥动空间、跳跃空间仅在当前 Demo 体验会话内保存。
|
||||
|
||||
这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。
|
||||
|
||||
采用仅当前 Demo 体验会话内保存的原因:
|
||||
|
||||
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
|
||||
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
|
||||
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
|
||||
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
|
||||
|
||||
## 13. 后续待确认事项
|
||||
|
||||
当前暂无待确认事项。
|
||||
@@ -0,0 +1,117 @@
|
||||
# 寓教于乐发现页临时入口设计
|
||||
|
||||
> 日期:2026-05-09
|
||||
> 适用范围:平台入口发现页、儿童动作识别娱乐教育内容线临时入口
|
||||
> 文档性质:产品与前端落地边界
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为儿童动作识别娱乐教育内容线提供一个临时入口。
|
||||
|
||||
入口放置在平台“发现”页面内,作为独立标签展示,标签名称为:
|
||||
|
||||
```text
|
||||
寓教于乐
|
||||
```
|
||||
|
||||
后续生产的该内容线模板和游戏关卡,都放置在“寓教于乐”独立标签下。
|
||||
|
||||
该内容线当前只覆盖儿童动作识别 Demo 内容。后续创作环节需要继续对该板块内容做区分和独立管理,不把普通公开作品仅凭近似教育题材自动归入本板块。
|
||||
|
||||
## 2. 展示边界
|
||||
|
||||
寓教于乐内容不直接展示在以下位置:
|
||||
|
||||
1. 推荐页;
|
||||
2. 发现页的推荐标签;
|
||||
3. 发现页的今日标签;
|
||||
4. 发现页的分类标签;
|
||||
5. 发现页的排行标签;
|
||||
6. 发现页搜索结果。
|
||||
|
||||
寓教于乐内容只在“发现 / 寓教于乐”标签下展示。
|
||||
|
||||
“寓教于乐”标签在发现页频道列表中放在最后,桌面端和移动端都显示。移动端访问该内容线的动作识别 Demo 时,需要提示横屏体验。
|
||||
|
||||
## 3. 开关规则
|
||||
|
||||
该入口需要支持灵活开关。
|
||||
|
||||
开关打开时:
|
||||
|
||||
1. 发现页显示“寓教于乐”标签;
|
||||
2. “寓教于乐”标签下展示该内容线内容;
|
||||
3. 该内容线内容仍不进入推荐、今日、分类、排行和搜索结果。
|
||||
|
||||
开关关闭时:
|
||||
|
||||
1. 发现页隐藏“寓教于乐”标签;
|
||||
2. 隐藏“寓教于乐”标签下内容;
|
||||
3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果;
|
||||
4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。
|
||||
|
||||
## 4. 内容识别规则
|
||||
|
||||
临时阶段使用作品标签识别寓教于乐内容。
|
||||
|
||||
当公开作品标签中存在一个精确等于以下文本的标签:
|
||||
|
||||
```text
|
||||
寓教于乐
|
||||
```
|
||||
|
||||
则该作品归入寓教于乐内容线。
|
||||
|
||||
识别规则为精确匹配,不做包含匹配,不兼容空格、大小写变体或同义标签,例如“教育”“儿童教育”“动作教育”都不视为寓教于乐内容。
|
||||
|
||||
关闭开关时,即使作品具备精确的“寓教于乐”标签,也不允许通过任何平台公开展示入口或搜索入口访问。
|
||||
|
||||
## 5. 技术落地边界
|
||||
|
||||
本次只做前端入口和前端展示过滤,不新增后端接口。
|
||||
|
||||
前端通过功能开关控制入口显隐。
|
||||
|
||||
开关环境变量:
|
||||
|
||||
```text
|
||||
VITE_ENABLE_EDUTAINMENT_ENTRY
|
||||
```
|
||||
|
||||
默认开启。
|
||||
|
||||
当该变量显式配置为以下值时,入口关闭:
|
||||
|
||||
```text
|
||||
false
|
||||
0
|
||||
off
|
||||
no
|
||||
```
|
||||
|
||||
## 6. 验收点
|
||||
|
||||
1. 开关打开时,发现页显示“寓教于乐”标签。
|
||||
2. 开关关闭时,发现页不显示“寓教于乐”标签。
|
||||
3. 带有“寓教于乐”标签的公开作品不进入推荐页。
|
||||
4. 带有“寓教于乐”标签的公开作品不进入发现页推荐、今日、分类、排行和搜索结果。
|
||||
5. 带有“寓教于乐”标签的公开作品只在“发现 / 寓教于乐”标签下展示。
|
||||
6. “寓教于乐”标签位于发现页频道列表最后,桌面端和移动端均可见。
|
||||
7. 开关关闭后,带有“寓教于乐”标签的公开作品不可通过作品号搜索、公开详情深链或浏览历史入口打开。
|
||||
8. 标签识别只接受精确等于“寓教于乐”的作品标签,近似标签不归入该内容线。
|
||||
|
||||
## 7. 待补充事项
|
||||
|
||||
“寓教于乐”标签下暂无内容时的空状态文案待定。落地时可先复用平台现有空状态组件,但不新增功能说明类长文案。
|
||||
|
||||
## 8. 第 1-2 项工程落地状态
|
||||
|
||||
第 1 项“发现页入口与过滤”和第 2 项“搜索 / 深链 / 历史入口拦截”已进入前端落地阶段,当前实现口径如下:
|
||||
|
||||
1. 入口开关由 `VITE_ENABLE_EDUTAINMENT_ENTRY` 控制,默认开启,显式配置 `false`、`0`、`off`、`no` 时关闭。
|
||||
2. 内容识别集中在 `src/components/platform-entry/platformEdutainmentVisibility.ts`,只读取公开作品原始 `themeTags`,且只接受精确等于“寓教于乐”的标签。
|
||||
3. `src/components/rpg-entry/RpgEntryHomeView.tsx` 已在发现页频道末尾追加“寓教于乐”频道,并将该类作品从推荐、今日、分类、排行、搜索、本地搜索兜底和桌面推荐模块中过滤。
|
||||
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper,避免推荐运行态自动启动寓教于乐作品,并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。
|
||||
5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。
|
||||
6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。
|
||||
7. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`src/components/platform-entry/platformEdutainmentVisibility.test.ts` 和 `src/routing/appRoutes.test.ts`,包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截和精确标签识别。
|
||||
@@ -28,3 +28,13 @@
|
||||
- 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。
|
||||
- RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。
|
||||
- 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。
|
||||
|
||||
## 2026-05-07 玩法参考图
|
||||
|
||||
1. 每个玩法入口都必须配置一张 `public/creation-type-references/` 下的参考图。
|
||||
2. 当前创作 Tab 顶部玩法卡带、旧创作中心卡带和玩法类型弹层都消费同一份 `PLATFORM_CREATION_TYPES.imageSrc`,避免多入口视觉漂移。
|
||||
3. 图片只承担玩法识别和氛围锚定,不在卡片上叠加规则说明文案。
|
||||
4. 移动端卡片仍以紧凑横滑为主,参考图使用暗色遮罩承接标题,文本不得溢出卡片。
|
||||
5. 当前创作 Tab 的可见玩法卡必须真实渲染 `img`,不能只在隐藏弹窗或旧入口中配置图片。
|
||||
6. 参考图卡片上的标题和副标题必须显式使用白色文字,并配合底部加深渐变与文字阴影;禁止依赖 `text-inherit`,避免黑字叠在暗蒙版上。
|
||||
7. 当前创作 Tab 顶部不再保留“10分钟创作一个精品互动玩法”标题,玩法参考图卡带直接作为首屏入口;移动端卡带必须支持横向拖动滑动。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user