Compare commits
3 Commits
master
...
fix/jump-h
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bdf84dc0d | |||
| 09ef80cd23 | |||
| 95df62fc82 |
@@ -1,151 +1,229 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-cli
|
name: spacetimedb-cli
|
||||||
description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers
|
||||||
|
triggers:
|
||||||
|
- spacetime init
|
||||||
|
- spacetime build
|
||||||
|
- spacetime publish
|
||||||
|
- spacetime dev
|
||||||
|
- spacetime sql
|
||||||
|
- spacetime call
|
||||||
|
- spacetime logs
|
||||||
|
- spacetime server
|
||||||
|
- spacetime login
|
||||||
|
- spacetime generate
|
||||||
|
- how do I use the CLI
|
||||||
|
- CLI command
|
||||||
---
|
---
|
||||||
|
|
||||||
# SpacetimeDB CLI
|
# SpacetimeDB CLI
|
||||||
|
|
||||||
Use this skill when working with the `spacetime` CLI in Genarrative. Prefer repository scripts when they exist, and keep every operation pinned to an explicit target server or local process.
|
Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues.
|
||||||
|
|
||||||
## Genarrative Rules
|
## Quick Reference
|
||||||
|
|
||||||
- Do not rely on the default SpacetimeDB cloud target. Pass `--server` or `--server-url` explicitly in scripts, docs, smoke tests, and manual troubleshooting.
|
### Project Initialization & Development
|
||||||
- Do not introduce `maincloud` / `MAINCLOUD` commands, env vars, or docs. Treat old references as historical residue.
|
|
||||||
- Do not use `spacetime --root-dir` in manual commands or docs. Use project scripts, `--data-dir`, explicit `--server`, or the configured running service.
|
|
||||||
- For repository version upgrades, update `server-rs/Cargo.toml` exact pins, regenerate bindings, and verify the actual CLI/runtime version. Do not treat a local CLI reinstall as a repo upgrade.
|
|
||||||
- For host upgrades, verify the running service binary, not just shell PATH: `systemctl show ... MainPID` -> `/proc/$pid/exe --version` -> `/v1/ping`.
|
|
||||||
|
|
||||||
## Core Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Initialize new project
|
||||||
|
spacetime init my-project --lang rust|csharp|typescript|cpp
|
||||||
|
spacetime init my-project --template <template-id>
|
||||||
|
|
||||||
# Build module
|
# Build module
|
||||||
spacetime build
|
spacetime build # release build
|
||||||
spacetime build --debug
|
spacetime build --debug # faster iteration, slower runtime
|
||||||
|
|
||||||
# Publish to an explicit server
|
# Dev mode (auto-rebuild, auto-publish, generates bindings)
|
||||||
spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients
|
spacetime dev
|
||||||
|
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
|
||||||
|
|
||||||
# Destructive publish only when explicitly intended
|
# Generate client bindings
|
||||||
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=always --yes=delete-data,migrate
|
|
||||||
|
|
||||||
# Delete data only for breaking schema conflicts
|
|
||||||
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
|
||||||
|
|
||||||
# Generate bindings
|
|
||||||
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
|
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
|
||||||
```
|
```
|
||||||
|
|
||||||
## Genarrative Local Workflow
|
### Publishing & Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prefer project wrappers
|
# Publish to an explicit server
|
||||||
npm run dev:spacetime
|
spacetime publish my-database --server http://127.0.0.1:3101 --yes
|
||||||
npm run dev:api-server
|
|
||||||
npm run spacetime:generate
|
|
||||||
|
|
||||||
# Query local database
|
# Publish to local server
|
||||||
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players"
|
spacetime publish my-database --server local --yes
|
||||||
|
|
||||||
# Logs
|
# Clear database and republish
|
||||||
spacetime logs my-db --server http://127.0.0.1:3101 -f
|
spacetime publish my-database --clear-database --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Interaction
|
### Database Interaction
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SQL / describe
|
# SQL queries
|
||||||
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM users"
|
spacetime sql my-database "SELECT * FROM users"
|
||||||
spacetime describe my-db --server http://127.0.0.1:3101 --json
|
spacetime sql my-database --interactive # REPL mode
|
||||||
spacetime describe my-db table users --server http://127.0.0.1:3101 --json
|
|
||||||
|
|
||||||
# Reducer/procedure calls. Arguments are positional JSON values.
|
# Call reducers
|
||||||
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
|
spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}'
|
||||||
|
|
||||||
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
|
# Subscribe to changes
|
||||||
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
|
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
|
||||||
|
|
||||||
# Subscribe from CLI
|
# View logs
|
||||||
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
|
spacetime logs my-database -f # follow logs
|
||||||
|
spacetime logs my-database -n 100 # up to 100 log lines
|
||||||
|
|
||||||
|
# Describe schema
|
||||||
|
spacetime describe my-database --json
|
||||||
|
spacetime describe my-database table users --json
|
||||||
|
spacetime describe my-database reducer my_reducer --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server & Auth
|
### Database Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# List databases
|
||||||
|
spacetime list
|
||||||
|
|
||||||
|
# Delete database
|
||||||
|
spacetime delete my-database
|
||||||
|
|
||||||
|
# Rename database
|
||||||
|
spacetime rename <database-identity> --to new-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List configured servers
|
||||||
spacetime server list
|
spacetime server list
|
||||||
spacetime server add local --url http://localhost:3000 --default
|
|
||||||
spacetime server add genarrative-dev --url http://127.0.0.1:3101
|
|
||||||
spacetime server ping genarrative-dev
|
|
||||||
|
|
||||||
|
# Add server
|
||||||
|
spacetime server add local --url http://localhost:3000 --default
|
||||||
|
spacetime server add myserver --url https://my-spacetime.example.com
|
||||||
|
|
||||||
|
# Set default server
|
||||||
|
spacetime server set-default local
|
||||||
|
|
||||||
|
# Test connectivity
|
||||||
|
spacetime server ping local
|
||||||
|
|
||||||
|
# Start local instance
|
||||||
|
spacetime start
|
||||||
|
|
||||||
|
# Clear local data
|
||||||
|
spacetime server clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login (opens browser)
|
||||||
spacetime login
|
spacetime login
|
||||||
|
|
||||||
|
# Login with token
|
||||||
spacetime login --token <token>
|
spacetime login --token <token>
|
||||||
|
|
||||||
|
# Show login status
|
||||||
spacetime login show
|
spacetime login show
|
||||||
|
|
||||||
|
# Logout
|
||||||
spacetime logout
|
spacetime logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Version & Runtime Verification
|
## Default Servers
|
||||||
|
|
||||||
|
| Name | URL | Description |
|
||||||
|
|------|-----|-------------|
|
||||||
|
| `local` | `http://127.0.0.1:3000` | Local development server |
|
||||||
|
| `dev` | `http://127.0.0.1:3101` | Genarrative local development server |
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### New Project Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CLI resolution can be misleading; compare all candidates when diagnosing.
|
# 1. Login
|
||||||
type -a spacetime
|
spacetime login
|
||||||
spacetime --version
|
|
||||||
spacetime version list
|
|
||||||
|
|
||||||
# Verify a systemd service binary actually changed.
|
# 2. Create project
|
||||||
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
spacetime init my-game --lang rust
|
||||||
readlink -f "/proc/${pid}/exe"
|
cd my-game
|
||||||
"/proc/${pid}/exe" --version
|
|
||||||
curl -fsS http://127.0.0.1:3101/v1/ping
|
# 3. Start dev mode (auto-rebuilds and publishes)
|
||||||
|
spacetime dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flags
|
### Local Development
|
||||||
|
|
||||||
| Flag | Description |
|
```bash
|
||||||
|------|-------------|
|
# Start local server (in separate terminal)
|
||||||
| `--server`, `-s` | Target server nickname, host, or URL |
|
spacetime start
|
||||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values |
|
|
||||||
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
|
# Publish to local
|
||||||
| `--module-path`, `-p` | Module project path |
|
spacetime publish my-db --server local --clear-database --yes
|
||||||
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
|
|
||||||
| `--no-config` | Ignore `spacetime.json` |
|
# Query local database
|
||||||
| `--env` | Select config file layering environment |
|
spacetime sql my-db --server local "SELECT * FROM players"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Client Bindings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After building module
|
||||||
|
spacetime build
|
||||||
|
spacetime generate --lang typescript --out-dir ./client/src/bindings --module-path .
|
||||||
|
|
||||||
|
# Or use dev mode which auto-generates
|
||||||
|
spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Flags
|
||||||
|
|
||||||
|
| Flag | Short | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `--server` | `-s` | Target server (nickname, hostname, or URL) |
|
||||||
|
| `--yes` | `-y` | Non-interactive mode (skip confirmations) |
|
||||||
|
| `--anonymous` | | Use anonymous identity |
|
||||||
|
| `--module-path` | `-p` | Path to module project |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Not Logged In
|
### "Not logged in"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime login
|
spacetime login
|
||||||
|
# Or use --anonymous for public operations
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server Not Responding
|
### "Server not responding"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime server ping <server>
|
spacetime server ping <server>
|
||||||
curl -fsS http://127.0.0.1:3101/v1/ping
|
# For local: ensure spacetime start is running
|
||||||
```
|
```
|
||||||
|
|
||||||
For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`.
|
### "Schema conflict"
|
||||||
|
|
||||||
### Schema Conflict
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
# Clear data and republish
|
||||||
|
spacetime publish my-db --clear-database --yes
|
||||||
|
# Clear data and republish only when conflict
|
||||||
|
spacetime publish my-db --clear-database=on-conflict --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `--delete-data=always` only with explicit approval.
|
### "Build failed"
|
||||||
|
|
||||||
### Version Mismatch
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rg -n 'spacetimedb' server-rs/Cargo.toml
|
# Check Rust/C# toolchain
|
||||||
spacetime --version
|
rustup show
|
||||||
spacetime version list
|
# For Rust modules, ensure wasm32-unknown-unknown target
|
||||||
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
rustup target add wasm32-unknown-unknown
|
||||||
"/proc/${pid}/exe" --version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Module Languages
|
||||||
|
|
||||||
|
**Server-side (modules):** Rust, C#, TypeScript, C++
|
||||||
|
**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine
|
||||||
|
**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
|
- Many commands are marked UNSTABLE and may change
|
||||||
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
|
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default
|
||||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
|
- Use `--yes` flag in scripts to avoid interactive prompts
|
||||||
|
- Dev mode watches files and auto-rebuilds on changes
|
||||||
|
|||||||
@@ -1,105 +1,345 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-concepts
|
name: spacetimedb-concepts
|
||||||
description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: clockworklabs
|
||||||
|
version: "2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SpacetimeDB Core Concepts
|
# SpacetimeDB Core Concepts
|
||||||
|
|
||||||
SpacetimeDB is a relational database that also executes application logic in uploaded modules. In Genarrative, it is the data and transaction layer behind `server-rs + Axum + SpacetimeDB`, not a replacement for the `api-server` BFF or external platform adapters.
|
SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely.
|
||||||
|
|
||||||
## Genarrative Boundaries
|
---
|
||||||
|
|
||||||
- Domain rules live in `module-*`.
|
## Critical Rules (Read First)
|
||||||
- SpacetimeDB tables, reducers, procedures, migrations, row mappers, and read models live in `spacetime-module`.
|
|
||||||
- Backend access goes through `spacetime-client` facades.
|
|
||||||
- HTTP/SSE/BFF and external orchestration stay in `api-server`.
|
|
||||||
- External side effects stay in `platform-*`.
|
|
||||||
- Frontend renders backend truth and must not bypass BFF/projections to invent formal business state.
|
|
||||||
|
|
||||||
## Critical Rules
|
These five rules prevent the most common SpacetimeDB mistakes:
|
||||||
|
|
||||||
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
|
1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data.
|
||||||
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
|
2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables.
|
||||||
3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries.
|
||||||
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
|
4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
|
||||||
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
|
5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization.
|
||||||
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
|
|
||||||
|
|
||||||
## Tables
|
---
|
||||||
|
|
||||||
- Private tables are the default; only reducers/procedures and database owners can access them.
|
## Feature Implementation Checklist
|
||||||
- Public tables are exposed to clients through subscriptions. Writes still go through reducers/procedures.
|
|
||||||
- Organize data by access pattern when bandwidth or update frequency differs.
|
|
||||||
- Existing persistent tables in Genarrative are conservative: no rename, delete, reorder, or type changes without a user-approved migration plan.
|
|
||||||
|
|
||||||
## Reducers
|
When implementing a feature that spans backend and client:
|
||||||
|
|
||||||
Reducers are deterministic transactional functions. They are the primary client-invoked mutation path.
|
1. **Backend:** Define table(s) to store the data
|
||||||
|
2. **Backend:** Define reducer(s) to mutate the data
|
||||||
|
3. **Client:** Subscribe to the table(s)
|
||||||
|
4. **Client:** Call the reducer(s) from UI — **do not skip this step**
|
||||||
|
5. **Client:** Render the data from the table(s)
|
||||||
|
|
||||||
- No global mutable state.
|
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||||
- No filesystem, network, timers, or non-deterministic RNG.
|
|
||||||
- Return `Result<(), String>` for expected sender-visible errors.
|
|
||||||
- Use `ctx.sender()` for authorization.
|
|
||||||
- Store persistent state in tables.
|
|
||||||
|
|
||||||
## Procedures
|
---
|
||||||
|
|
||||||
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
|
||||||
|
|
||||||
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
|
|
||||||
|
|
||||||
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
|
|
||||||
|
|
||||||
## Views
|
|
||||||
|
|
||||||
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
|
||||||
|
|
||||||
## Event Tables
|
|
||||||
|
|
||||||
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
|
|
||||||
|
|
||||||
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
|
||||||
|
|
||||||
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
|
||||||
|
|
||||||
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
|
||||||
|
|
||||||
## Subscriptions
|
|
||||||
|
|
||||||
1. Subscribe to SQL queries or generated table/query builders.
|
|
||||||
2. Receive initial matching rows.
|
|
||||||
3. Receive updates when subscribed rows change.
|
|
||||||
4. Render from subscribed data, not reducer return values.
|
|
||||||
|
|
||||||
Best practices:
|
|
||||||
|
|
||||||
- Group subscriptions by lifetime.
|
|
||||||
- Subscribe to new data before unsubscribing old data during transitions.
|
|
||||||
- Avoid overlapping queries that duplicate row delivery.
|
|
||||||
- Use indexes for subscribed filters.
|
|
||||||
|
|
||||||
## 2.2.0 to 2.5.0 Delta
|
|
||||||
|
|
||||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
|
||||||
|
|
||||||
- **2.2.0**: v3 WebSocket transport and TS SDK default, safer production operations (`lock`/`unlock`, safer `delete`, better `publish --yes`), TS React `useProcedure`, table clearing APIs, empty-table drop automigration, primary-key migration fixes, bytes-key B-tree support, durability hardening.
|
|
||||||
- **2.3.0**: first-party Godot SDK, more WebSocket pipelining/batching, HTTP/2 backend support, Vue `useProcedure`, Unity 6 WebGL support, commitlog compression/throughput improvements, Rust `DbContext` generics, `ReducerContext::identity` deprecated in favor of `database_identity`, connection lifecycle and unsubscribe fixes.
|
|
||||||
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
|
|
||||||
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
|
|
||||||
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
|
||||||
|
|
||||||
## Debugging Checklist
|
## Debugging Checklist
|
||||||
|
|
||||||
1. Is the Genarrative SpacetimeDB server running? Use `npm run dev:spacetime` locally or host-local `systemctl`.
|
When things are not working:
|
||||||
2. Is the module published to the same server the API uses?
|
|
||||||
3. Are generated bindings current? Use `npm run spacetime:generate`.
|
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||||
4. Is `api-server` using the same database and token?
|
2. Is the module published? (`spacetime publish`)
|
||||||
5. Is the reducer/procedure actually called?
|
3. Are client bindings generated? (`spacetime generate`)
|
||||||
6. Did `/healthz` / `/readyz` pass while business SpacetimeDB calls still timeout? Inspect API logs and public route behavior.
|
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||||
|
5. **Is the reducer actually being called from the client?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime start
|
||||||
|
spacetime publish <db-name> --module-path <module-path>
|
||||||
|
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||||
|
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||||
|
spacetime logs <db-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What SpacetimeDB Is
|
||||||
|
|
||||||
|
SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency.
|
||||||
|
|
||||||
|
Key characteristics:
|
||||||
|
|
||||||
|
- **In-memory execution**: Application state is served from memory for very low-latency access
|
||||||
|
- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability
|
||||||
|
- **Real-time synchronization**: Changes are automatically pushed to subscribed clients
|
||||||
|
- **Single deployment**: No separate servers, containers, or infrastructure to manage
|
||||||
|
|
||||||
|
## The Five Zen Principles
|
||||||
|
|
||||||
|
1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize.
|
||||||
|
2. **Everything is Persistent**: SpacetimeDB persists state by default (for example via WAL-backed durability).
|
||||||
|
3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically.
|
||||||
|
4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back.
|
||||||
|
5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions.
|
||||||
|
|
||||||
|
### Defining Tables
|
||||||
|
|
||||||
|
Tables are defined using language-specific attributes. In 2.0, use `accessor` (not `name`) for the API name:
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::table(accessor = player, public)]
|
||||||
|
pub struct Player {
|
||||||
|
#[primary_key]
|
||||||
|
#[auto_inc]
|
||||||
|
id: u32,
|
||||||
|
#[index(btree)]
|
||||||
|
name: String,
|
||||||
|
#[unique]
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C#:**
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||||
|
public partial struct Player
|
||||||
|
{
|
||||||
|
[SpacetimeDB.PrimaryKey]
|
||||||
|
[SpacetimeDB.AutoInc]
|
||||||
|
public uint Id;
|
||||||
|
[SpacetimeDB.Index.BTree]
|
||||||
|
public string Name;
|
||||||
|
[SpacetimeDB.Unique]
|
||||||
|
public string Email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
const players = table(
|
||||||
|
{ name: 'players', public: true },
|
||||||
|
{
|
||||||
|
id: t.u32().primaryKey().autoInc(),
|
||||||
|
name: t.string().index('btree'),
|
||||||
|
email: t.string().unique(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table Visibility
|
||||||
|
|
||||||
|
- **Private tables** (default): Only accessible by reducers and the database owner
|
||||||
|
- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers.
|
||||||
|
|
||||||
|
### Table Design Principles
|
||||||
|
|
||||||
|
Organize data by access pattern, not by entity:
|
||||||
|
|
||||||
|
**Decomposed approach (recommended):**
|
||||||
|
```
|
||||||
|
Player PlayerState PlayerStats
|
||||||
|
id <-- player_id player_id
|
||||||
|
name position_x total_kills
|
||||||
|
position_y total_deaths
|
||||||
|
velocity_x play_time
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits: reduced bandwidth, cache efficiency, schema evolution, semantic clarity.
|
||||||
|
|
||||||
|
## Reducers
|
||||||
|
|
||||||
|
Reducers are transactional functions that modify database state. They are the primary client-invoked mutation path; procedures can also mutate tables by running explicit transactions.
|
||||||
|
|
||||||
|
### Key Properties
|
||||||
|
|
||||||
|
- **Transactional**: Run in isolated database transactions
|
||||||
|
- **Atomic**: Either all changes succeed or all roll back
|
||||||
|
- **Isolated**: Cannot interact with the outside world (no network, no filesystem)
|
||||||
|
- **Callable**: Clients invoke reducers as remote procedure calls
|
||||||
|
|
||||||
|
### Critical Reducer Rules
|
||||||
|
|
||||||
|
1. **No global state**: Relying on static variables is undefined behavior
|
||||||
|
2. **No side effects**: Reducers cannot make network requests or access files
|
||||||
|
3. **Store state in tables**: All persistent state must be in tables
|
||||||
|
4. **No return data**: Reducers do not return data to callers — use subscriptions
|
||||||
|
5. **Must be deterministic**: No random, no timers, no external I/O
|
||||||
|
|
||||||
|
### Defining Reducers
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
ctx.db.user().insert(User { id: 0, name, email });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C#:**
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void CreateUser(ReducerContext ctx, string name, string email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
throw new ArgumentException("Name cannot be empty");
|
||||||
|
ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ReducerContext
|
||||||
|
|
||||||
|
Every reducer receives a `ReducerContext` providing:
|
||||||
|
- **Database**: `ctx.db` (Rust field, TS property) / `ctx.Db` (C# property)
|
||||||
|
- **Sender**: `ctx.sender()` (Rust method) / `ctx.Sender` (C# property) / `ctx.sender` (TS property)
|
||||||
|
- **Connection ID**: `ctx.connection_id()` (Rust method) / `ctx.ConnectionId` (C# property) / `ctx.connectionId` (TS property)
|
||||||
|
- **Timestamp**: `ctx.timestamp` (Rust field, TS property) / `ctx.Timestamp` (C# property)
|
||||||
|
|
||||||
|
## Event Tables (2.0)
|
||||||
|
|
||||||
|
Event tables are the preferred way to broadcast reducer-specific data to clients.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[table(accessor = damage_event, public, event)]
|
||||||
|
pub struct DamageEvent {
|
||||||
|
pub target: Identity,
|
||||||
|
pub amount: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[reducer]
|
||||||
|
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
||||||
|
ctx.db.damage_event().insert(DamageEvent { target, amount });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients subscribe to event tables and use `on_insert` callbacks. Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
|
||||||
|
Subscriptions replicate database rows to clients in real-time.
|
||||||
|
|
||||||
|
### How Subscriptions Work
|
||||||
|
|
||||||
|
1. **Subscribe**: Register SQL queries describing needed data
|
||||||
|
2. **Receive initial data**: All matching rows are sent immediately
|
||||||
|
3. **Receive updates**: Real-time updates when subscribed rows change
|
||||||
|
4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`)
|
||||||
|
|
||||||
|
### Subscription Best Practices
|
||||||
|
|
||||||
|
1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions
|
||||||
|
2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first
|
||||||
|
3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing
|
||||||
|
4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
Modules are WebAssembly bundles containing application logic that runs inside the database.
|
||||||
|
|
||||||
|
### Module Components
|
||||||
|
|
||||||
|
- **Tables**: Define the data schema
|
||||||
|
- **Reducers**: Define callable functions that modify state
|
||||||
|
- **Views**: Define read-only computed queries
|
||||||
|
- **Event Tables**: Broadcast reducer-specific data to clients (2.0)
|
||||||
|
- **Procedures**: (Beta) Functions that can have side effects (HTTP requests)
|
||||||
|
|
||||||
|
### Module Languages
|
||||||
|
|
||||||
|
Server-side modules can be written in: Rust, C#, TypeScript (beta)
|
||||||
|
|
||||||
|
### Module Lifecycle
|
||||||
|
|
||||||
|
1. **Write**: Define tables and reducers in your chosen language
|
||||||
|
2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI
|
||||||
|
3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish`
|
||||||
|
4. **Hot-swap**: Republish to update code without disconnecting clients
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC).
|
||||||
|
|
||||||
|
- **Identity**: A long-lived, globally unique identifier for a user.
|
||||||
|
- **ConnectionId**: Identifies a specific client connection.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn do_something(ctx: &ReducerContext) {
|
||||||
|
let caller_identity = ctx.sender(); // Who is calling?
|
||||||
|
// NEVER trust identity passed as a reducer argument
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Providers
|
||||||
|
|
||||||
|
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
|
||||||
|
|
||||||
|
## When to Use SpacetimeDB
|
||||||
|
|
||||||
|
### Ideal Use Cases
|
||||||
|
|
||||||
|
- **Real-time games**: MMOs, multiplayer games, turn-based games
|
||||||
|
- **Collaborative applications**: Document editing, whiteboards, design tools
|
||||||
|
- **Chat and messaging**: Real-time communication with presence
|
||||||
|
- **Live dashboards**: Streaming analytics and monitoring
|
||||||
|
|
||||||
|
### Key Decision Factors
|
||||||
|
|
||||||
|
Choose SpacetimeDB when you need:
|
||||||
|
- Sub-10ms latency for reads and writes
|
||||||
|
- Automatic real-time synchronization
|
||||||
|
- Transactional guarantees for all operations
|
||||||
|
- Simplified architecture (no separate cache, queue, or server)
|
||||||
|
|
||||||
|
### Less Suitable For
|
||||||
|
|
||||||
|
- **Batch analytics**: Optimized for OLTP, not OLAP
|
||||||
|
- **Large blob storage**: Better suited for structured relational data
|
||||||
|
- **Stateless APIs**: Traditional REST APIs do not need real-time sync
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Authentication check in reducer:**
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
fn admin_action(ctx: &ReducerContext) -> Result<(), String> {
|
||||||
|
let admin = ctx.db.admin().identity().find(&ctx.sender())
|
||||||
|
.ok_or("Not an admin")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scheduled reducer:**
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::table(accessor = reminder, scheduled(send_reminder))]
|
||||||
|
pub struct Reminder {
|
||||||
|
#[primary_key]
|
||||||
|
#[auto_inc]
|
||||||
|
id: u64,
|
||||||
|
scheduled_at: ScheduleAt,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
fn send_reminder(ctx: &ReducerContext, reminder: Reminder) {
|
||||||
|
log::info!("Reminder: {}", reminder.message);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Editing Behavior
|
## Editing Behavior
|
||||||
|
|
||||||
- Make the smallest change necessary.
|
When modifying SpacetimeDB code:
|
||||||
- Do not invent SpacetimeDB APIs; verify against current docs, generated bindings, or source.
|
|
||||||
- For Genarrative schema edits, update migration code, table catalog/docs, generated bindings, and relevant tests.
|
- Make the smallest change necessary
|
||||||
- After schema edits, run `npm run spacetime:generate` and `npm run check:spacetime-schema`.
|
- Do NOT touch unrelated files, configs, or dependencies
|
||||||
|
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||||
|
|||||||
646
.codex/skills/spacetimedb-csharp/SKILL.md
Normal file
646
.codex/skills/spacetimedb-csharp/SKILL.md
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
---
|
||||||
|
name: spacetimedb-csharp
|
||||||
|
description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: clockworklabs
|
||||||
|
version: "2.0"
|
||||||
|
tested_with: "SpacetimeDB 2.0, .NET 8 SDK"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SpacetimeDB C# SDK
|
||||||
|
|
||||||
|
This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HALLUCINATED APIs — DO NOT USE
|
||||||
|
|
||||||
|
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WRONG — these table access patterns do not exist
|
||||||
|
ctx.db.tableName // Wrong casing — use ctx.Db
|
||||||
|
ctx.Db.tableName // Wrong casing — accessor must match exactly
|
||||||
|
ctx.Db.TableName.Get(id) // Use Find, not Get
|
||||||
|
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
|
||||||
|
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
|
||||||
|
Optional<string> field; // Use C# nullable: string? field
|
||||||
|
|
||||||
|
// WRONG — missing partial keyword
|
||||||
|
public struct MyTable { } // Must be "partial struct"
|
||||||
|
public class Module { } // Must be "static partial class"
|
||||||
|
|
||||||
|
// WRONG — non-partial types
|
||||||
|
[SpacetimeDB.Table(Accessor = "Player")]
|
||||||
|
public struct Player { } // WRONG — missing partial!
|
||||||
|
|
||||||
|
// WRONG — sum type syntax (VERY COMMON MISTAKE)
|
||||||
|
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
|
||||||
|
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
|
||||||
|
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
|
||||||
|
|
||||||
|
// WRONG — Index attribute without full qualification
|
||||||
|
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
|
||||||
|
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
|
||||||
|
|
||||||
|
// WRONG — old 1.0 patterns
|
||||||
|
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
|
||||||
|
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
|
||||||
|
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
||||||
|
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
||||||
|
|
||||||
|
// WRONG — lifecycle hooks starting with "On"
|
||||||
|
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
||||||
|
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
|
||||||
|
|
||||||
|
// WRONG — non-deterministic code in reducers
|
||||||
|
var random = new Random(); // Use ctx.Rng
|
||||||
|
var guid = Guid.NewGuid(); // Not allowed
|
||||||
|
var now = DateTime.Now; // Use ctx.Timestamp
|
||||||
|
|
||||||
|
// WRONG — collection parameters
|
||||||
|
int[] itemIds = { 1, 2, 3 };
|
||||||
|
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORRECT PATTERNS
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using SpacetimeDB;
|
||||||
|
|
||||||
|
// CORRECT TABLE — must be partial struct, use Accessor
|
||||||
|
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||||
|
public partial struct Player
|
||||||
|
{
|
||||||
|
[SpacetimeDB.PrimaryKey]
|
||||||
|
[SpacetimeDB.AutoInc]
|
||||||
|
public ulong Id;
|
||||||
|
[SpacetimeDB.Index.BTree]
|
||||||
|
public Identity OwnerId;
|
||||||
|
public string Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT MODULE — must be static partial class
|
||||||
|
public static partial class Module
|
||||||
|
{
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void CreatePlayer(ReducerContext ctx, string name)
|
||||||
|
{
|
||||||
|
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
|
||||||
|
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
|
||||||
|
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
|
||||||
|
|
||||||
|
// CORRECT SUM TYPE — partial record with named tuple elements
|
||||||
|
[SpacetimeDB.Type]
|
||||||
|
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
||||||
|
|
||||||
|
// CORRECT — collection parameters use List<T>
|
||||||
|
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes Table
|
||||||
|
|
||||||
|
| Wrong | Right | Error |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Wrong .csproj name | `StdbModule.csproj` | Publish fails silently |
|
||||||
|
| .NET 9 SDK | .NET 8 SDK only | WASI compilation fails |
|
||||||
|
| Missing WASI workload | `dotnet workload install wasi-experimental` | Build fails |
|
||||||
|
| async/await in reducers | Synchronous only | Not supported |
|
||||||
|
| `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) |
|
||||||
|
| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire |
|
||||||
|
| Accessing `conn.Db` from background thread | Copy data in callback | Data races |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Requirements
|
||||||
|
|
||||||
|
1. **Tables and Module MUST be `partial`** — required for code generation
|
||||||
|
2. **Use `Accessor =` in table attributes** — `Name =` is only for SQL compatibility (2.0)
|
||||||
|
3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement
|
||||||
|
4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported
|
||||||
|
5. **Install WASI workload** — `dotnet workload install wasi-experimental`
|
||||||
|
6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed
|
||||||
|
7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random`
|
||||||
|
8. **Add `Public = true`** — if clients need to subscribe to a table
|
||||||
|
9. **Use `T?` for nullable fields** — not `Optional<T>`
|
||||||
|
10. **Pass `0` for auto-increment** — to trigger ID generation on insert
|
||||||
|
11. **Sum types must be `partial record`** — not struct or class
|
||||||
|
12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity
|
||||||
|
13. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
||||||
|
14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0)
|
||||||
|
15. **Use `List<T>` for collection parameters** — not arrays
|
||||||
|
16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side Module Development
|
||||||
|
|
||||||
|
### Table Definition
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using SpacetimeDB;
|
||||||
|
|
||||||
|
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||||
|
public partial struct Player
|
||||||
|
{
|
||||||
|
[SpacetimeDB.PrimaryKey]
|
||||||
|
[SpacetimeDB.AutoInc]
|
||||||
|
public ulong Id;
|
||||||
|
|
||||||
|
[SpacetimeDB.Index.BTree]
|
||||||
|
public Identity OwnerId;
|
||||||
|
|
||||||
|
public string Name;
|
||||||
|
public Timestamp CreatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-column index (use fully-qualified attribute!)
|
||||||
|
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
|
||||||
|
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
|
||||||
|
public partial struct Score
|
||||||
|
{
|
||||||
|
[SpacetimeDB.PrimaryKey]
|
||||||
|
[SpacetimeDB.AutoInc]
|
||||||
|
public ulong Id;
|
||||||
|
public Identity PlayerId;
|
||||||
|
public string GameId;
|
||||||
|
public int Points;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Attributes
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
|
||||||
|
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
|
||||||
|
[SpacetimeDB.Unique] // Unique constraint
|
||||||
|
[SpacetimeDB.Index.BTree] // Single-column B-tree index
|
||||||
|
[SpacetimeDB.Default(value)] // Default value for new columns
|
||||||
|
```
|
||||||
|
|
||||||
|
### SpacetimeDB Column Types
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
|
||||||
|
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
|
||||||
|
ScheduleAt // For scheduled tables
|
||||||
|
T? // Nullable (e.g., string?)
|
||||||
|
List<T> // Collections (use List, not arrays)
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported.
|
||||||
|
|
||||||
|
### Insert with Auto-Increment
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var player = ctx.Db.Player.Insert(new Player
|
||||||
|
{
|
||||||
|
Id = 0, // Pass 0 to trigger auto-increment
|
||||||
|
OwnerId = ctx.Sender,
|
||||||
|
Name = name,
|
||||||
|
CreatedAt = ctx.Timestamp
|
||||||
|
});
|
||||||
|
ulong newId = player.Id; // Insert returns the row with generated ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module and Reducers
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using SpacetimeDB;
|
||||||
|
|
||||||
|
public static partial class Module
|
||||||
|
{
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void CreateTask(ReducerContext ctx, string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(title))
|
||||||
|
throw new Exception("Title cannot be empty");
|
||||||
|
|
||||||
|
ctx.Db.Task.Insert(new Task
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
OwnerId = ctx.Sender,
|
||||||
|
Title = title,
|
||||||
|
Completed = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void CompleteTask(ReducerContext ctx, ulong taskId)
|
||||||
|
{
|
||||||
|
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
|
||||||
|
throw new Exception("Task not found");
|
||||||
|
if (task.OwnerId != ctx.Sender)
|
||||||
|
throw new Exception("Not authorized");
|
||||||
|
|
||||||
|
ctx.Db.Task.Id.Update(task with { Completed = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void DeleteTask(ReducerContext ctx, ulong taskId)
|
||||||
|
{
|
||||||
|
ctx.Db.Task.Id.Delete(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Reducers
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public static partial class Module
|
||||||
|
{
|
||||||
|
[SpacetimeDB.Reducer(ReducerKind.Init)]
|
||||||
|
public static void Init(ReducerContext ctx)
|
||||||
|
{
|
||||||
|
Log.Info("Module initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: no "On" prefix!
|
||||||
|
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
||||||
|
public static void ClientConnected(ReducerContext ctx)
|
||||||
|
{
|
||||||
|
Log.Info($"Client connected: {ctx.Sender}");
|
||||||
|
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
||||||
|
{
|
||||||
|
ctx.Db.User.Identity.Update(user with { Online = true });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
|
||||||
|
public static void ClientDisconnected(ReducerContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
||||||
|
{
|
||||||
|
ctx.Db.User.Identity.Update(user with { Online = false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Tables (2.0)
|
||||||
|
|
||||||
|
Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
|
||||||
|
public partial struct DamageEvent
|
||||||
|
{
|
||||||
|
public Identity Target;
|
||||||
|
public uint Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
|
||||||
|
{
|
||||||
|
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Client subscribes and uses `OnInsert`:
|
||||||
|
```csharp
|
||||||
|
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
|
||||||
|
PlayDamageAnimation(evt.Target, evt.Amount);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`.
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Find by primary key — returns nullable, use pattern matching
|
||||||
|
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
|
||||||
|
|
||||||
|
// Update by primary key (2.0: only primary key has .Update)
|
||||||
|
ctx.Db.Task.Id.Update(task with { Title = newTitle });
|
||||||
|
|
||||||
|
// Delete by primary key
|
||||||
|
ctx.Db.Task.Id.Delete(taskId);
|
||||||
|
|
||||||
|
// Find by unique index — returns nullable
|
||||||
|
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
|
||||||
|
|
||||||
|
// Filter by B-tree index — returns iterator
|
||||||
|
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
|
||||||
|
|
||||||
|
// Full table scan — avoid for large tables
|
||||||
|
foreach (var task in ctx.Db.Task.Iter()) { }
|
||||||
|
var count = ctx.Db.Task.Count;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Types and Sum Types
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Type]
|
||||||
|
public partial struct Position { public int X; public int Y; }
|
||||||
|
|
||||||
|
// Sum types MUST be partial record with named tuple
|
||||||
|
[SpacetimeDB.Type]
|
||||||
|
public partial struct Circle { public int Radius; }
|
||||||
|
[SpacetimeDB.Type]
|
||||||
|
public partial struct Rectangle { public int Width; public int Height; }
|
||||||
|
[SpacetimeDB.Type]
|
||||||
|
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
||||||
|
|
||||||
|
// Creating sum type values
|
||||||
|
var circle = new Shape.Circle(new Circle { Radius = 10 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduled Tables
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
|
||||||
|
public partial struct Reminder
|
||||||
|
{
|
||||||
|
[SpacetimeDB.PrimaryKey]
|
||||||
|
[SpacetimeDB.AutoInc]
|
||||||
|
public ulong Id;
|
||||||
|
public string Message;
|
||||||
|
public ScheduleAt ScheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static partial class Module
|
||||||
|
{
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void SendReminder(ReducerContext ctx, Reminder reminder)
|
||||||
|
{
|
||||||
|
Log.Info($"Reminder: {reminder.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
|
||||||
|
{
|
||||||
|
ctx.Db.Reminder.Insert(new Reminder
|
||||||
|
{
|
||||||
|
Id = 0,
|
||||||
|
Message = message,
|
||||||
|
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Log.Debug("Debug message");
|
||||||
|
Log.Info("Information");
|
||||||
|
Log.Warn("Warning");
|
||||||
|
Log.Error("Error occurred");
|
||||||
|
Log.Exception("Critical failure"); // Logs at error level
|
||||||
|
```
|
||||||
|
|
||||||
|
### ReducerContext API
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ctx.Sender // Identity of the caller
|
||||||
|
ctx.Timestamp // Current timestamp
|
||||||
|
ctx.Db // Database access
|
||||||
|
ctx.Identity // Module's own identity
|
||||||
|
ctx.ConnectionId // Connection ID (nullable)
|
||||||
|
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
|
||||||
|
ctx.Rng // Deterministic random number generator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Throwing an exception in a reducer rolls back the entire transaction:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[SpacetimeDB.Reducer]
|
||||||
|
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
|
||||||
|
{
|
||||||
|
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
|
||||||
|
throw new Exception("Sender not found");
|
||||||
|
|
||||||
|
if (sender.Credits < amount)
|
||||||
|
throw new Exception("Insufficient credits");
|
||||||
|
|
||||||
|
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
|
||||||
|
|
||||||
|
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
|
||||||
|
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
### Required .csproj (MUST be named `StdbModule.csproj`)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install .NET 8 SDK (required, not .NET 9)
|
||||||
|
# Install WASI workload
|
||||||
|
dotnet workload install wasi-experimental
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client SDK
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet add package SpacetimeDB.ClientSDK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Module Bindings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`.
|
||||||
|
|
||||||
|
### Connection Setup
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using SpacetimeDB;
|
||||||
|
using SpacetimeDB.Types;
|
||||||
|
|
||||||
|
var conn = DbConnection.Builder()
|
||||||
|
.WithUri("http://localhost:3000")
|
||||||
|
.WithDatabaseName("my-database")
|
||||||
|
.WithToken(savedToken)
|
||||||
|
.OnConnect(OnConnected)
|
||||||
|
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
|
||||||
|
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||||
|
{
|
||||||
|
// Save authToken to persistent storage for reconnection
|
||||||
|
Console.WriteLine($"Connected: {identity}");
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.SubscribeToAllTables();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical: FrameTick
|
||||||
|
|
||||||
|
**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Console application
|
||||||
|
while (running) { conn.FrameTick(); Thread.Sleep(16); }
|
||||||
|
|
||||||
|
// Unity: call conn?.FrameTick() in Update()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races.
|
||||||
|
|
||||||
|
### Subscribing to Tables
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// SQL queries
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
|
||||||
|
.Subscribe(new[] {
|
||||||
|
"SELECT * FROM player",
|
||||||
|
"SELECT * FROM message WHERE sender = :sender"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to all tables (development only)
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.SubscribeToAllTables();
|
||||||
|
|
||||||
|
// Subscription handle for later unsubscribe
|
||||||
|
SubscriptionHandle handle = conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(ctx => Console.WriteLine("Applied"))
|
||||||
|
.Subscribe(new[] { "SELECT * FROM player" });
|
||||||
|
|
||||||
|
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection.
|
||||||
|
|
||||||
|
### Accessing the Client Cache
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Iterate all rows
|
||||||
|
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
|
||||||
|
|
||||||
|
// Count rows
|
||||||
|
int playerCount = ctx.Db.Player.Count;
|
||||||
|
|
||||||
|
// Find by unique/primary key — returns nullable
|
||||||
|
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
|
||||||
|
if (player != null) { Console.WriteLine(player.Name); }
|
||||||
|
|
||||||
|
// Filter by BTree index — returns IEnumerable
|
||||||
|
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Row Event Callbacks
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||||
|
Console.WriteLine($"Player joined: {player.Name}");
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
||||||
|
Console.WriteLine($"Player left: {player.Name}");
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
|
||||||
|
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Checking event source
|
||||||
|
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||||
|
switch (ctx.Event)
|
||||||
|
{
|
||||||
|
case Event<Reducer>.SubscribeApplied:
|
||||||
|
break; // Initial subscription data
|
||||||
|
case Event<Reducer>.Reducer(var reducerEvent):
|
||||||
|
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calling Reducers
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ctx.Reducers.SendMessage("Hello, world!");
|
||||||
|
ctx.Reducers.CreatePlayer("NewPlayer");
|
||||||
|
|
||||||
|
// Reducer completion callbacks
|
||||||
|
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
||||||
|
if (ctx.Event.Status is Status.Committed)
|
||||||
|
Console.WriteLine($"Message sent: {text}");
|
||||||
|
else if (ctx.Event.Status is Status.Failed(var reason))
|
||||||
|
Console.Error.WriteLine($"Send failed: {reason}");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unhandled reducer errors
|
||||||
|
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
|
||||||
|
Console.Error.WriteLine($"Reducer error: {ex.Message}");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity and Authentication
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In OnConnect callback — save token for reconnection
|
||||||
|
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||||
|
{
|
||||||
|
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
|
||||||
|
SaveToken(authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect with saved token
|
||||||
|
string savedToken = LoadToken();
|
||||||
|
DbConnection.Builder()
|
||||||
|
.WithUri("http://localhost:3000")
|
||||||
|
.WithDatabaseName("my-database")
|
||||||
|
.WithToken(savedToken)
|
||||||
|
.OnConnect(OnConnected)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Pass null or omit WithToken for anonymous connection
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime start
|
||||||
|
spacetime publish <module-name> --module-path <backend-dir>
|
||||||
|
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||||
|
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
|
||||||
|
spacetime logs <module-name>
|
||||||
|
```
|
||||||
@@ -1,170 +1,312 @@
|
|||||||
---
|
---
|
||||||
name: spacetimedb-rust
|
name: spacetimedb-rust
|
||||||
description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: clockworklabs
|
||||||
|
version: "2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SpacetimeDB Rust Module Development
|
# SpacetimeDB Rust Module Development
|
||||||
|
|
||||||
Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work.
|
SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it.
|
||||||
|
|
||||||
## Genarrative Rules
|
> **Tested with:** SpacetimeDB 2.0+ APIs
|
||||||
|
|
||||||
- Keep domain rules in `module-*`; keep SpacetimeDB tables, reducers, procedures, views, mappers, and transaction adapters in `spacetime-module`.
|
---
|
||||||
- Existing table fields must be appended at the end with explicit defaults. Do not rename, remove, reorder, or change field types without a user-confirmed migration plan.
|
|
||||||
- After schema changes, update `migration.rs`, table catalog/docs, generated bindings, and run `npm run spacetime:generate` plus `npm run check:spacetime-schema`.
|
|
||||||
- Private tables are backend facts. Expose user-visible state through BFF endpoints/read models rather than direct client SQL.
|
|
||||||
|
|
||||||
## Hallucinated APIs: Do Not Use
|
## HALLUCINATED APIs — DO NOT USE
|
||||||
|
|
||||||
|
**These APIs/patterns are incorrect. LLMs frequently hallucinate them.**
|
||||||
|
|
||||||
|
Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[derive(Table)] // Tables use #[table], not derive
|
#[derive(Table)] // Tables use #[table] attribute, not derive
|
||||||
#[derive(Reducer)] // Reducers use #[reducer], not derive
|
#[derive(Reducer)] // Reducers use #[reducer] attribute
|
||||||
#[derive(SpacetimeType)] // Do not derive this on #[table] structs
|
|
||||||
|
|
||||||
pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext
|
// WRONG — SpacetimeType on tables
|
||||||
|
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
|
||||||
|
#[table(accessor = my_table)]
|
||||||
|
pub struct MyTable { ... }
|
||||||
|
|
||||||
ctx.db.player // Use ctx.db.player()
|
// WRONG — mutable context
|
||||||
ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
|
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext
|
||||||
ctx.sender // Use ctx.sender()
|
|
||||||
ctx.db.user().name().update(..) // Update by primary key only
|
|
||||||
|
|
||||||
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
|
// WRONG — table access without parentheses
|
||||||
|
ctx.db.player // Should be ctx.db.player()
|
||||||
|
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
|
||||||
|
|
||||||
|
// WRONG — old 1.0 patterns
|
||||||
|
ctx.sender // Use ctx.sender() — method, not field (2.0)
|
||||||
|
.with_module_name("db") // Use .with_database_name() (2.0)
|
||||||
|
ctx.db.user().name().update(..) // Update only via primary key (2.0)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Required Patterns
|
### CORRECT PATTERNS:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp};
|
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||||
use spacetimedb::SpacetimeType; // Custom types only, not tables
|
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
|
||||||
|
|
||||||
|
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
|
||||||
#[table(accessor = player, public)]
|
#[table(accessor = player, public)]
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
#[primary_key]
|
#[primary_key]
|
||||||
#[auto_inc]
|
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub owner: Identity,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub created_at: Timestamp,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CORRECT REDUCER — immutable context, sender() is a method
|
||||||
#[reducer]
|
#[reducer]
|
||||||
|
pub fn create_player(ctx: &ReducerContext, name: String) {
|
||||||
|
ctx.db.player().insert(Player { id: 0, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
|
||||||
|
let player = ctx.db.player().id().find(&player_id);
|
||||||
|
let caller = ctx.sender();
|
||||||
|
```
|
||||||
|
|
||||||
|
### DO NOT:
|
||||||
|
- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||||
|
- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext`
|
||||||
|
- **Forget `Table` trait import** — required for table operations
|
||||||
|
- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player`
|
||||||
|
- **Use `ctx.sender`** — it's `ctx.sender()` (method) in 2.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes Table
|
||||||
|
|
||||||
|
| Wrong | Right | Error |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||||
|
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||||
|
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||||
|
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Requirements
|
||||||
|
|
||||||
|
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||||
|
2. **Import `Table` trait** — required for all table operations
|
||||||
|
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||||
|
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||||
|
5. **Use `ctx.sender()`** — method call, not field access (2.0)
|
||||||
|
6. **Use `accessor =` for API handles** — `name = "..."` is optional canonical naming in table/index attributes
|
||||||
|
7. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||||
|
8. **Use `ctx.rng()`** — not `rand` crate for random numbers
|
||||||
|
9. **Add `public` flag** — if clients need to subscribe to a table
|
||||||
|
10. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "my-module"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
spacetimedb = { workspace = true }
|
||||||
|
log = "0.4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Essential Imports
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use spacetimedb::{ReducerContext, Table};
|
||||||
|
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Definitions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::table(accessor = player, public)]
|
||||||
|
pub struct Player {
|
||||||
|
#[primary_key]
|
||||||
|
#[auto_inc]
|
||||||
|
id: u64,
|
||||||
|
name: String,
|
||||||
|
score: u32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table Attributes
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `accessor = identifier` | Required. The API name used in `ctx.db.{accessor}()` |
|
||||||
|
| `public` | Makes table visible to clients via subscriptions |
|
||||||
|
| `scheduled(function_name)` | Creates a schedule table that triggers the named reducer or procedure |
|
||||||
|
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
|
||||||
|
|
||||||
|
### Column Attributes
|
||||||
|
|
||||||
|
| Attribute | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `#[primary_key]` | Unique identifier for the row (one per table max) |
|
||||||
|
| `#[unique]` | Enforces uniqueness, enables `find()` method |
|
||||||
|
| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 |
|
||||||
|
| `#[index(btree)]` | Creates a B-tree index for efficient lookups |
|
||||||
|
|
||||||
|
### Supported Column Types
|
||||||
|
|
||||||
|
**Primitives**: `u8`-`u256`, `i8`-`i256`, `f32`, `f64`, `bool`, `String`
|
||||||
|
|
||||||
|
**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt`
|
||||||
|
|
||||||
|
**Collections**: `Vec<T>`, `Option<T>`, `Result<T, E>`
|
||||||
|
|
||||||
|
**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reducers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[spacetimedb::reducer]
|
||||||
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||||
if name.trim().is_empty() {
|
if name.is_empty() {
|
||||||
return Err("name required".to_string());
|
return Err("Name cannot be empty".to_string());
|
||||||
}
|
}
|
||||||
ctx.db.player().try_insert(Player {
|
ctx.db.player().insert(Player { id: 0, name, score: 0 });
|
||||||
id: 0,
|
|
||||||
owner: ctx.sender(),
|
|
||||||
name,
|
|
||||||
created_at: ctx.timestamp,
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Hard requirements:
|
### Reducer Rules
|
||||||
|
|
||||||
- Import `Table` for table operations.
|
1. First parameter must be `&ReducerContext`
|
||||||
- Use `accessor = identifier`, not string literals.
|
2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display`
|
||||||
- Use `ctx.sender()` for authorization.
|
3. All changes roll back on panic or `Err` return
|
||||||
- Use `ctx.rng()` / `ctx.random()` / `ctx.new_uuid_*()` for deterministic randomness and UUIDs.
|
4. Must import `Table` trait: `use spacetimedb::Table;`
|
||||||
- Use `Result<(), String>` for expected sender errors; avoid panics except impossible states.
|
|
||||||
- Use `try_insert()` in `Result` reducers when constraint violations should be reported cleanly.
|
|
||||||
|
|
||||||
## Tables
|
### ReducerContext
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
|
ctx.db // Database access
|
||||||
pub struct GameTickSchedule {
|
ctx.sender() // Identity of the caller (method, not field!)
|
||||||
#[primary_key]
|
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
|
||||||
#[auto_inc]
|
ctx.timestamp // Invocation timestamp
|
||||||
pub scheduled_id: u64,
|
ctx.identity() // Module's own identity
|
||||||
pub scheduled_at: ScheduleAt,
|
ctx.rng() // Deterministic RNG (method, not field!)
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Table attributes:
|
---
|
||||||
|
|
||||||
| Attribute | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `accessor = identifier` | API name used in `ctx.db.{accessor}()` |
|
|
||||||
| `public` | Visible to clients via subscriptions |
|
|
||||||
| `event` | Transient event table |
|
|
||||||
| `scheduled(function_name)` | Schedule table that triggers a reducer/procedure |
|
|
||||||
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
|
|
||||||
|
|
||||||
Column attributes:
|
|
||||||
|
|
||||||
| Attribute | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `#[primary_key]` | One primary key per table |
|
|
||||||
| `#[auto_inc]` | Auto-generates integer values when inserting `0` |
|
|
||||||
| `#[unique]` | Unique constraint and `find()` accessor |
|
|
||||||
| `#[index(btree)]` | B-tree index and `filter()` accessor |
|
|
||||||
| `#[default(...)]` | Required for new fields on existing Genarrative tables |
|
|
||||||
|
|
||||||
## Genarrative Schema Change Pattern
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[spacetimedb::table(accessor = creation_entry_config, public)]
|
|
||||||
pub struct CreationEntryConfig {
|
|
||||||
#[primary_key]
|
|
||||||
pub id: u64,
|
|
||||||
pub existing_field: String,
|
|
||||||
|
|
||||||
// Append new fields at the end and provide a default.
|
|
||||||
#[default(false)]
|
|
||||||
pub new_flag: bool,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then update `migration.rs`, table catalog/docs, generated bindings, and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run spacetime:generate
|
|
||||||
npm run check:spacetime-schema
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table Operations
|
## Table Operations
|
||||||
|
|
||||||
|
### Insert
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let row = ctx.db.player().insert(Player { id: 0, owner, name, created_at });
|
// Insert returns the row with auto_inc values populated
|
||||||
ctx.db.player().try_insert(row)?;
|
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
|
||||||
|
log::info!("Created player with id: {}", player.id);
|
||||||
let by_id = ctx.db.player().id().find(&123u64);
|
|
||||||
for player in ctx.db.player().owner().filter(&ctx.sender()) {}
|
|
||||||
for player in ctx.db.player().level().filter(&(18u32..=65u32)) {}
|
|
||||||
for player in ctx.db.player().iter() {}
|
|
||||||
let count = ctx.db.player().count();
|
|
||||||
|
|
||||||
if let Some(player) = ctx.db.player().id().find(&id) {
|
|
||||||
ctx.db.player().id().update(Player { name: new_name, ..player });
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.db.player().id().delete(&id);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation.
|
### Find and Filter
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Find by unique/primary key — returns Option
|
||||||
|
if let Some(player) = ctx.db.player().id().find(&123) {
|
||||||
|
log::info!("Found: {}", player.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional clarity: typed literals can avoid inference ambiguity
|
||||||
|
if let Some(player) = ctx.db.player().id().find(&123u64) {
|
||||||
|
log::info!("Found: {}", player.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by indexed column — returns iterator
|
||||||
|
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
|
||||||
|
log::info!("Player: {}", player.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full table scan
|
||||||
|
for player in ctx.db.player().iter() { }
|
||||||
|
let total = ctx.db.player().count();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Update via primary key (2.0: only primary key has update)
|
||||||
|
if let Some(player) = ctx.db.player().id().find(&123) {
|
||||||
|
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-PK changes: delete + insert
|
||||||
|
if let Some(old) = ctx.db.player().id().find(&id) {
|
||||||
|
ctx.db.player().id().delete(&id);
|
||||||
|
ctx.db.player().insert(Player { name: new_name, ..old });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Delete by primary key
|
||||||
|
ctx.db.player().id().delete(&123);
|
||||||
|
|
||||||
|
// Delete by indexed column (collect first to avoid iterator invalidation)
|
||||||
|
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
|
||||||
|
.map(|p| p.id)
|
||||||
|
.collect();
|
||||||
|
for id in to_remove {
|
||||||
|
ctx.db.player().id().delete(&id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Indexes
|
## Indexes
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
// Single-column index
|
||||||
|
#[spacetimedb::table(accessor = player, public)]
|
||||||
|
pub struct Player {
|
||||||
|
#[primary_key]
|
||||||
|
id: u64,
|
||||||
|
#[index(btree)]
|
||||||
|
level: u32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-column index
|
||||||
#[spacetimedb::table(
|
#[spacetimedb::table(
|
||||||
accessor = score,
|
accessor = score, public,
|
||||||
public,
|
|
||||||
index(accessor = by_player_level, btree(columns = [player_id, level]))
|
index(accessor = by_player_level, btree(columns = [player_id, level]))
|
||||||
)]
|
)]
|
||||||
pub struct Score {
|
pub struct Score {
|
||||||
pub player_id: u32,
|
player_id: u32,
|
||||||
pub level: u32,
|
level: u32,
|
||||||
pub points: i64,
|
points: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
for row in ctx.db.score().by_player_level().filter(&(42,)) {}
|
// Multi-column index querying: prefix match (first column only)
|
||||||
for row in ctx.db.score().by_player_level().filter(&(42, 5)) {}
|
for s in ctx.db.score().by_player_level().filter(&(42,)) {
|
||||||
|
log::info!("Player 42, any level: {} pts", s.points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full match (both columns)
|
||||||
|
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
|
||||||
|
log::info!("Player 42, level 5: {} pts", s.points);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Event Tables
|
---
|
||||||
|
|
||||||
|
## Event Tables (2.0)
|
||||||
|
|
||||||
|
Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[table(accessor = damage_event, public, event)]
|
#[table(accessor = damage_event, public, event)]
|
||||||
@@ -179,65 +321,182 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
Client subscribes and uses `on_insert`:
|
||||||
|
|
||||||
In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
|
||||||
|
|
||||||
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
|
||||||
|
|
||||||
Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
|
||||||
|
|
||||||
## Views
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[spacetimedb::view(accessor = my_players, public, primary_key = id)]
|
conn.db.damage_event().on_insert(|ctx, event| {
|
||||||
pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec<Player> {
|
play_damage_animation(event.target, event.amount);
|
||||||
ctx.db.player().owner().filter(&ctx.sender()).collect()
|
});
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rust and TypeScript gained primary key support for procedural views in 2.4.1. With primary keys, clients can receive update events when subscribed to such views. Avoid duplicate primary keys in view results.
|
Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`.
|
||||||
|
|
||||||
## Lifecycle & Scheduled Reducers
|
---
|
||||||
|
|
||||||
|
## Lifecycle Reducers
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[spacetimedb::reducer(init)]
|
#[spacetimedb::reducer(init)]
|
||||||
pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
|
||||||
|
log::info!("Database initializing...");
|
||||||
|
ctx.db.config().insert(Config {
|
||||||
|
id: 0,
|
||||||
|
max_players: 100,
|
||||||
|
game_mode: "default".to_string(),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::reducer(client_connected)]
|
#[spacetimedb::reducer(client_connected)]
|
||||||
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
|
||||||
|
let caller = ctx.sender();
|
||||||
|
log::info!("Client connected: {}", caller);
|
||||||
|
|
||||||
|
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
||||||
|
ctx.db.user().identity().update(User { online: true, ..user });
|
||||||
|
} else {
|
||||||
|
ctx.db.user().insert(User {
|
||||||
|
identity: caller,
|
||||||
|
name: format!("User-{}", &caller.to_hex()[..8]),
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[spacetimedb::reducer(client_disconnected)]
|
#[spacetimedb::reducer(client_disconnected)]
|
||||||
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
|
||||||
|
let caller = ctx.sender();
|
||||||
|
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
||||||
|
ctx.db.user().identity().update(User { online: false, ..user });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
use spacetimedb::{ScheduleAt, TimeDuration};
|
---
|
||||||
|
|
||||||
|
## Scheduled Reducers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use spacetimedb::ScheduleAt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
|
||||||
|
pub struct GameTickSchedule {
|
||||||
|
#[primary_key]
|
||||||
|
#[auto_inc]
|
||||||
|
scheduled_id: u64,
|
||||||
|
scheduled_at: ScheduleAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) {
|
||||||
|
if !ctx.sender_auth().is_internal() { return; }
|
||||||
|
log::info!("Game tick at {:?}", ctx.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule at interval (e.g., in init reducer)
|
||||||
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
||||||
scheduled_id: 0,
|
scheduled_id: 0,
|
||||||
scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()),
|
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let run_at = ctx.timestamp + std::time::Duration::from_secs(60);
|
// Schedule at specific time
|
||||||
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
|
||||||
|
ctx.db.reminder_schedule().insert(ReminderSchedule {
|
||||||
scheduled_id: 0,
|
scheduled_id: 0,
|
||||||
scheduled_at: ScheduleAt::Time(run_at),
|
scheduled_at: ScheduleAt::Time(run_at),
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer should only be system-triggered.
|
---
|
||||||
|
|
||||||
## Procedures
|
## Identity and Authentication
|
||||||
|
|
||||||
Procedures are stable in 2.5 and no longer require the `unstable` feature.
|
```rust
|
||||||
|
#[spacetimedb::table(accessor = user, public)]
|
||||||
|
pub struct User {
|
||||||
|
#[primary_key]
|
||||||
|
identity: Identity,
|
||||||
|
name: String,
|
||||||
|
online: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
|
||||||
|
let caller = ctx.sender();
|
||||||
|
let user = ctx.db.user().identity().find(&caller)
|
||||||
|
.ok_or("User not found — connect first")?;
|
||||||
|
ctx.db.user().identity().update(User { name: new_name, ..user });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Owner-Only Reducer Pattern
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
|
||||||
|
if ctx.sender() != *entity_owner {
|
||||||
|
Err("Not authorized: you don't own this entity".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
|
||||||
|
let character = ctx.db.character().id().find(&char_id)
|
||||||
|
.ok_or("Character not found")?;
|
||||||
|
require_owner(ctx, &character.owner)?;
|
||||||
|
ctx.db.character().id().update(Character { name: new_name, ..character });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Sender error — return Err (user sees message, transaction rolls back cleanly)
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
|
||||||
|
let sender = ctx.db.wallet().identity().find(&ctx.sender())
|
||||||
|
.ok_or("Wallet not found")?;
|
||||||
|
if sender.balance < amount {
|
||||||
|
return Err("Insufficient balance".to_string());
|
||||||
|
}
|
||||||
|
// ... proceed with transfer
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmer error — panic (destroys the WASM instance, expensive!)
|
||||||
|
// Only use for truly impossible states
|
||||||
|
#[spacetimedb::reducer]
|
||||||
|
pub fn process(ctx: &ReducerContext, id: u64) {
|
||||||
|
let item = ctx.db.item().id().find(&id)
|
||||||
|
.expect("BUG: item should exist at this point");
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer `Result<(), String>` for all expected failure cases. Panics destroy and recreate the WASM instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedures (Beta)
|
||||||
|
|
||||||
|
> Procedures are behind the `unstable` feature in `spacetimedb`.
|
||||||
|
> In `Cargo.toml`: `spacetimedb = { version = "...", features = ["unstable"] }`
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use spacetimedb::{procedure, ProcedureContext};
|
use spacetimedb::{procedure, ProcedureContext};
|
||||||
|
|
||||||
#[procedure]
|
#[procedure]
|
||||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||||
let body = ctx.http.get(url).send()?.text()?;
|
let data = fetch_from_url(&url)?;
|
||||||
ctx.try_with_tx(|tx| {
|
ctx.try_with_tx(|tx| {
|
||||||
tx.db.external_data().insert(ExternalData { id: 0, content: body });
|
tx.db.external_data().insert(ExternalData { id: 0, content: data });
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -246,35 +505,52 @@ fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), Str
|
|||||||
|
|
||||||
| Reducers | Procedures |
|
| Reducers | Procedures |
|
||||||
|----------|------------|
|
|----------|------------|
|
||||||
| `&ReducerContext` | `&mut ProcedureContext` |
|
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||||
| Direct `ctx.db` access | Use `with_tx()` / `try_with_tx()` |
|
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||||
| No HTTP/network | Outgoing HTTP via `ctx.http` |
|
| No HTTP/network | HTTP allowed |
|
||||||
| Deterministic transaction path | Side-effect-capable workflow path |
|
| No return values | Can return data |
|
||||||
|
|
||||||
In Genarrative, keep external provider protocols in `platform-*` by default unless the architecture explicitly moves that workflow into the module.
|
---
|
||||||
|
|
||||||
## Identity & Auth
|
## Custom Types
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
fn require_owner(ctx: &ReducerContext, owner: &Identity) -> Result<(), String> {
|
use spacetimedb::SpacetimeType;
|
||||||
if ctx.sender() != *owner {
|
|
||||||
return Err("Not authorized".to_string());
|
#[derive(SpacetimeType)]
|
||||||
}
|
pub enum PlayerStatus { Active, Idle, Away }
|
||||||
Ok(())
|
|
||||||
|
#[derive(SpacetimeType)]
|
||||||
|
pub struct Position { x: f32, y: f32, z: f32 }
|
||||||
|
|
||||||
|
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||||
|
#[spacetimedb::table(accessor = player, public)]
|
||||||
|
pub struct Player {
|
||||||
|
#[primary_key]
|
||||||
|
id: u64,
|
||||||
|
status: PlayerStatus,
|
||||||
|
position: Position,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ReducerContext::identity` is deprecated since 2.3; use the current database/module identity API when needed, and use `ctx.sender()` for caller identity.
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
spacetime build
|
spacetime build
|
||||||
spacetime publish my_database --server http://127.0.0.1:3101 --module-path . --yes=migrate
|
spacetime publish my_database --module-path .
|
||||||
spacetime publish my_database --server http://127.0.0.1:3101 --delete-data=on-conflict --module-path . --yes=migrate
|
spacetime publish my_database --clear-database --module-path .
|
||||||
spacetime logs my_database --server http://127.0.0.1:3101
|
spacetime logs my_database
|
||||||
spacetime call --server http://127.0.0.1:3101 my_database create_player '"Alice"'
|
spacetime call my_database create_player "Alice"
|
||||||
spacetime sql my_database --server http://127.0.0.1:3101 "SELECT * FROM player"
|
spacetime sql my_database "SELECT * FROM player"
|
||||||
npm run spacetime:generate
|
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||||
npm run check:spacetime-schema
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
1. **No Global State**: Static/global variables are undefined behavior across reducer calls
|
||||||
|
2. **No Side Effects**: Reducers cannot make network requests or file I/O
|
||||||
|
3. **Deterministic Execution**: Use `ctx.rng()` and `ctx.new_uuid_*()` for randomness
|
||||||
|
4. **Transactional**: All reducer changes roll back on failure
|
||||||
|
5. **Isolated**: Reducers don't see concurrent changes until commit
|
||||||
|
|||||||
489
.codex/skills/spacetimedb-typescript/SKILL.md
Normal file
489
.codex/skills/spacetimedb-typescript/SKILL.md
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
---
|
||||||
|
name: spacetimedb-typescript
|
||||||
|
description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: clockworklabs
|
||||||
|
version: "2.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SpacetimeDB TypeScript SDK
|
||||||
|
|
||||||
|
Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HALLUCINATED APIs — DO NOT USE
|
||||||
|
|
||||||
|
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WRONG PACKAGE — does not exist
|
||||||
|
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
|
||||||
|
|
||||||
|
// WRONG — these methods don't exist
|
||||||
|
SpacetimeDBClient.connect(...);
|
||||||
|
SpacetimeDBClient.call("reducer_name", [...]);
|
||||||
|
connection.call("reducer_name", [arg1, arg2]);
|
||||||
|
|
||||||
|
// WRONG — positional reducer arguments
|
||||||
|
conn.reducers.doSomething("value"); // WRONG!
|
||||||
|
|
||||||
|
// WRONG — old 1.0 patterns
|
||||||
|
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
|
||||||
|
schema(myTable); // Use schema({ myTable })
|
||||||
|
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
|
||||||
|
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
|
||||||
|
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
|
||||||
|
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORRECT PATTERNS:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT IMPORTS
|
||||||
|
import { DbConnection, tables } from './module_bindings'; // Generated!
|
||||||
|
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
||||||
|
import { Identity } from 'spacetimedb';
|
||||||
|
|
||||||
|
// CORRECT REDUCER CALLS — object syntax, not positional!
|
||||||
|
conn.reducers.doSomething({ value: 'test' });
|
||||||
|
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
|
||||||
|
|
||||||
|
// CORRECT DATA ACCESS — useTable returns [rows, isReady]
|
||||||
|
const [items, isReady] = useTable(tables.item);
|
||||||
|
```
|
||||||
|
|
||||||
|
### DO NOT:
|
||||||
|
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
|
||||||
|
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes Table
|
||||||
|
|
||||||
|
### Server-side errors
|
||||||
|
|
||||||
|
| Wrong | Right | Error |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||||
|
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||||
|
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||||
|
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) of `table()` | "reading 'tag'" error |
|
||||||
|
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||||
|
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||||
|
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||||
|
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||||
|
| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID |
|
||||||
|
| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" |
|
||||||
|
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
|
||||||
|
| Incorrect multi-column `.filter()` range shape | Match index prefix/tuple shape | Empty results or range/type errors |
|
||||||
|
| `.iter()` in views | Use index lookups only | Views can't scan tables |
|
||||||
|
| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions |
|
||||||
|
|
||||||
|
### Client-side errors
|
||||||
|
|
||||||
|
| Wrong | Right | Error |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||||
|
| `const rows = useTable(table)` | `const [rows, isReady] = useTable(table)` | Tuple destructuring |
|
||||||
|
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||||
|
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard Requirements
|
||||||
|
|
||||||
|
1. **`schema({ table })`** — use a single tables object; optional module settings are allowed as a second argument
|
||||||
|
2. **Reducer/procedure names from exports** — `export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
|
||||||
|
3. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args
|
||||||
|
4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
|
||||||
|
5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||||
|
6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
|
||||||
|
7. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1`
|
||||||
|
8. **Reducers are transactional** — they do not return data
|
||||||
|
9. **Reducers must be deterministic** — no filesystem, network, timers, random
|
||||||
|
10. **Views should use index lookups** — `.iter()` causes severe performance issues
|
||||||
|
11. **Procedures need `ctx.withTx()`** — `ctx.db` doesn't exist in procedures
|
||||||
|
12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
|
||||||
|
13. **Use `.withDatabaseName()`** — not `.withModuleName()` (2.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install spacetimedb
|
||||||
|
```
|
||||||
|
|
||||||
|
For Node.js environments without native fetch/WebSocket support, install `undici`.
|
||||||
|
|
||||||
|
## Generating Type Bindings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Connection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DbConnection } from './module_bindings';
|
||||||
|
|
||||||
|
const connection = DbConnection.builder()
|
||||||
|
.withUri('ws://localhost:3000')
|
||||||
|
.withDatabaseName('my_database')
|
||||||
|
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
|
||||||
|
.onConnect((conn, identity, token) => {
|
||||||
|
// identity: your unique Identity for this database
|
||||||
|
console.log('Connected as:', identity.toHexString());
|
||||||
|
|
||||||
|
// Save token for reconnection (preserves identity across sessions)
|
||||||
|
localStorage.setItem('spacetimedb_token', token);
|
||||||
|
|
||||||
|
conn.subscriptionBuilder()
|
||||||
|
.onApplied(() => console.log('Cache ready'))
|
||||||
|
.subscribe('SELECT * FROM player');
|
||||||
|
})
|
||||||
|
.onDisconnect((ctx) => console.log('Disconnected'))
|
||||||
|
.onConnectError((ctx, error) => console.error('Connection failed:', error))
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscribing to Tables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Basic subscription
|
||||||
|
connection.subscriptionBuilder()
|
||||||
|
.onApplied((ctx) => console.log('Cache ready'))
|
||||||
|
.subscribe('SELECT * FROM player');
|
||||||
|
|
||||||
|
// Multiple queries
|
||||||
|
connection.subscriptionBuilder()
|
||||||
|
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
|
||||||
|
|
||||||
|
// Subscribe to all tables (development only — cannot mix with Subscribe)
|
||||||
|
connection.subscriptionBuilder().subscribeToAllTables();
|
||||||
|
|
||||||
|
// Subscription handle for later unsubscribe
|
||||||
|
const handle = connection.subscriptionBuilder()
|
||||||
|
.onApplied(() => console.log('Subscribed'))
|
||||||
|
.subscribe('SELECT * FROM player');
|
||||||
|
|
||||||
|
handle.unsubscribeThen(() => console.log('Unsubscribed'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Table Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
for (const player of connection.db.player.iter()) { console.log(player.name); }
|
||||||
|
const players = Array.from(connection.db.player.iter());
|
||||||
|
const count = connection.db.player.count();
|
||||||
|
const player = connection.db.player.id.find(42n);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Event Callbacks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
|
||||||
|
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
|
||||||
|
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calling Reducers
|
||||||
|
|
||||||
|
**CRITICAL: Use object syntax, not positional arguments.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Snake_case to camelCase conversion
|
||||||
|
- Server: `export const do_something = spacetimedb.reducer(...)`
|
||||||
|
- Client: `conn.reducers.doSomething({ ... })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity and Authentication
|
||||||
|
|
||||||
|
- `identity` and `token` are provided in the `onConnect` callback (see Client Connection above)
|
||||||
|
- `identity.toHexString()` for display or logging
|
||||||
|
- Omit `.withToken()` for anonymous connection — server assigns a new identity
|
||||||
|
- Pass a stale/invalid token: server issues a new identity and token in `onConnect`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Connection-level errors (`.onConnectError`, `.onDisconnect`) are shown in the Client Connection example above.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscription error
|
||||||
|
connection.subscriptionBuilder()
|
||||||
|
.onApplied(() => console.log('Subscribed'))
|
||||||
|
.onError((ctx) => console.error('Subscription error:', ctx.event))
|
||||||
|
.subscribe('SELECT * FROM player');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side Module Development
|
||||||
|
|
||||||
|
### Table Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { schema, table, t } from 'spacetimedb/server';
|
||||||
|
|
||||||
|
export const Task = table({
|
||||||
|
name: 'task',
|
||||||
|
public: true,
|
||||||
|
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
|
||||||
|
}, {
|
||||||
|
id: t.u64().primaryKey().autoInc(),
|
||||||
|
ownerId: t.identity(),
|
||||||
|
title: t.string(),
|
||||||
|
createdAt: t.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Column types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
t.identity() // User identity
|
||||||
|
t.u64() // Unsigned 64-bit integer (use for IDs)
|
||||||
|
t.string() // Text
|
||||||
|
t.bool() // Boolean
|
||||||
|
t.timestamp() // Timestamp
|
||||||
|
t.scheduleAt() // For scheduled tables only
|
||||||
|
t.object('Name', {}) // Product types (nested objects)
|
||||||
|
t.enum('Name', {}) // Sum types (tagged unions)
|
||||||
|
t.string().optional() // Nullable
|
||||||
|
```
|
||||||
|
|
||||||
|
> BigInt syntax: All `u64`/`i64` fields use `0n`, `1n`, not `0`, `1`.
|
||||||
|
|
||||||
|
### Schema export
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const spacetimedb = schema({ Task, Player });
|
||||||
|
export default spacetimedb;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reducer Definition (2.0)
|
||||||
|
|
||||||
|
**Name comes from the export — NOT from a string argument.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import spacetimedb from './schema';
|
||||||
|
import { t, SenderError } from 'spacetimedb/server';
|
||||||
|
|
||||||
|
export const create_task = spacetimedb.reducer(
|
||||||
|
{ title: t.string() },
|
||||||
|
(ctx, { title }) => {
|
||||||
|
if (!title) throw new SenderError('title required');
|
||||||
|
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existing = ctx.db.task.id.find(taskId);
|
||||||
|
if (!existing) throw new SenderError('Task not found');
|
||||||
|
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
|
||||||
|
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Tables (2.0)
|
||||||
|
|
||||||
|
Reducer callbacks are removed in 2.0. Use event tables + `onInsert` instead.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const DamageEvent = table(
|
||||||
|
{ name: 'damage_event', public: true, event: true },
|
||||||
|
{ target: t.identity(), amount: t.u32() }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deal_damage = spacetimedb.reducer(
|
||||||
|
{ target: t.identity(), amount: t.u32() },
|
||||||
|
(ctx, { target, amount }) => {
|
||||||
|
ctx.db.damageEvent.insert({ target, amount });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Client subscribes and uses `onInsert`:
|
||||||
|
```typescript
|
||||||
|
conn.db.damageEvent.onInsert((ctx, evt) => {
|
||||||
|
playDamageAnimation(evt.target, evt.amount);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Event tables must be subscribed explicitly — they are excluded from `subscribeToAllTables()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
### ViewContext vs AnonymousViewContext
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ViewContext — has ctx.sender, result varies per user
|
||||||
|
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
|
||||||
|
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||||
|
});
|
||||||
|
|
||||||
|
// AnonymousViewContext — no ctx.sender, same result for everyone (better perf)
|
||||||
|
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
|
||||||
|
return ctx.from.player.where(p => p.score.gt(1000));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Views can only use index lookups — `.iter()` is NOT allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scheduled Tables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CleanupJob = table({
|
||||||
|
name: 'cleanup_job',
|
||||||
|
scheduled: () => run_cleanup // function returning the exported reducer
|
||||||
|
}, {
|
||||||
|
scheduledId: t.u64().primaryKey().autoInc(),
|
||||||
|
scheduledAt: t.scheduleAt(),
|
||||||
|
targetId: t.u64(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const run_cleanup = spacetimedb.reducer(
|
||||||
|
{ arg: CleanupJob.rowType },
|
||||||
|
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule a job
|
||||||
|
import { ScheduleAt } from 'spacetimedb';
|
||||||
|
ctx.db.cleanupJob.insert({
|
||||||
|
scheduledId: 0n,
|
||||||
|
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
|
||||||
|
targetId: someId
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ScheduleAt on Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ScheduleAt is a tagged union on the client
|
||||||
|
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
|
||||||
|
const schedule = row.scheduledAt;
|
||||||
|
if (schedule.tag === 'Time') {
|
||||||
|
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timestamps
|
||||||
|
|
||||||
|
### Server-side
|
||||||
|
```typescript
|
||||||
|
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
||||||
|
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client-side
|
||||||
|
```typescript
|
||||||
|
// Timestamps are objects with BigInt, not numbers
|
||||||
|
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedures (Beta)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const fetch_data = spacetimedb.procedure(
|
||||||
|
{ url: t.string() }, t.string(),
|
||||||
|
(ctx, { url }) => {
|
||||||
|
const response = ctx.http.fetch(url);
|
||||||
|
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Procedures don't have `ctx.db` — use `ctx.withTx(tx => tx.db...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
||||||
|
import { DbConnection, tables } from './module_bindings';
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
const connectionBuilder = useMemo(() =>
|
||||||
|
DbConnection.builder()
|
||||||
|
.withUri('ws://localhost:3000')
|
||||||
|
.withDatabaseName('my_game')
|
||||||
|
.withToken(localStorage.getItem('auth_token') || undefined)
|
||||||
|
.onConnect((conn, identity, token) => {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
conn.subscriptionBuilder().subscribe(tables.player);
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
|
||||||
|
<App />
|
||||||
|
</SpacetimeDBProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerList() {
|
||||||
|
const [players, isReady] = useTable(tables.player);
|
||||||
|
if (!isReady) return <div>Loading...</div>;
|
||||||
|
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Server (`backend/spacetimedb/`)
|
||||||
|
```
|
||||||
|
src/schema.ts -> Tables, export spacetimedb
|
||||||
|
src/index.ts -> Reducers, lifecycle, import schema
|
||||||
|
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
|
||||||
|
tsconfig.json -> Standard config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client (`client/`)
|
||||||
|
```
|
||||||
|
src/module_bindings/ -> Generated (spacetime generate)
|
||||||
|
src/main.tsx -> Provider, connection setup
|
||||||
|
src/App.tsx -> UI components
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime start
|
||||||
|
spacetime publish <module-name> --module-path <backend-dir>
|
||||||
|
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||||
|
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||||
|
spacetime logs <module-name>
|
||||||
|
```
|
||||||
292
.codex/skills/spacetimedb-unity/SKILL.md
Normal file
292
.codex/skills/spacetimedb-unity/SKILL.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
---
|
||||||
|
name: spacetimedb-unity
|
||||||
|
description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence.
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: clockworklabs
|
||||||
|
version: "2.0"
|
||||||
|
tested_with: "SpacetimeDB 2.0, Unity 2022.3+"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SpacetimeDB Unity Integration
|
||||||
|
|
||||||
|
This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development and general C# SDK usage, see the `spacetimedb-csharp` skill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HALLUCINATED APIs — DO NOT USE
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// WRONG — these do not exist in Unity SDK
|
||||||
|
SpacetimeDBClient.instance.Connect(...); // Use DbConnection.Builder()
|
||||||
|
SpacetimeDBClient.instance.Subscribe(...); // Use conn.SubscriptionBuilder()
|
||||||
|
NetworkManager.RegisterReducer(...); // SpacetimeDB is not a Unity networking plugin
|
||||||
|
|
||||||
|
// WRONG — old 1.0 patterns
|
||||||
|
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
||||||
|
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Wrong | Right | Error |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Not calling `FrameTick()` | `conn?.FrameTick()` in `Update()` | No callbacks fire |
|
||||||
|
| Accessing `conn.Db` from background thread | Copy data in callback, use on main thread | Data races / crashes |
|
||||||
|
| Forgetting `DontDestroyOnLoad` | Add to manager `Awake()` | Connection lost on scene load |
|
||||||
|
| Connecting in `Update()` | Connect in `Start()` or on user action | Reconnects every frame |
|
||||||
|
| Not saving auth token | `PlayerPrefs.SetString(...)` in `OnConnect` | New identity every session |
|
||||||
|
| Missing generated bindings | Run `spacetime generate --lang csharp` | Compile errors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add via Unity Package Manager using the git URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Window > Package Manager > + > Add package from git URL**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generate Module Bindings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE
|
||||||
|
```
|
||||||
|
|
||||||
|
Place generated files in your Assets folder so Unity compiles them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SpacetimeManager Singleton
|
||||||
|
|
||||||
|
The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using UnityEngine;
|
||||||
|
using SpacetimeDB;
|
||||||
|
using SpacetimeDB.Types;
|
||||||
|
|
||||||
|
public class SpacetimeManager : MonoBehaviour
|
||||||
|
{
|
||||||
|
private const string TOKEN_KEY = "SpacetimeAuthToken";
|
||||||
|
private const string SERVER_URI = "http://localhost:3000";
|
||||||
|
private const string DATABASE_NAME = "my-game";
|
||||||
|
|
||||||
|
public static SpacetimeManager Instance { get; private set; }
|
||||||
|
public DbConnection Connection { get; private set; }
|
||||||
|
public Identity LocalIdentity { get; private set; }
|
||||||
|
|
||||||
|
void Awake()
|
||||||
|
{
|
||||||
|
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||||
|
Instance = this;
|
||||||
|
DontDestroyOnLoad(gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Start()
|
||||||
|
{
|
||||||
|
string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null);
|
||||||
|
|
||||||
|
Connection = DbConnection.Builder()
|
||||||
|
.WithUri(SERVER_URI)
|
||||||
|
.WithDatabaseName(DATABASE_NAME)
|
||||||
|
.WithToken(savedToken)
|
||||||
|
.OnConnect(OnConnected)
|
||||||
|
.OnConnectError(err => Debug.LogError($"Connection failed: {err}"))
|
||||||
|
.OnDisconnect((conn, err) => {
|
||||||
|
if (err != null) Debug.LogError($"Disconnected: {err}");
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Update()
|
||||||
|
{
|
||||||
|
Connection?.FrameTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnDestroy()
|
||||||
|
{
|
||||||
|
Connection?.Disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||||
|
{
|
||||||
|
LocalIdentity = identity;
|
||||||
|
PlayerPrefs.SetString(TOKEN_KEY, authToken);
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
|
||||||
|
Debug.Log($"Connected as: {identity}");
|
||||||
|
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.SubscribeToAllTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
|
||||||
|
{
|
||||||
|
Debug.Log("Subscription applied — game state loaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FrameTick — Critical
|
||||||
|
|
||||||
|
**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it:
|
||||||
|
- No callbacks fire (OnInsert, OnUpdate, OnDelete, reducer callbacks)
|
||||||
|
- The client appears frozen
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
void Update()
|
||||||
|
{
|
||||||
|
Connection?.FrameTick();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subscribing to Tables
|
||||||
|
|
||||||
|
Subscribe in the `OnConnected` callback:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||||
|
{
|
||||||
|
// ...save token...
|
||||||
|
|
||||||
|
// Development: subscribe to all
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.SubscribeToAllTables();
|
||||||
|
|
||||||
|
// Production: subscribe to specific tables
|
||||||
|
conn.SubscriptionBuilder()
|
||||||
|
.OnApplied(OnSubscriptionApplied)
|
||||||
|
.Subscribe(new[] {
|
||||||
|
"SELECT * FROM player",
|
||||||
|
"SELECT * FROM game_state"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Row Callbacks for Game State
|
||||||
|
|
||||||
|
Register callbacks to update Unity GameObjects when table data changes.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
void RegisterCallbacks()
|
||||||
|
{
|
||||||
|
Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||||
|
SpawnPlayerObject(player);
|
||||||
|
};
|
||||||
|
|
||||||
|
Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
||||||
|
DestroyPlayerObject(player.Id);
|
||||||
|
};
|
||||||
|
|
||||||
|
Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => {
|
||||||
|
UpdatePlayerObject(newPlayer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Calling Reducers from UI
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class GameUI : MonoBehaviour
|
||||||
|
{
|
||||||
|
public void OnMoveButtonClicked(Vector2 direction)
|
||||||
|
{
|
||||||
|
SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnSendChat(string message)
|
||||||
|
{
|
||||||
|
SpacetimeManager.Instance.Connection.Reducers.SendMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reducer Callbacks
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
||||||
|
if (ctx.Event.Status is Status.Committed)
|
||||||
|
Debug.Log($"Message sent: {text}");
|
||||||
|
else if (ctx.Event.Status is Status.Failed(var reason))
|
||||||
|
Debug.LogError($"Send failed: {reason}");
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading the Client Cache
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Find by primary key
|
||||||
|
if (Connection.Db.Player.Id.Find(playerId) is Player player)
|
||||||
|
{
|
||||||
|
Debug.Log($"Player: {player.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate all
|
||||||
|
foreach (var p in Connection.Db.Player.Iter())
|
||||||
|
{
|
||||||
|
Debug.Log(p.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by index
|
||||||
|
foreach (var p in Connection.Db.Player.Level.Filter(5))
|
||||||
|
{
|
||||||
|
Debug.Log($"Level 5: {p.Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count
|
||||||
|
int total = Connection.Db.Player.Count;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unity-Specific Considerations
|
||||||
|
|
||||||
|
### Main Thread Only
|
||||||
|
All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback.
|
||||||
|
|
||||||
|
### Scene Loading
|
||||||
|
Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene.
|
||||||
|
|
||||||
|
### IL2CPP / AOT
|
||||||
|
The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds:
|
||||||
|
- Ensure generated bindings are up to date
|
||||||
|
- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping
|
||||||
|
|
||||||
|
### Token Persistence
|
||||||
|
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. If the token is stale or invalid, the server issues a new identity and token in the `OnConnect` callback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
spacetime start
|
||||||
|
spacetime publish <module-name> --module-path <backend-dir>
|
||||||
|
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||||
|
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path <backend-dir>
|
||||||
|
spacetime logs <module-name>
|
||||||
|
```
|
||||||
@@ -22,7 +22,6 @@ tmp
|
|||||||
.env.secrets.*
|
.env.secrets.*
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
deploy/container/api-server.env
|
deploy/container/api-server.env
|
||||||
deploy/container/worker-smoke
|
|
||||||
|
|
||||||
server-rs/target
|
server-rs/target
|
||||||
server-rs/target-*
|
server-rs/target-*
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -37,14 +37,11 @@ temp*build*/
|
|||||||
/.app/
|
/.app/
|
||||||
/target/
|
/target/
|
||||||
/logs
|
/logs
|
||||||
/.codegraph/
|
|
||||||
/server-rs/crates/*/logs/
|
/server-rs/crates/*/logs/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.rag/
|
|
||||||
.env.secrets.local
|
.env.secrets.local
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
deploy/container/api-server.env
|
deploy/container/api-server.env
|
||||||
deploy/container/worker-smoke/
|
|
||||||
|
|
||||||
# Local load-test data extracted from private migration files
|
# Local load-test data extracted from private migration files
|
||||||
scripts/loadtest/data/*.local.json
|
scripts/loadtest/data/*.local.json
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
# Genarrative Hermes 工具目录
|
# Genarrative 团队 Hermes 共享记忆
|
||||||
|
|
||||||
本目录只保留 Hermes 专用的仓库级工具资源,例如 Hermes skills、plugins 和启用说明。项目知识本体、长期记忆、计划和 TODO 不再放在 `.hermes/`,统一迁移到 `docs/project-memory/`。
|
本目录用于在仓库内共享团队级 Hermes 上下文,供 3 名开发人员在各自本地 Hermes 中读取、更新和同步。
|
||||||
|
|
||||||
## 使用原则
|
## 使用原则
|
||||||
|
|
||||||
- `.hermes/` 中只保存 Hermes 工具运行或加载所需内容。
|
- `.hermes/` 中只保存可以进入 Git 的团队共享内容。
|
||||||
- 项目长期知识、架构约定、排障经验、协作规则、计划和 TODO 统一放在 `docs/project-memory/`。
|
|
||||||
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
|
- 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。
|
||||||
- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。
|
- 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。
|
||||||
|
- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。
|
||||||
- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。
|
- 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。
|
||||||
- 若 `.hermes/` 中的工具说明与代码或 `docs/` 冲突,以当前代码和最新 `docs/` 为准。
|
- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。
|
||||||
|
- 阶段性计划、一次性 TODO 和已关闭实验不再长期沉淀为仓库文档;仍有效内容应合并进 `docs/` 当前融合文档或 `.hermes/shared-memory/`。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.hermes/
|
.hermes/
|
||||||
├─ README.md # Hermes 工具目录说明
|
├─ README.md # 本说明
|
||||||
|
├─ shared-memory/
|
||||||
|
│ ├─ project-overview.md # 项目概览与当前技术路线
|
||||||
|
│ ├─ team-conventions.md # 团队协作约定
|
||||||
|
│ ├─ development-workflow.md # 开发、测试、提交流程
|
||||||
|
│ ├─ document-map.md # README / AGENTS / docs 阅读索引
|
||||||
|
│ ├─ decision-log.md # 长期决策记录
|
||||||
|
│ ├─ pitfalls.md # 踩坑与排障记录
|
||||||
|
│ └─ handoff-template.md # 任务交接模板
|
||||||
├─ skills/ # 仓库级 Hermes skills
|
├─ skills/ # 仓库级 Hermes skills
|
||||||
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
└─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin)
|
||||||
```
|
```
|
||||||
@@ -62,5 +71,22 @@ HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取
|
|||||||
在本仓库中开始复杂任务时,可以先对 Hermes 说:
|
在本仓库中开始复杂任务时,可以先对 Hermes 说:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请先读取 AGENTS.md 以及 docs/project-memory/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 docs/project-memory/shared-memory/ 对应文件。
|
请先读取 AGENTS.md 以及 .hermes/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 .hermes/shared-memory/ 对应文件。
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 需要沉淀到这里的内容
|
||||||
|
|
||||||
|
- 长期有效的架构约定
|
||||||
|
- 反复会用到的本地开发/测试流程
|
||||||
|
- 已确认的接口契约或模块边界
|
||||||
|
- 重要技术决策及原因
|
||||||
|
- 踩坑、排障方式、验证命令
|
||||||
|
- 团队协作规则和任务交接规范
|
||||||
|
|
||||||
|
## 不应沉淀到这里的内容
|
||||||
|
|
||||||
|
- API Key、Token、Cookie、私有密钥
|
||||||
|
- 个人账号、个人本地绝对路径、个人隐私信息
|
||||||
|
- 大段临时聊天记录
|
||||||
|
- 尚未确认的一次性猜测
|
||||||
|
- 构建产物、日志、缓存、数据库 dump
|
||||||
|
|||||||
@@ -16,46 +16,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
|
|
||||||
|
|
||||||
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。
|
|
||||||
- 决策:`.codex/skills/` 下只保留 `spacetimedb-cli`、`spacetimedb-concepts`、`spacetimedb-rust` 三个本地 SpacetimeDB skill;删除 `spacetimedb-typescript`、`spacetimedb-csharp`、`spacetimedb-unity`。前端 / Node 侧如需处理 SpacetimeDB 订阅或绑定,按当前生成绑定、项目代码和官方文档核对,不再依赖仓库内单独 TypeScript skill。
|
|
||||||
- 影响范围:`AGENTS.md` 的 SpacetimeDB skill 清单、`.codex/skills/` 本地 skill 维护范围、后续 SpacetimeDB 设计 / CLI / Rust module 开发协作口径。
|
|
||||||
- 验证方式:用上游 `clockworklabs/SpacetimeDB@master` 的 `skills/` 目录对照,运行本地 skill 校验、删除引用扫描、`git diff --check -- .codex/skills AGENTS.md .hermes/shared-memory/decision-log.md` 和 `npm run check:encoding`。
|
|
||||||
- 关联文档:`AGENTS.md`、`.codex/skills/spacetimedb-cli/SKILL.md`、`.codex/skills/spacetimedb-concepts/SKILL.md`、`.codex/skills/spacetimedb-rust/SKILL.md`。
|
|
||||||
|
|
||||||
## 2026-06-13 图片大图预览统一为黑底全屏查看器
|
|
||||||
|
|
||||||
- 背景:`CreativeImageInputPanel` 的参考图 / 主图预览曾使用白底 `UnifiedModal` 工具弹窗,移动端会透出原页面背景,且不能全屏查看、缩放或拖拽细节。
|
|
||||||
- 决策:纯图片大图预览统一使用 `src/components/common/PlatformImagePreviewModal.tsx`。该组件底层复用 `UnifiedModal` 的 dialog / portal / Escape 语义,但视觉上固定为黑底全屏查看器;图片按视口 contain 初始完整展示,缩放范围固定 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免露出背景。裁剪、选择、编辑等工具语义仍继续使用白底工具弹窗,不并入图片查看器。
|
|
||||||
- 影响范围:`CreativeImageInputPanel` 的参考图预览、主图预览,以及后续 common 级图片查看场景。
|
|
||||||
- 验证方式:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
|
||||||
- 关联文档:`docs/README.md`、`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
|
||||||
|
|
||||||
## 2026-06-13 外部生成队列概览归属“我的”页签
|
|
||||||
|
|
||||||
- 背景:外部生成 worker 队列从单个生成页等待信息扩展为当前账号级别的后台排队 / 生成概览;继续放在生成页 / 进度页会把账号级队列与当前玩法业务进度混在一起。
|
|
||||||
- 决策:移动端用户可见的外部生成队列概览统一放在一级 `我的` 页签;生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作。队列概览只读取 BFF `GET /api/runtime/external-generation/queue-overview` 与当前前端已知单 job 状态作为等待补充,不替代玩法 session/detail 的 ready / failed 回读。
|
|
||||||
- 影响范围:平台入口壳层轮询条件、`RpgEntryHomeView` 我的页卡片、共用生成页 `CustomWorldGenerationView` / `UnifiedGenerationPage`、外部生成 worker 技术文档和本地开发验证文档。
|
|
||||||
- 验证方式:生成页不出现“生成队列”区域;登录用户进入“我的”页且队列有 pending/running 或当前 job 为 queued/running/failed 时显示队列卡;退出登录或切换账号时不保留旧账号队列概览。前端验证运行 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/unified-creation/UnifiedGenerationPage.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
|
||||||
|
|
||||||
## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼
|
|
||||||
|
|
||||||
- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。
|
|
||||||
- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。
|
|
||||||
- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。
|
|
||||||
- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。
|
|
||||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
|
|
||||||
|
|
||||||
- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。
|
|
||||||
- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh <alias> sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。
|
|
||||||
- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。
|
|
||||||
- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。
|
|
||||||
- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。
|
|
||||||
|
|
||||||
## 2026-06-10 公开作品互动能力进入后台全局配置
|
## 2026-06-10 公开作品互动能力进入后台全局配置
|
||||||
|
|
||||||
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
|
- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。
|
||||||
@@ -72,13 +32,6 @@
|
|||||||
- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。
|
- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。
|
||||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-08 通用分享统一为作品分享卡片
|
|
||||||
|
|
||||||
- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。
|
|
||||||
- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL;微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail` 与 `work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。
|
|
||||||
- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`miniprogram/pages/web-view/`、`miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。
|
|
||||||
- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts`、`npm run test -- miniprogram/pages/share-grid/index.test.js`、`npm run test -- src/index.test.ts -t "mini program recommend runtime"`、`npm run typecheck`、`npm run check:encoding`。
|
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
|
||||||
## 2026-06-08 微信能力按领域收口
|
## 2026-06-08 微信能力按领域收口
|
||||||
|
|
||||||
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
|
- 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。
|
||||||
@@ -95,415 +48,6 @@
|
|||||||
- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation/<play>/*`、历史 `/api/runtime/<play>/agent/*` 与公开 runtime 路由外部契约不变。
|
- 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation/<play>/*`、历史 `/api/runtime/<play>/agent/*` 与公开 runtime 路由外部契约不变。
|
||||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口
|
|
||||||
|
|
||||||
- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。
|
|
||||||
- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。
|
|
||||||
- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。
|
|
||||||
- 2026-06-10 追加:标准泥点消耗确认弹窗统一收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;该 Module 专门承接“确认消耗泥点 + 消耗 N 泥点”的同形态确认骨架,当前已覆盖 `PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx` 与 `Match3DResultView.tsx`。后续遇到同形态泥点确认时,业务页只传点数、补充说明和确认回调,不再重复拼接 `UnifiedConfirmDialog` 正文;`RpgCreationRoleAssetStudioModalImpl` 这类节奏和内容结构不同的泥点弹层继续单独评估,留作后续轮次处理。
|
|
||||||
- 2026-06-10 追加:`RpgCreationRoleAssetStudioModalImpl.tsx` 的角色形象生成 / 动作草稿生成确认也并入 `PlatformMudPointConfirmDialog`;共享组件通过自定义 title 与补充说明承接工坊语义,工坊页不再单独维护 `UnifiedConfirmDialog` 的标准泥点文案骨架。后续同类“确认消耗泥点 + 补充说明”场景继续优先复用该 Module。
|
|
||||||
- 2026-06-10 追加:平台危险确认统一收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;该 Module 专门承接“确认 / 取消 + 危险主动作”的标准骨架,当前已覆盖 `PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认和 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认。后续删除、覆盖、清空等危险动作优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + confirmTone=\"danger\"` 组合。
|
|
||||||
- 2026-06-10 追加:平台未保存离开确认统一收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;该 Module 专门承接“继续编辑 + 确认离开”的标准骨架,当前已覆盖 `RpgCreationEntityEditorShared.tsx` 里的关闭未保存修改、生成结果未保存退出和普通结果未保存退出确认。后续同类未保存离开场景优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + cancelLabel=\"继续编辑\"` 组合和重复壳层 class。
|
|
||||||
- 2026-06-10 追加:平台单按钮已读状态统一收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;该 Module 专门承接“状态提示 + 知道了”的单按钮确认已读语义,当前已覆盖 `BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示。后续同类 status-dialog 场景优先复用该 Module,不再在业务页重复拼装 `action={{ label: '知道了', onClick: onClose }}`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
|
||||||
- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;`RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。
|
|
||||||
- 2026-06-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`。
|
|
||||||
- 2026-06-10 追加:`PlatformStatusDialog` 支持自定义图标、图标可访问标签以及动作按钮 surface / size / className 透传,用来承接玩法结果页里保留品牌视觉但语义仍是“状态结果弹层”的场景;大鱼吃小鱼结果页的发布失败弹层已迁移到这套组件,业务页不再保留 `UnifiedConfirmDialog + PlatformIconBadge` 的专用组合。
|
|
||||||
- 2026-06-10 追加:`PlatformStatusDialog` 继续支持 header notice 布局、body content、close button、backdrop / Escape 关闭路径,用来承接“提示 / 规则阻断 / 作品不可用 / 泥点不足”这类带标题栏的状态 notice;平台入口的 `draftGenerationPointNotice`、`workNotFoundRecoveryDialog` 和 RPG 大编辑器里的 `EditorNoticeDialog` 已迁移到这套共享组件,不再各自维护 `UnifiedConfirmDialog` 壳层和关闭策略。
|
|
||||||
- 2026-06-10 追加:`CustomWorldEntityCatalog` 的 `minimum-playable` 规则阻断提示也统一迁到 `PlatformStatusDialog`,不再和删除角色 / 批量删除共用 `UnifiedConfirmDialog` 配置;同日平台入口公开编号搜索把 error 分支从用户摘要 modal 中拆出,未命中结果单独走 `PlatformStatusDialog`,命中用户继续保留 `UnifiedModal + PlatformSubpanel` 信息布局。
|
|
||||||
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续从 profile modal 与作品架扩展到 RPG 首页公开分区;`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区已统一改成 `loadingState / emptyState / children` 三态 slot。页面级 `platformError` 继续留在状态壳外层,保证错误提示可以和内容并存;`recommend runtime`、分类筛选等含运行态或二级筛选语义的分支暂不硬并入这一轮。
|
|
||||||
- 2026-06-11 追加:暗色 / 像素 modal 的标准 footer 布局统一抽到 `src/components/common/PlatformDarkModalFooter.tsx`;该组件只负责 dark footer 的分隔线、padding 和常见动作区排布,不持有“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer,以及 `InventoryItemViews.tsx` 的详情 footer wrapper 已接入;sticky 工作台 footer、正文内单 CTA 收尾和 runtime HUD 工具条暂不并入这一抽象。
|
|
||||||
- 2026-06-11 追加:桌面首页里的轻量可点击扁平行开始统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;目前已覆盖 `RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行。组件只承接 `button + left content + right affordance` 结构、默认 `type="button"` 与 `leading / trailing` 插槽,暂不扩成覆盖教培 promo card、分类卡片、世界卡或 runtime 列表项的万能 row primitive。
|
|
||||||
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 profile 设置行;`src/components/platform-entry/PlatformProfilePrimitives.tsx` 的 `ProfileSettingsRow` 已改成委托共享 `button + leading + trailing` 骨架,继续保留本地 `platform-profile-settings-row` class 承接分隔线、icon 胶囊和字号微调。后续 profile / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 `<button>` 手写布局。
|
|
||||||
- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 RPG 首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx` 的 `PlatformRankingItem`、`PlatformCategoryGameItem` 已改成委托共享 `button + leading + body + trailing` 骨架,同时保留 `platform-ranking-item__*` 与 `platform-category-game-item__*` 局部 class 承接封面、metric、badge、摘要和右侧 `试玩 / 进入` affordance。后续首页 / 发现页里同类浅色导航行优先沿“共享骨架 + 本地皮肤 class”推进,不再为了这类 row 回退成原生 `<button>` 手写布局。
|
|
||||||
- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续补齐 RPG 首页分类分支;移动端“发现 -> 分类”、桌面发现页“分类”和桌面首页“作品分类”模块现在都统一委托共享状态壳切换外层 `loading / empty / content`,分类控制条与排序按钮继续留在内容 slot 中。筛选后无结果的“当前筛选下没有作品。”也统一改成内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自维护嵌套 ternary。
|
|
||||||
- 2026-06-11 追加:`PlatformDarkModalFooter` 不只收动作按钮区,也继续覆盖纯内容 footer;`CompanionCampModal.tsx` 底部“营地气氛”区域已改成 `layout="content"` + `padding="roomy"` 的共享 footer frame,保留原有文案和卡片布局,不再单独手写 `border-t border-white/10 px-5 py-4`。
|
|
||||||
- 2026-06-11 追加:`PlatformDarkModalFooter` 继续从标准双按钮 footer 扩到 detail / confirm 收尾;`NpcModals.tsx` 的交易详情 footer 和 `MapModal.tsx` 的场景切换确认 footer 已改成复用同一个 dark footer frame,即使只有单个“关闭”按钮也不再手写 `flex justify-end`。这条抽象继续只覆盖 dark / pixel modal 里的底部分隔线与常规动作区排布,不向白底 profile 弹窗 footer、sticky 工作台 footer 或运行态 HUD 工具条扩张。
|
|
||||||
- 2026-06-11 追加:`PlatformFilterToolbar.tsx` 作为薄结构组件收口 RPG 首页分类工具条;组件只承接“筛选按钮 + tabs + 排序按钮”的排布与 `mobile / desktop` 两种布局差异,不持有筛选状态、空态或排序逻辑。后续只有在同构壳层真的复现时才继续往 `common` 扩覆盖面;如果只是单页内局部重复、接口会越抽越胖,就优先退回文件内 helper。
|
|
||||||
- 2026-06-11 追加:`SquareImageCropModal.tsx` 的白底弹窗壳层改为复用 `UnifiedModal.tsx`,同时给 `UnifiedModal` 薄补 `titleId` 与 `closeIcon` 透传,让裁剪弹窗继续保留自定义 close icon、无 backdrop / Escape 关闭和两列 footer,而不把 `PlatformProfileModalShell` 这类带页面语义的壳层倒灌回 `common/`。这条规则适用于 `common` 级工具弹窗:先看 `UnifiedModal` 能不能承接,再决定是否需要新的薄壳。
|
|
||||||
- 2026-06-11 追加:`CreativeImageInputPanel.tsx` 里参考图预览、主图预览和移除图片确认都继续并回 `UnifiedModal` 体系:两个预览弹窗直接复用 `UnifiedModal`,删除确认直接复用 `UnifiedConfirmDialog`,不再在图片面板里手写三段 `platform-modal-backdrop + platform-modal-shell`。当前没有新增 `PlatformImagePreviewModal`,因为这批差异还只在尺寸与文案层,继续组合已有 modal 原语的 leverage 更高。
|
|
||||||
- 2026-06-11 追加:`src/components/common/PlatformUtilityInfoModal.tsx` 作为 `UnifiedModal` 之上的薄壳,统一承接 `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗骨架:平台主题 overlay、白底 panel,以及 body / footer 间距与标准 footer frame。该壳层不继续向上吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌 icon;后续 `common` 级工具信息弹窗若只是重复这套白底信息壳,优先复用 `PlatformUtilityInfoModal`,业务正文和 footer 交互继续留在调用方。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
- 2026-06-11 追加:profile 白底副弹层里的摘要头、列表骨架和内容行继续沉到 `PlatformProfileSummaryHeader.tsx`、`PlatformProfileSkeletonList.tsx` 与 `PlatformProfileContentRow.tsx`;这组组件只承接 `kicker + title + badge` 摘要层次、重复 skeleton 行以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或状态切换逻辑。后续 profile modal 若只是重复这三类白底内容骨架,优先复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
- 2026-06-11 追加:`PlatformProfileModalShell` 继续补齐标准 footer 插槽,直接透传 `UnifiedModal.footer` 与 `footerClassName`;`RpgEntryHomeView.tsx` 的昵称修改弹窗已改成标准 profile footer,不再把双按钮动作区手写在 body 末尾。后续个人中心里同类“表单内容 + 底部双按钮”弹窗优先走壳层 footer 接法。
|
|
||||||
- 2026-06-11 追加:`PlatformProfileModalShell` 的标准 footer 接法继续扩展到单 CTA 表单收尾;`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换按钮已迁到壳层 footer,body 只保留输入和反馈消息。`PlatformAsyncStatePanel` 同日继续扩展到 `PlatformAssetPickerGrid`、`VisualNovelSavePanel.tsx` 与 `AccountModal.tsx` 的账号安全三个子区块;其中公共素材网格继续把 `error` banner 放在状态壳外层,保持错误提示可与加载态或内容并存的原语义。
|
|
||||||
- 2026-06-11 追加:按钮层继续补齐轻量漏网项。`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留透明背景和原 chip 高度;`RpgEntryCharacterSelectView.tsx` 的两处“返回”按钮统一沉到局部 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`。同日 `GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已开始复用这套暖色生成页返回入口骨架;后续同类轻量返回按钮与 chip 删除按钮优先继续沿共享按钮 + 薄包装的方向推进。
|
|
||||||
- 2026-06-09 追加:通用输入 Composer 的上传参考图、发送和移除参考图已迁移到 `PlatformIconButton`;图标上传仍使用 `asChild="label"` 保留 label + file input 语义,公共组件会自动写入隐藏文本,确保内嵌 file input 继承可访问名称。
|
|
||||||
- 2026-06-10 追加:creation-agent composer 的上传文档 / 上传参考图入口使用 `PlatformIconButton` 默认 `platformIcon`;工作台只保留动态 label、title、busy 状态和 picker 回调,发送按钮继续保留主题色动作布局。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-10 追加:作品详情顶部返回 / 分享和封面轮播上一张 / 下一张入口使用 `PlatformIconButton variant="platformIcon"`;详情页保留原 `platform-work-detail__*` 局部 class 控制位置和尺寸,点赞、复制三态等专用动作暂不迁移。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-09 追加:通用输入 Composer 普通 panel 外壳迁移到 `PlatformSubpanel`,文本域迁移到 `PlatformTextField variant="textarea"`,读图错误迁移到 `PlatformStatusMessage surface="profile"`;浮动胶囊 Composer 保留专用外壳和 CSS 覆盖。
|
|
||||||
- 2026-06-10 追加:`PlatformStatusMessage` 根节点固定带 `platform-status-message` 类名,供业务测试断言公共状态条接入;RPG 大编辑器中的场景背景生成、作品封面生成和封面上传错误 / 成功提示先使用 `surface="tinted"` 加局部暗色 class 保留编辑器视觉,后续普通暗色编辑 / 运行面板状态提示统一迁入 `surface="editorDark"`。
|
|
||||||
- 2026-06-10 追加:`PlatformStatusMessage surface="editorDark"` 承接 RPG 暗色面板里的普通错误 / 成功 / 信息 / 警告 / 中性提示;背包故事档案 QA 提示、角色聊天错误提示、营地编组战斗中提示和自定义选择弹窗错误 / 生成中提示已迁移,业务 JSX 不再手写暗色 `border-*-300/15 bg-*-500/10 text-*-50/90` 状态条 chrome。
|
|
||||||
- 2026-06-10 追加:NPC 交易 / 赠礼 / 招募弹窗里的叙事提示使用 `PlatformStatusMessage surface="editorDark"`;弹窗只保留 introText 数据和业务 tone 选择,不再手写暗色提示条边框、底色、圆角、字号和换行 class。
|
|
||||||
- 2026-06-10 追加:creation-agent composer 错误条使用 `PlatformStatusMessage surface="platform"`;工作台只保留错误来源合并和局部外边距 / 圆角,不再手写红色边框、底色和文字 class。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 追加:creative-agent 首页错误提示使用 `PlatformStatusMessage tone="error" surface="platform" size="md"`;首页只保留宽度对齐局部 class 和错误文案,不再手写 danger panel chrome。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 追加:大鱼吃小鱼结果页发布校验阻断项使用 `PlatformStatusMessage tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-09 追加:通用创作图片面板中覆盖在图片或输入区上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,以及抓大鹅封面编辑中覆盖在封面图上的移除入口,使用 `PlatformIconButton variant="surfaceFloating"`;白底圆形 / 短标签浮动图标动作的 `border-white/80`、`bg-white/94`、`backdrop-blur`、hover 和禁用态不再在业务 JSX 中重复拼。
|
|
||||||
- 2026-06-10 追加:`PlatformIconButton variant="darkMini"` 承接覆盖在缩略图上的暗色小型图标动作;`PlatformUploadPreviewCard` 的 square 右上移除按钮已迁移到该 variant,上传预览卡不再手写黑底圆形移除按钮 chrome。
|
|
||||||
- 2026-06-09 追加:图片编辑面板中的白底胶囊开关统一使用 `src/components/common/PlatformPillSwitch.tsx` 承载 label + `role="switch"` 输入语义、轨道、圆点、白底浮层和禁用态;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移,业务页只保留受控布尔值和状态变更回调。
|
|
||||||
- 2026-06-09 追加:设置面板、结果页配置和工作台白底配置项里的整行开关统一使用 `src/components/common/PlatformToggleRow.tsx` 承载 label、checkbox、只读状态 pill、可选 icon、可选点击状态行、禁用态和 soft / plain 两类白底 surface;视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页只保留字段写回和点击动作。
|
|
||||||
- 2026-06-09 追加:公开编号搜索结果弹窗关闭按钮使用 `PlatformModalCloseButton variant="platformIcon"`,平台壳不再手写 `platform-icon-button` + 关闭文本。
|
|
||||||
- 2026-06-10 追加:RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`,暗色编辑器保留 `platform-icon-button` 视觉 token,但业务 JSX 不再手写关闭按钮 aria、默认 X 图标和禁用态拼接。
|
|
||||||
- 2026-06-10 追加:`PlatformModalCloseButton variant="editorDark"` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口,根节点固定带 `platform-modal-close-button--editor-dark` 稳定类名;自定义选择弹窗头部关闭按钮已迁移,并补齐 `aria-label`,业务 JSX 不再手写暗色关闭按钮边框、底色、hover 和默认 X 图标。验证命令:`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`PlatformModalCloseButton variant="pixel"` 承接 `UnifiedModal variant="pixel"` 头部圆形关闭入口;`UnifiedModal` 只选择 `platformIcon / pixel` 变体并保留 closeDisabled、Backdrop、Escape 和 portal 语义,不再手写 X 图标、aria 和关闭按钮 class。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`UnifiedModal` 新增 `closeVariant`、`closeOnEscape`、`titleClassName` 和 `descriptionClassName`,用于在收口标准平台弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、原有标题层级和“不响应 Escape / backdrop”的交互语义;RPG 首页个人中心里的昵称修改、账户充值、每日任务和兑换码弹窗已迁移到 `UnifiedModal`,支付结果 / 支付确认遮罩 / 泥点账单这类头部结构不同的弹窗继续保留专用实现。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`UnifiedModal` 新增 `showHeader`,用于收口不需要标准头部但仍要保留 dialog 无障碍语义、遮罩和层级控制的轻量弹窗;RPG 首页个人中心的支付结果提示与支付确认遮罩已迁移到 `showHeader={false}` 模式,业务页只保留 icon badge、文案与按钮,不再手写 backdrop、aria 和白底壳层。个人中心移动端顶栏“扫码”“打开设置”入口统一使用 `PlatformIconButton`,并继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置与主题色。验证命令:`npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformIconButton.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页发现页分类筛选弹窗和个人中心扫码面板改用 `UnifiedModal` 承接 backdrop、dialog 语义和层级;分类筛选保留本地选项 / 动作布局,扫码面板继续使用 `showHeader={false}` 保留深色自定义头部与摄像头 viewport,并显式维持 `closeOnBackdrop={false}`、`closeOnEscape={false}`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/UnifiedModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心泥点账单改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留渐变面板、`PlatformModalCloseButton variant="floating"`、余额 badge 与账单列表布局;账单继续显式维持 `closeOnBackdrop={false}`、`closeOnEscape={false}`,测试改为直接断言具名 dialog 和关闭后卸载。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "opens wallet ledger modal from narrative coin card|wallet ledger modal shows empty and error states" src/components/common/UnifiedModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心“玩过作品”面板改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PLAYED` kicker、总时长 badge、`PlatformModalCloseButton variant="floating"`、`可继续 / 玩过` 双分区与作品卡布局;存档入口继续留在同一个“玩过”面板内,不再回退成独立 `SAVE ARCHIVE` / `ARCHIVE` 壳层。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile played modal summary and work type use platform pill badges|profile played modal empty state uses platform empty state" src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel|profile page keeps save archives inside played stats panel" src/components/common/UnifiedModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心邀请相关弹层里的 live `community / redeem` 分支改用 `UnifiedModal showHeader={false}` 承接 `dialog` 语义和遮罩层级,同时保留 `PlatformModalCloseButton variant="floatingPlain"`、居中标题、社区二维码卡片、邀请码输入 / 已填写空态和成功 / 失败提示;历史 `invite` 分支没有新的入口,当前只随同一壳层维持现状。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login" src/components/common/UnifiedModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页个人中心昵称旁的铅笔入口改用 `PlatformIconButton`,继续保留 `.platform-profile-edit-button` 局部尺寸、边框和浅色底样式;昵称编辑入口不再手写原生 `<button>` 的 `type`、`aria-label` 和图标壳。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal uses platform text field and submits with Enter" src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-09 追加:RPG 大编辑器暗色面板内的保存和角色槽动作继续走本地 `ActionButton`,不再混用白底平台 `platform-button` class;平台白底动作收口和编辑器暗色动作收口保持两套视觉边界。
|
|
||||||
- 2026-06-10 追加:`PlatformActionButton surface="editorDark"` 承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,支持 `size="xxs"` 与 `tone="success" | "warning"`;`tone="accent"` 承接暗色壳层内的琥珀实心 CTA,`tone="accentSoft"` 承接依赖局部 accent 变量的柔和强调按钮。角色自定义 footer、自定义世界生成 footer、地图切换确认、营地编组普通动作和角色聊天刷新动作已迁移。暗色可选项卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。验证命令:`npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 首页创作 / 草稿顶栏的钱包快捷入口通过同文件 `TopbarWalletShortcutButton` 复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"` 与 `PlatformIconBadge`;移动端 / 桌面端继续保留 `.platform-mobile-create-wallet-chip`、`.platform-desktop-create-wallet-chip` 和 `.platform-desktop-search` 兼容 class,承接余额截断、桌面顶栏胶囊壳和既有测试锚点,点击语义仍统一走 `openRechargeOrRewardCodeModal`。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 大编辑器里的当前角色、可选角色、预设背景和场景连接关系等暗色信息面板通过本地 `EditorInfoPanel` 复用 `PlatformSubpanel surface="dark"`;有右侧动作的面板也只向适配器传 actions,不再在业务 JSX 中重复手写暗色面板边框、底色、圆角、标题行和内容间距。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
|
||||||
- 2026-06-10 追加:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `PlatformActionButton surface="platform" shape="pill" size="lg" fullWidth`;详情页保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
- 2026-06-10 追加:作品详情点赞按钮使用 `PlatformActionButton tone="accentSoft"`;详情页只保留纵向排布、尺寸和 `--platform-action-accent` 局部变量,不再手写点赞按钮边框、底色、文字和阴影 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
|
||||||
- 2026-06-09 追加:大鱼吃小鱼结果页白底平台动作迁移到 `PlatformActionButton shape="pill" size="xs"`;资产工坊关闭 / 生成正式图、关卡主图 / 待机 / 移动入口和场地背景生成只保留业务回调,深色 hero 返回 / 测试 / 发布按钮继续保留玩法品牌布局。
|
|
||||||
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `PlatformPillBadge tone="lightOverlay"`,并只保留局部 `bg-white/10` 覆盖;hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段白色静态标签。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`。
|
|
||||||
- 2026-06-10 追加:反馈页“查看反馈与投诉记录”这类页面内次级文本动作使用 `PlatformActionButton tone="ghost" shape="pill" size="xs"`;反馈页只保留提示回调,不再手写居中、字号、内边距和冷色文本按钮 class。验证命令:`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
- 2026-06-10 追加:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `PlatformActionButton tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 承接三列布局、移动端跨列、紧凑高度和玻璃底,同时保留点击 / 键盘冒泡拦截,避免触发整卡打开。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`。
|
|
||||||
- 2026-06-09 追加:敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗等白底 HUD 动作使用 `PlatformActionButton`,拼消消 runtime 白底错误条使用 `PlatformStatusMessage surface="platform"`;深色半透明游戏提示和强品牌按钮仍可保留 runtime 专用布局。
|
|
||||||
- 2026-06-10 追加:运行态短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast` 承接圆角、字号、阴影、色值和 `role="alert/status"` 语义;跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态短 toast 已迁移。玩法专属返回按钮、计分牌、蓄力提示和强品牌主按钮仍留在 runtime 壳层,不把位置和玩法资产耦合进公共 Module。验证命令:`npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`。
|
|
||||||
- 2026-06-09 追加:历史图片 / 历史素材 / 可引用素材选择统一使用 `src/components/common/PlatformAssetPickerCard.tsx` 中的 `PlatformAssetPickerCard` 与 `PlatformAssetPickerGrid`,由该 Module 承载缩略图、禁用态、选中态、边框、hover、主副文案、`ResolvedAssetImage` 壳层、错误态、读取态、空态和网格布局;拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移,业务页只传素材数组、素材地址、文案、可访问名称、surface、选中判断和选择回调。RPG 大编辑器等暗色弹窗使用 `surface="editorDark"`,不混用白底平台卡片视觉;场景横图通过 `imageShellClassName` 保留 16:9。
|
|
||||||
- 2026-06-09 追加:平台白底圆角输入框和文本域统一使用 `src/components/common/PlatformTextField.tsx` 承载 input / textarea 语义、基础边框、背景、内边距、字号 / 行高、密度和禁用态;同组下拉框使用 `PlatformSelectField` 复用同一输入 chrome。抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图结果页作品信息 / 关卡名称 / 智能修订输入,敲木鱼结果页作品标题 / 简介,敲木鱼创作工作台功德词条输入,creative-agent 模板确认调整弹层关卡数输入,拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题,以及视觉小说结果页音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段已先迁移,业务页只保留受控值、事件、可访问名称、占位符、选项和局部布局 class。同一面板内的主图上传和提示词参考图上传必须使用不同可访问名称,避免多个同名“上传参考图”入口让测试和读屏语义混淆;拼图关卡编辑中的描述参考图入口使用“上传描述参考图”。
|
|
||||||
- 2026-06-09 追加:通用创作图片输入面板的提示词文本域也使用 `PlatformTextField variant="textarea" density="roomy"`;图片面板只通过局部 class 保留高度、`pb-14` 和浮动参考图上传按钮避让,不再自己维护白底 textarea 边框、背景、字号和禁用态。
|
|
||||||
- 2026-06-09 追加:`PlatformTextField` / `PlatformSelectField` 的 `tone="warm" | "rose" | "emerald"` 统一承接平台表单焦点色;视觉小说创作工作台、统一抓大鹅创作工作台、汪汪声浪轻配置编辑器和宝贝识物工作台普通输入 / 文本域 / 下拉框已先迁移,玩法调性焦点色通过 tone 表达,不在业务 JSX 中重复拼 `focus:border-* focus:ring-*`。
|
|
||||||
- 2026-06-10 追加:`PlatformTextField` / `PlatformSelectField` 支持 `surface="editorDark"` 和 `tone="sky"`,承接 RPG 暗色弹窗 / 运行面板里的普通输入框、文本域、下拉框、禁用态、密度、字号和焦点色;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿已迁移,业务 JSX 不再手写暗色 `border-white/10 bg-black/30 px-4 py-3` 或 `focus:border-*` 输入 chrome。验证命令:`npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`PlatformTagEditor` 内部新增标签输入框也使用 `PlatformTextField density="compact" size="xs"`;标签编辑器只保留新增状态、解析、Enter / Escape 行为和按钮组合,不再手写白底 input chrome。
|
|
||||||
- 2026-06-10 追加:认证图形验证码答案输入使用 `PlatformTextField density="compact"`;验证码组件只保留 challenge 展示、答案受控值和变更回调,不再手写 `platform-input` 输入框 chrome。
|
|
||||||
- 2026-06-10 追加:认证入口的短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段使用 `PlatformTextField surface="platform"` 与 `PlatformFieldLabel variant="form"`;认证业务组件只保留受控值、登录 / 绑定流程、原生 input 属性和校验提示,字段可访问名称继续由外层原生 `label` 承接,不再手写 `platform-input` 或表单标题 class。
|
|
||||||
- 2026-06-10 追加:个人中心兑换码和邀请兑换输入使用 `PlatformTextField surface="platform"`;业务组件只保留兑换 / 邀请码提交、归一化、大写展示、Enter 提交和原生可访问名称,不再手写 `platform-profile-input` 或白底 input chrome。
|
|
||||||
- 2026-06-10 追加:个人中心昵称弹窗输入框使用 `PlatformTextField surface="editorDark" size="lg" density="roomy"`;业务组件保留原生 `label` / sr-only “新昵称”、`autoFocus`、`maxLength`、Enter 提交、昵称校验和保存流程,不再手写暗色 input chrome。
|
|
||||||
- 2026-06-10 追加:平台反馈页问题描述和联系电话字段使用 `PlatformTextField surface="platform"`,标题使用 `PlatformFieldLabel variant="form"`;反馈页保留外层原生 label、受控值、长度限制、透明嵌入式局部 class 和提交校验,不再手写 textarea / input / 字段标题 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-09 追加:平台字段标签统一使用 `src/components/common/PlatformFieldLabel.tsx` 承载 `field`、`section`、`form`、`pill` 与 `accentPill` 五类字段标题视觉;视觉小说结果页、汪汪声浪轻配置编辑器和宝贝识物工作台已先迁移,业务页只保留字段文案和必要局部布局 class,不再重复拼普通字段名、分区标题、表单标题、普通胶囊和强调胶囊 class。
|
|
||||||
- 2026-06-10 追加:通用创作图片输入面板的主图标题和提示词标题使用 `PlatformFieldLabel variant="form"`;提示词字段保留外层原生 `label htmlFor`,业务组件只保留字段文案、布局和上传 / 生成交互,不再手写 `mb-2 block text-sm font-black` 标题 class。
|
|
||||||
- 2026-06-10 追加:个人中心存档 / 玩过弹窗里的简单空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,玩过弹窗的“可继续 / 玩过”分区标题使用 `PlatformFieldLabel variant="section"`,已玩作品白底按钮卡使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="md" interactive`;`SaveArchiveCard` 因含图片遮罩和加载态暂不并入本轮。
|
|
||||||
- 2026-06-10 追加:creative-agent 首页抽屉无创作记录使用 `PlatformEmptyState surface="subpanel" size="inline"`;抽屉只保留历史记录分组和点击行为,不再手写 bordered empty chrome。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-10 追加:平台入口壳纯 Suspense fallback 使用 `PlatformSubpanel radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;带恢复动作、错误语义或运行态遮罩的提示面板不和纯加载 fallback 同批迁移。
|
|
||||||
- 2026-06-10 追加:平台入口作品详情读取 / 错误提示、Agent 工作区恢复提示和生成结果恢复面板也迁移到 `PlatformSubpanel`;普通提示使用 `radius="sm" padding="none"`,带恢复动作的 `CreationResultRecoveryPanel` 使用 `radius="xl" padding="none"`,玩法 runtime overlay 继续保留专用层级语义。验证命令:`npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`。
|
|
||||||
- 2026-06-10 追加:RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `PlatformSubpanel radius="sm" padding="none"`;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 不并入该普通提示面板规则。验证命令:`npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:个人中心钱包账单弹窗的“暂无账单记录”使用 `PlatformEmptyState surface="subpanel" size="inline"`,账单行使用 `PlatformSubpanel as="div" surface="flat" radius="xs" padding="none"`;业务 JSX 只保留来源、时间、收支色值、余额右对齐和局部间距 / 阴影。
|
|
||||||
- 2026-06-10 追加:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `PlatformSubpanel`,简单空态使用 `PlatformEmptyState`,小标题使用 `PlatformFieldLabel variant="section"`;外层弹窗、query 自动打开、复制邀请和提交邀请码状态机不随 UI chrome 收口改动。
|
|
||||||
- 2026-06-10 追加:个人中心邀请弹窗里的邀请奖励说明使用 `PlatformStatusMessage tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`。
|
|
||||||
- 2026-06-10 追加:个人中心任务中心任务条目使用 `PlatformSubpanel radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
|
|
||||||
- 2026-06-10 追加:个人中心充值弹窗微信 Native 支付二维码确认面板使用 `PlatformSubpanel radius="sm" padding="md"`;业务组件只保留二维码生成、扫码展示和确认支付按钮流程。
|
|
||||||
- 2026-06-10 追加:个人中心充值弹窗商品整卡按钮使用 `PlatformSubpanel as="button" surface="platform" radius="sm" padding="none" interactive`;商品标题、金额、角标、购买中态和购买回调留在业务组件,按钮壳、hover、focus、默认 type 与 disabled chrome 归公共组件。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"`、`npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:个人中心充值商品卡里的“购买 / 处理中”胶囊暂不抽共享组件;该胶囊位于 `PlatformSubpanel as="button"` 内部,直接复用 `PlatformActionButton` 会形成嵌套交互,当前也还没有第二个同形态的非交互 action chip 证明需要单独沉淀共享展示基元。
|
|
||||||
- 2026-06-09 追加:抓大鹅结果页作品信息、发布封面和物品素材详情中的 section 字段标题迁移到 `PlatformFieldLabel variant="section"`;业务页不再重复拼 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]`。
|
|
||||||
- 2026-06-09 追加:方洞结果页主信息、形状选项、洞口选项和历史生成标题迁移到 `PlatformFieldLabel variant="section"`;业务页只保留字段文案、图标和按钮布局,不再重复拼 section 标题 class。
|
|
||||||
- 2026-06-09 追加:拼图结果页关卡详情的“关卡名称”和发布弹窗的“发布检查 / 封面关卡”标题迁移到 `PlatformFieldLabel variant="section"`;业务页保留 label 关联和弹窗布局,不再重复拼 section 标题 class。
|
|
||||||
- 2026-06-09 追加:拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt 和 RPG 发布弹窗发布检查 / 封面设置迁移到 `PlatformFieldLabel variant="section"`;业务组件内不再直接出现 `text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]` section 标题 class,后续同类标题只从公共 Module 扩展。
|
|
||||||
- 2026-06-09 追加:平台白底分段 Tab / 二选一统一使用 `src/components/common/PlatformSegmentedTabs.tsx` 承载选项、当前 id、变更回调、响应式列数、尺寸、圆角、surface、截断标签、禁用态和 `aria-pressed`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移,业务页不再重复拼 `grid + border + bg-white/62 + button aria-pressed`。
|
|
||||||
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="four"`、`size="choice"`、`tone="warm" | "rose"`、`surface="transparent"` 和 `frame="bare"`,用于承接创作 / 结果页里的四选一配置项;抓大鹅创作工作台和结果页难度选择已迁移,业务页只保留难度选项、当前值和派生回调。
|
|
||||||
- 2026-06-09 追加:`PlatformSegmentedTabs` 支持 `columns="one"`、`size="tab"`、`tone="underline"` 和 `semantics="tabs"`,用于承接认证入口短信 / 密码登录切换的真实 Tab 语义;认证页不再维护本地 `LoginTabButton`、`role="tab"`、`aria-selected` 和下划线选中态。登录入口不可用的白底提示也迁移到 `PlatformSubpanel`。
|
|
||||||
- 2026-06-09 追加:平台结果页统计小卡和轻量状态 chip 统一使用 `src/components/common/PlatformStatGrid.tsx` 承载 `items`、响应式列数、密度、surface、对齐和 label/value 顺序;拼消消结果页素材摘要、方洞结果页封面状态 chip 和抓大鹅结果页难度摘要已迁移,业务页不再重复拼统计卡 `grid + rounded + bg-white/* + text-xl/text-xs`。
|
|
||||||
- 2026-06-09 追加:平台单个胶囊状态 / 标签 chip 统一使用 `src/components/common/PlatformPillBadge.tsx` 承载 tone、尺寸、图标、圆角、边框、底色和字号;宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 过程计数 / 条目 meta chip、通用音频输入面板限制标签、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、汪汪声浪生成页和通用生成页右上状态 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、创作类型弹层锁定 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge 和生成失败 chip 已先迁移,业务页不再重复拼 `rounded-full border bg-* text-* px-* py-*`。多项数值 / 标签摘要仍归 `PlatformStatGrid`,可交互标签编辑仍归 `PlatformTagEditor`。
|
|
||||||
- 2026-06-09 追加:`PlatformPillBadge` 支持 `profile` / `profileAccent` 个人中心玫瑰色 chip tone;泥点账单余额、玩过总时长和玩过作品类型 chip 已迁移,个人中心后续轻量状态 / 分类胶囊不再在业务 JSX 中重复拼 rose / zinc 胶囊 class。
|
|
||||||
- 2026-06-10 追加:`PlatformPillBadge` 支持 `neutralSolid` 实心中性 tone,承接无强调的只读状态胶囊;`PlatformToggleRow mode="status"` 的开启 / 关闭状态已迁移到 `platformPillBadgeModel`,整行开关不再手写中性 pill class。
|
|
||||||
- 2026-06-10 追加:`PlatformPillBadge` 支持 `lightOverlay` 浅色叠层 tone,承接主动作按钮内部的泥点消耗等小胶囊;通用创作图片面板和抓大鹅创作工作台提交按钮内的消耗标签已迁移,业务 JSX 不再手写 `rounded-full bg-white/24 px-2 py-0.5`。
|
|
||||||
- 2026-06-10 追加:`PlatformPillBadge` 支持 `size="xxs"` 承接密集目录元信息 chip;自定义世界实体目录的新生成、生成中进度、开局 CG 消耗 / 时长 / 已生成、批量删除已选数量和可扮演角色元信息 chip 已迁移,实体目录不再手写 `platform-pill platform-pill--* px-2.5 py-1 text-[10px]`。
|
|
||||||
- 2026-06-10 追加:creative-agent 工作台顶部阶段状态 chip 迁移到 `PlatformPillBadge tone="cool" size="xs"`;工作台只保留阶段枚举到文案的映射,不再手写 `platform-pill platform-pill--cool` 外观。
|
|
||||||
- 2026-06-10 追加:RPG 首页公开作品卡标签、趋势卡标签、公开作品搜索结果类型、充值商品角标、移动端创建入口、桌面发现 hero / 今日 / 最近作品 / 最近浏览 chip 迁移到 `PlatformPillBadge`,首页不再手写 `platform-pill platform-pill--neutral / warm / cool`。
|
|
||||||
- 2026-06-10 追加:RPG 世界详情页的发布状态、主题、作者、发布时间 / 可见性和展示标签等静态元信息 chip 迁移到 `PlatformPillBadge`;作品号复制和分享入口仍保留 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态。
|
|
||||||
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionAppearance="pill"`,`CopyCodeButton` 透传同一入口,并复用 `platformPillBadgeModel.ts` 的 `getPlatformPillBadgeClassName` 视觉 chrome;可点击复制 / 分享胶囊 chip 不再在业务 JSX 中手写 `platform-pill`,RPG 世界详情作品号复制 / 分享入口和抓大鹅批量新增 / 重生成物品名称预览已迁移。
|
|
||||||
- 2026-06-10 追加:平台作品详情页主题标签使用 `PlatformPillBadge tone="neutralSolid" size="sm"`,作品号复制按钮使用 `CopyCodeButton actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留标签映射、作品号复制状态和顶部外边距,不再手写 `platform-work-detail__chip / code` 基础 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`。
|
|
||||||
- 2026-06-10 追加:平台作品详情页分享复制反馈使用 `PlatformStatusMessage surface="platform"`,按 `shareState` 映射 `success / error`;详情页保留 `useCopyFeedback` 状态机和文案,不再让失败态复用成功 toast chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 追加:平台错误弹窗和生成完成弹窗的“字段展示 + 复制整段报告”能力统一收口到 `src/components/common/PlatformReportDialog.tsx`;`PlatformErrorDialog` 与 `PlatformTaskCompletionDialog` 只保留标题、字段语义和错误黑名单过滤,不再各自组合 `UnifiedModal`、`PlatformInfoBlock`、`CopyFeedbackButton` 与 `useCopyFeedback`。验证命令:`npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`CopyFeedbackButton` 支持 `actionShape`,用于共享复制状态按钮直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情页 hero 的分享按钮已使用 `actionSurface="editorDark" actionShape="pill"`,修改作品 / 进入第 1 关动作使用 `PlatformActionButton`,返回和封面轮播前后按钮使用 `PlatformIconButton darkMini`。验证命令:`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
|
||||||
- 2026-06-10 追加:creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`,但继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏和抽屉品牌视觉;收口按钮语义时不强行同时抹平定制视觉。验证命令:`npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx`。
|
|
||||||
- 2026-06-10 追加:像 `creative-agent-drawer__history-item` 这种纯文本轻量列表行,当前不为了单点场景单独新建共享组件;现阶段优先沿用 `PlatformActionButton` 承接动作行、`PlatformSubpanel as="button" interactive` 承接有壳列表行,等出现更多同构透明列表行再评估独立 row primitive。
|
|
||||||
- 2026-06-10 追加:绑定手机号页左侧“当前登录身份”提示块迁移到 `PlatformSubpanel radius="sm" padding="md"`;认证页只保留身份文案和绑定流程,不再手写 `platform-subpanel` 信息块壳。验证命令:`npm run test -- src/components/auth/BindPhoneScreen.test.tsx`。
|
|
||||||
- 2026-06-10 追加:大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton darkMini`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布状态和提交语义,不再手写 hero 顶栏按钮壳。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-10 追加:`PlatformPillBadge` 支持 `darkSoft` / `darkNeutral` / `darkSky` / `darkEmerald` / `darkAmber` / `darkRose` 暗色 tone,用于 RPG 暗色弹窗和角色详情里的纯展示 chip;角色身份 / 等级、技能列表出手方式、技能详情方式 / 风格 / 状态标签、地图节点方向标签、地图场景切换方向标签和营地编组状态数值已迁移。暗色动作按钮、runtime HUD、属性加成动态 pill 和按钮内部消耗 chip 暂不直接套静态 badge。
|
|
||||||
- 2026-06-10 追加:背景故事已解锁 / 需好感状态和好感等级 badge 也使用 `PlatformPillBadge` 的 `dark*` tone;好感进度时间轴刻度、runtime HUD 和带点击卡片视觉的标签仍保留专用布局。
|
|
||||||
- 2026-06-10 追加:RPG 角色资产工作室动作列表的生成中 / 已生成 / 待生成状态 chip 直接使用 `PlatformPillBadge` 的 `darkAmber` / `darkEmerald` / `darkNeutral` tone;父弹窗不再维护本地 `StatusBadge` 浅封装,动作生成按钮仍保留工作室专用暗色按钮布局。
|
|
||||||
- 2026-06-10 追加:NPC 交易物品数量、赠礼好感增量和背包工坊材料需求状态使用 `PlatformPillBadge` 的 `dark*` tone;这些只是纯展示 chip,交易 / 赠礼列表按钮和工坊锻造 / 合成动作按钮继续保留各自交互布局。
|
|
||||||
- 2026-06-10 追加:RPG 角色编辑器技能列表里的动作已生成 / 待生成动作状态直接使用 `PlatformPillBadge` 的 `darkEmerald` / `darkNeutral` tone;本地 `StatusBadge` 浅封装删除,技能编辑按钮卡片仍保留原有点击布局。
|
|
||||||
- 2026-06-10 追加:RPG 角色编辑器两处重复的已应用主图 / 已应用动作 chip 合并为局部 `RoleAssetAppliedBadges`,内部复用 `PlatformPillBadge darkEmerald / darkAmber`;场景角色选择列表的选择 / 已选中和地标连接列表的当前连接也使用 `PlatformPillBadge dark*`,但外层按钮卡片仍保留原交互语义。
|
|
||||||
- 2026-06-10 追加:RPG 作品封面来源状态使用 `PlatformPillBadge darkNeutral`,角色开局物品标签合并为局部 `RoleInitialItemTagBadges` 并复用 `PlatformPillBadge darkNeutral`;物品编辑弹窗和开局物品列表不再重复维护标签 chip class。
|
|
||||||
- 2026-06-10 追加:RPG 世界地图节点中的当前状态使用 `PlatformPillBadge tone="muted"` 复用平台白底柔和 badge chrome;地图节点位置、连线和整体卡片仍保留地图专用布局。
|
|
||||||
- 2026-06-10 追加:媒体 / 舞台预览上的非交互悬浮短标签使用 `src/components/common/PlatformOverlayBadge.tsx`,复合控件内部的紧凑槽位编号使用 `src/components/common/PlatformSlotBadge.tsx`;RPG 场景幕预览左上幕标签和每幕角色槽位“主 / 2 / 3”已迁移。普通状态 chip 继续使用 `PlatformPillBadge`,外层按钮卡片、人物舞台位置和运行态 HUD 不迁入这两个小 Module。
|
|
||||||
- 2026-06-10 追加:拼图结果页智能修订条的白底图标圆槽使用 `PlatformIconBadge tone="soft" size="sm"`,外层编辑条使用 `PlatformSubpanel radius="lg"`;结果页只保留提交、禁用和错误提示语义,不再手写 `platform-subpanel rounded-[1.35rem] p-3 sm:p-4` 或 `hidden h-9 w-9 rounded-full bg-white/72`。
|
|
||||||
- 2026-06-10 追加:拼图结果页关卡卡片外壳使用 `PlatformSubpanel radius="lg" padding="none"`,关卡列表只保留图片、生成中状态、标题打开和删除动作,不再手写 `platform-subpanel overflow-hidden rounded-[1.35rem] p-0`。
|
|
||||||
- 2026-06-10 追加:`PlatformOverlayBadge` 支持 `tone="muted"`、`size="compact"` 和 `offset="tight"`,用于素材缩略图右上角“占位图”等紧凑非交互浮层;宝贝识物结果页占位资源标记已从绝对定位的 `PlatformPillBadge` 迁移到 overlay badge。
|
|
||||||
- 2026-06-10 追加:`PlatformSlotBadge` 支持 `tone="soft"` 和 `size="md"`,用于 creative-agent 阶段时间线的白底柔和步骤圆点;阶段卡片本体与 active / done / idle 语义仍保留在 `CreativeAgentStageTimeline`。
|
|
||||||
- 2026-06-10 追加:物品格、奖励格等缩略图右下角数量使用 `src/components/common/PlatformQuantityBadge.tsx`;背包物品格和 RPG 冒险面板 / 覆盖层奖励物品数量已迁移。该 Module 只承接数量角标 chrome,物品按钮、稀有度边框、选中态和详情弹窗仍归业务 Module。
|
|
||||||
- 2026-06-10 追加:RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等暗色纯展示 chip 使用 `PlatformPillBadge dark*`;任务 presentation / 日志状态只返回语义 tone,不再直接返回整段 `border / bg / text` class。运行态动作按钮、任务面板打开按钮和带 hover / click 语义的胶囊仍保留专用布局。任务日志状态补充验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`。
|
|
||||||
- 2026-06-10 追加:RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等暗色纯展示 chip 使用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;角色面板只保留标签数和 multiplier 计算,不再手写这些胶囊 chrome。
|
|
||||||
- 2026-06-10 追加:RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 使用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
|
|
||||||
- 2026-06-10 追加:自定义世界实体目录里的基础设定词条标签使用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
|
|
||||||
- 2026-06-10 追加:RPG 实体编辑器基本设定里的拆分标签也使用 `PlatformPillBadge darkSoft`;编辑器只保留字段草稿、文本解析和保存逻辑,不再手写暗色静态 tag chrome。
|
|
||||||
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡;任务目标、区域、进度、描述、角色维度和角色形象状态已先迁移。暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板继续保留业务布局。
|
|
||||||
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态里带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移。地图场景切换当前 / 前往摘要、营地编组分区、同行者卡和营地气氛小卡走 `surface="dark"` 非强调信息卡。后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`,普通暗色信息卡不再手写 `border-white/* bg-black/*`。
|
|
||||||
- 2026-06-10 追加:自定义选择弹窗当前角色信息块使用 `PlatformSubpanel surface="dark"`;弹窗只保留角色标签文案,不再手写 `rounded-2xl border border-white/10 bg-black/20 px-4 py-3` 暗色纯展示块。验证命令:`npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 队伍面板和实体详情弹窗里的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接;标签概览、属性加成明细和无明细提示组合 `PlatformSubpanel surface="dark"`,业务弹窗只保留选中状态和属性 rows,不再复制同一段标签效果暗色面板 JSX。
|
|
||||||
- 2026-06-10 追加:`CharacterInfoShared.CharacterSkillsList` 的空态使用 `PlatformEmptyState surface="editorDark"`,可点击和只读技能卡使用 `PlatformSubpanel surface="dark"`;角色信息共享模块只保留技能 render id、选择回调、数值字段和标签展示语义,不再手写技能空态 / 技能卡暗色外壳。验证命令:`npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 验证补充:共享构筑状态标签外壳收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 实体详情弹窗的物品空态使用 `PlatformEmptyState surface="editorDark"`,技能预览 fallback、技能数值卡、技能说明和附带状态标签区使用 `PlatformSubpanel surface="dark"`;实体详情只保留技能 / 物品数据和业务文案,不再手写这些暗色小卡 chrome。
|
|
||||||
- 2026-06-10 追加:RPG 实体详情弹窗最近回响中的后果、编年、载体和场景残留纯展示卡使用 `PlatformSubpanel surface="dark"`;实体详情只保留 story memory / 场景 residue 数据映射,队友收束等强调态继续保留业务语义样式。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "最近回响|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 追加:RPG 实体详情弹窗本地 `Section` 适配到 `PlatformSubpanel surface="dark"`;立绘、关系、私聊、最近回响、属性、技能和物品等主分区只保留标题与内容插槽,不再由业务组件维护 `rounded-2xl border border-white/8 bg-black/20 p-4` 外壳。验证命令:`npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 追加:RPG 冒险统计弹窗的总览和统计卡使用 `PlatformSubpanel surface="dark"`;统计弹窗只保留统计字段、图标和总览文案,设置弹窗里的 range input、保存退出按钮和入口按钮继续保留运行态专用交互布局。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 追加:RPG 覆盖层里的任务完成领奖提示、任务奖励缓存、战斗结束提示、战利品缓存和奖励物品详情描述 / 效果 / 标签使用 `PlatformSubpanel surface="dark"`,战斗结算敌人名使用 `PlatformPillBadge darkEmerald`;覆盖层只保留奖励数据、物品选择和弹窗层级语义,不再手写奖励缓存暗色面板和敌人名胶囊 chrome。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones"`。
|
|
||||||
- 2026-06-10 追加:RPG 覆盖层里的任务摘要卡和任务奖励条使用 `PlatformSubpanel surface="dark"`,奖励条内物品数量使用 `PlatformQuantityBadge`;覆盖层只保留任务文案、奖励数据和物品选择语义,不再手写任务摘要 / 奖励条暗色外壳或数量角标 chrome。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest reward strip|supports dark compact subpanel cards|renders a dark bottom-right quantity badge"`。
|
|
||||||
- 2026-06-10 追加:RPG 覆盖层里的任务奖励好感度、货币和经验数值卡使用 `PlatformSubpanel surface="darkRose" | "darkAmber" | "darkSky"`;覆盖层不再手写三套 `rounded-xl border bg-* px-3 py-2.5` 数值卡 chrome,也不再通过局部 class 覆盖 tint 调性。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 角色详情弹窗的装备格、背包格、旅程原因 / 目标、背景和性格小卡使用 `PlatformSubpanel surface="dark"`,候选人和性别静态 badge 使用 `PlatformPillBadge dark*` tone;角色详情只保留资料、属性、技能和动画展示语义,立绘框与属性网格暂保留原布局。验证命令:`npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-10 追加:RPG 角色面板详情里的个人线阶段、背景故事、性格纯展示块和装备行使用 `PlatformSubpanel surface="dark"`;角色面板只保留选中成员、个人线状态、展示文本和装备字段映射,像素外层面板与动作入口继续保留业务布局。验证命令:`npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-10 追加:好感状态卡的等级摘要和好感进度外壳使用 `PlatformSubpanel surface="dark"`;好感卡只保留等级推导、进度刻度和文案,不再手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色面板 chrome。验证命令:`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/AffinityStatusCard.test.tsx`。
|
|
||||||
- 2026-06-10 追加:背景故事公开印象、已解锁章节和锁定章节外壳使用 `PlatformSubpanel surface="dark"`,无背景线索空档案使用 `PlatformEmptyState surface="editorDark"`;背景档案只保留章节状态、好感阈值和故事文案,不再手写这些暗色小卡 / 空态 chrome。验证命令:`npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx`。
|
|
||||||
- 2026-06-10 追加:NPC 交易弹窗的数量 stepper 外壳、库存计数条、详情容器和总价卡使用 `PlatformSubpanel surface="dark"`;交易弹窗只保留交易数量、库存、价格和禁用原因语义,交易物品 / 礼物 / 招募可选列表按钮改由 `PlatformDarkOptionCard` 承接暗色 selected / idle / hover chrome。验证命令:`npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 追加:背包文书、故事档案和工坊分区外壳,以及文书按钮、故事档案条目和工坊配方卡使用 `PlatformSubpanel surface="dark"`;工坊材料需求状态使用 `PlatformPillBadge dark*` tone,故事档案 QA 提示使用 `PlatformStatusMessage surface="editorDark"`。锻造 / 合成动作按钮继续保留业务交互布局。验证命令:`npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`。
|
|
||||||
- 2026-06-10 追加:NPC 交易详情里的装备位、即时使用和标签属性格使用 `PlatformSubpanel surface="dark" padding="row"`,使用效果提示使用 `PlatformStatusMessage surface="editorDark"`;物品详情弹窗只保留物品属性、效果和标签计算,不再手写 `rounded-lg border border-white/8 bg-black/20 px-3 py-2` 或 emerald 提示条 chrome。
|
|
||||||
- 2026-06-10 追加:新增 `PlatformDarkOptionCard` 承接 RPG 暗色弹窗 / 面板中的可选项按钮卡 selected / idle / hover / disabled chrome;NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格和营地编组替换位按钮已迁移。业务组件只保留选中判断、tone、点击回调和卡片内容,不再手写 `rounded-* border px-3 py-*`、`border-*-400/* bg-*-500/10` 或 `border-white/* bg-black/20 hover:border-white/15`。
|
|
||||||
- 2026-06-10 追加:角色聊天弹窗的状态 / 总结卡使用 `PlatformSubpanel surface="dark"`,空聊天记录使用 `PlatformEmptyState surface="editorDark"`,建议回复按钮使用 `PlatformDarkOptionCard tone="sky"`;弹窗只保留角色状态、聊天记录和建议语义,不再手写这些暗色信息卡、空态或建议按钮 chrome。验证命令:`npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
|
||||||
- 2026-06-10 追加:拼图首访 onboarding 提示词文本域使用 `PlatformTextField surface="editorDark"`,输入错误和登录保存错误使用 `PlatformStatusMessage surface="editorDark"`,生成 / 登录 CTA 使用 `PlatformActionButton surface="editorDark" tone="accent"`,跳过按钮使用 `PlatformActionButton surface="editorDark" tone="ghost" shape="pill"`;onboarding 保留全屏沉浸壳层、登录 / 生成状态机和跳过行为,不再手写 textarea / 错误条 / 按钮 chrome。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
|
||||||
- 2026-06-10 追加:RPG 大编辑器本地 `SectionPanel` 适配到 `PlatformSubpanel surface="dark"`;可扮演角色背景故事 / 关系 / 技能 / 物品、世界基础设定等编辑分区只保留标题、subtitle、右侧动作和内容插槽,不再由本地适配器手写外层暗色面板 chrome。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`。
|
|
||||||
- 2026-06-10 验证补充:RPG 大编辑器上传封面中提示收口到 `PlatformSubpanel surface="darkSky"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`。
|
|
||||||
- 2026-06-10 追加:RPG 角色形象参考图缩略框使用 `PlatformMediaFrame surface="editorDark"`;角色形象面板只保留参考图数组、上传 / 清空回调和状态文案,不再手写 `img + overflow-hidden + border` 缩略图 chrome。
|
|
||||||
- 2026-06-10 追加:营地编组同行者头像框使用 `PlatformMediaFrame surface="editorDark"` 和固定尺寸 class,保留角色图片 `object-contain`、放大比例与 pixelated 渲染;编组卡只保留角色数据和操作语义,不再手写头像框 `border-white/10 bg-black/25` 外壳。验证命令:`npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
|
||||||
- 2026-06-09 追加:平台普通进度条统一使用 `src/components/common/PlatformProgressBar.tsx` 承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、尺寸、条内覆盖层、未知进度语义和局部主题色;creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移,业务页只保留进度值、显示文案、状态配色和必要覆盖内容。没有准确百分比的脉冲占位条使用 `indeterminate`,不暴露假的 `aria-valuenow`;生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
|
|
||||||
- 2026-06-09 追加:creation-agent operation banner 的状态外壳迁移到 `PlatformStatusMessage surface="platform" remapSurface`,进度条继续使用 `PlatformProgressBar`;局部 platform token 作用域需要重映射时由 `remapSurface` 承接,不在业务 JSX 中继续手写 `platform-remap-surface platform-banner` 和 `platform-banner--*`。
|
|
||||||
- 2026-06-09 追加:平台只读信息块统一使用 `src/components/common/PlatformInfoBlock.tsx` 承载短标签、无标签纯正文、白底圆角边框、单行 / 多行正文排版和横向只读信息行的标签 / 值局部排版;错误弹窗和生成完成弹窗的来源、错误、状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已迁移,业务页不再重复拼 `rounded-[1rem] border ... bg-white/72 px-3 py-2`、`rounded-[1.25rem] border ... bg-white/72 p-4` 或 `rounded-[0.85rem] bg-white/74 px-* py-*`。
|
|
||||||
- 2026-06-10 追加:`PlatformInfoBlock` 支持 `variant="compactRow"` 承接预览卡密集横向 label / value 行;汪汪声浪预览卡四个信息行只保留 label 和内容,不再维护本地 `PREVIEW_INFO_*` class 常量。
|
|
||||||
- 2026-06-09 追加:平台白底子面板统一使用 `src/components/common/PlatformSubpanel.tsx` 承载 `platform-subpanel` 外壳、标题行、右侧动作区、强标题、圆角和响应式内边距;静态 element 透传 `aria-*` / `data-*` 等原生属性,便于结果页预览卡保留可访问名称。拼图结果页作品信息 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、拼图图片生成模式选择器菜单外壳、敲木鱼结果页元信息 / 标签 / 飘字 / 音效、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、通用音频输入面板和 RPG 个人中心未登录提示已先迁移。`surface="soft" padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,不再手写 `rounded-[1rem] border ... bg-white/68 p-2`;`surface="soft" padding="row"` 用于上传预览横向已选素材条等白底柔和横向行,不再手写 `rounded-[1rem] border ... bg-white/68 px-3 py-2`;静态封面轮播壳使用 `radius="xl" padding="none"` 保留内部固定比例和轮播按钮;抓大鹅物品详情五视角面板使用 `radius="xl" padding="sm"` 加局部 `sm:p-5` 保留响应式间距。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”或小型浮层菜单的片段优先使用该 Module;暗色运行态 HUD、媒体预览和强玩法品牌面板继续保留专用布局。
|
|
||||||
- 2026-06-10 追加:发布分享弹窗渠道 tile 按钮使用 `PlatformSubpanel as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 圆角、边框、底色、hover 或 focus chrome。验证命令:`npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:平台入口创作类型弹层玩法卡片使用 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;弹层只保留玩法图片、蒙版、锁定 badge、标题副标题和分流回调,外层按钮语义、标准圆角和已开放卡 hover / focus chrome 归公共子面板。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:creation-agent 工作台聊天区外壳使用 `PlatformSubpanel radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。验证命令:`npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 追加:creation-agent 无 session / 加载提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;工作台只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
|
|
||||||
- 2026-06-10 追加:拼图结果页空草稿提示块迁移到 `PlatformSubpanel radius="sm" padding="lg"`;结果页只保留提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4` 普通居中提示面板。
|
|
||||||
- 2026-06-09 追加:敲木鱼结果页主预览面板也迁移到 `PlatformSubpanel`,页面只保留标题、简介和资源叠放语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`。
|
|
||||||
- 2026-06-09 追加:拼消消创作工作台左侧结构化表单面板迁移到 `PlatformSubpanel`,工作台只保留字段、开关、错误和提交语义,不再手写 `platform-subpanel rounded-[1.25rem] p-4`。
|
|
||||||
- 2026-06-09 追加:抓大鹅创作工作台难度选择小面板迁移到 `PlatformSubpanel surface="flat"`,工作台只保留难度选项和 payload 派生,不再手写小白底面板边框、圆角、内边距和 inset 高光。
|
|
||||||
- 2026-06-09 追加:视觉小说创作工作台画风选择小面板迁移到 `PlatformSubpanel surface="flat"`,横向滚动、选中态和移动端 touch 行为仍由业务滚动区与样式按钮承接,不再手写外层白底面板 chrome。
|
|
||||||
- 2026-06-09 追加:创作中心作品架整块无作品 / 无筛选结果空态迁移到 `PlatformEmptyState surface="soft" size="panel"`,加载骨架卡迁移到 `PlatformSubpanel as="div"`;Hub 只保留筛选、列表和打开 / 删除 / 分享语义,不再直接拼空态 `platform-subpanel` 或 skeleton 卡片外壳。
|
|
||||||
- 2026-06-09 追加:视觉小说上传资产弹窗的无历史素材本地上传占位迁移到 `PlatformEmptyState surface="dashed"`;弹窗只保留上传、AI 生成、历史素材和选择回调语义,不再手写 dashed 空态面板 chrome。
|
|
||||||
- 2026-06-09 追加:creative-agent 工作台目录、目标就绪、空消息、过程、关卡计划和模板确认理由等标准白底面板迁移到 `PlatformSubpanel`;模板确认的“关卡模式 / 计划关卡”摘要迁移到 `PlatformStatGrid`,creative-agent 内不再直接拼 `platform-subpanel rounded-[1.35rem] p-4` / `rounded-[1.25rem] p-4` / `rounded-[1.15rem] p-4`。
|
|
||||||
- 2026-06-09 追加:拼消消结果页预览、统计和操作三个标准白底面板迁移到 `PlatformSubpanel`;页面只保留图片预览、统计项和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4` 或 `platform-subpanel mt-auto rounded-[1.25rem] p-4`。
|
|
||||||
- 2026-06-09 追加:跳一跳结果页预览和结果操作两个标准白底面板迁移到 `PlatformSubpanel`,公开排行榜小卡迁移到 `PlatformSubpanel surface="flat"`;操作面板标题走 `PlatformFieldLabel variant="section"`,页面只保留资源预览、排行榜数据、状态提示和动作回调,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4` 或 `rounded-[1rem] border ... bg-white/70 p-3`。
|
|
||||||
- 2026-06-09 追加:跳一跳结果页角色 / 图集 / 路径预览框和拼消消结果页场地底图 / 素材图集预览框使用 `PlatformSubpanel surface="flat" padding="none"`;白底媒体框只保留内部图片、占位和尺寸,不再重复拼 `rounded-[1rem] border ... bg-white/80`。
|
|
||||||
- 2026-06-09 追加:`PlatformSubpanel` 支持 `radius="xl"`,用于承接方洞结果页等 `rounded-[1.5rem]` 的标准大面板;方洞结果页封面、主信息、形状选项和洞口选项面板已迁移到 `PlatformSubpanel radius="xl" padding="lg"`,页面只保留图片、字段、选项和动作逻辑。
|
|
||||||
- 2026-06-09 追加:方洞结果页形状 / 洞口选项卡迁移到 `PlatformSubpanel surface="flat"`,贴图缩略图按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;选项卡只保留字段写回、目标洞口选择、删除和图片槽位打开逻辑,不再重复小卡边框、白底、圆角、缩略图 hover / disabled chrome。
|
|
||||||
- 2026-06-09 追加:敲木鱼创作工作台的“功德有什么”词条面板迁移到 `PlatformSubpanel`,词条输入迁移到 `PlatformTextField`,删除词条圆形浮动入口迁移到 `PlatformIconButton variant="surfaceFloating"`;工作台只保留词条输入、新增和删除交互,不再直接拼 `platform-subpanel rounded-[1.25rem] p-4`、本地标题 class、白底输入框 chrome 或白底圆形图标按钮 chrome。
|
|
||||||
- 2026-06-09 追加:视觉小说结果页作品、开场、运行配置和世界观标准编辑面板迁移到 `PlatformSubpanel radius="lg"`;页面只保留表单字段、资产预览和运行配置写回,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4`。
|
|
||||||
- 2026-06-09 追加:抓大鹅结果页作品信息、难度配置、难度统计、UI 素材预览和物品图集预览标准面板迁移到 `PlatformSubpanel radius="lg" padding="lg"`;页面只保留表单、滑杆、统计项和素材预览逻辑,不再直接拼 `platform-subpanel rounded-[1.35rem] p-4 sm:p-5`。
|
|
||||||
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="flat"`、`padding="sm"` 和 `radius="sm"`,用于承接素材 / 音频等小型白底卡片的圆角、边框、`bg-white/72`、标题行和右侧图标动作;视觉小说结果页素材选择 / 音频生成小面板已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/72 p-3`。
|
|
||||||
- 2026-06-09 追加:抓大鹅结果页难度配置里的当前难度摘要小卡迁移到 `PlatformSubpanel surface="flat" radius="sm" padding="sm"`;结果页只保留当前难度标题、消除次数、物品种类和难度 badge,不再手写 `rounded-[1rem] border ... bg-white/62 px-3 py-3` 小卡 chrome。
|
|
||||||
- 2026-06-09 追加:RPG 结果页开发资产诊断面板里的摘要卡、资产条目和空态迁移到 `PlatformSubpanel`;开发开关判定拆到 `rpgCreationAssetDebugPanelModel.ts`,组件文件只保留诊断面板渲染和图片加载状态。
|
|
||||||
- 2026-06-09 追加:RPG 发布弹窗封面预览壳迁移到 `PlatformSubpanel padding="none"`;发布弹窗只保留封面 presentation、设置封面和发布动作语义,不再直接手写 `platform-subpanel rounded-[1.25rem] p-2`。
|
|
||||||
- 2026-06-09 追加:creative-agent 关卡计划小卡和抓大鹅结果页物品 spritesheet 分组卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`;普通信息 / 图集分组小卡不再直接拼 `rounded-[1rem] border ... bg-white/58 p-3` 或 `px-3 py-3`。
|
|
||||||
- 2026-06-09 追加:抓大鹅批量物品素材生成状态卡迁移到 `PlatformSubpanel surface="flat" radius="sm"`,内部进度条迁移到 `PlatformProgressBar`;局部进度状态不再手写白底边框和 track / fill div。
|
|
||||||
- 2026-06-09 追加:平台反馈页问题描述、上传凭证和联系方式三个普通白底区块迁移到 `PlatformSubpanel radius="md"`;平台表单页只表达字段、上传和提交语义,不再直接拼 `platform-subpanel rounded-[1.2rem] px-4 py-4`。
|
|
||||||
- 2026-06-10 追加:`PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于暗色编辑 / 运行面板里的小型信息卡;RPG 冒险面板 / 覆盖层任务目标、区域、进度和描述卡,以及自定义世界实体目录角色维度小卡已迁移。后续同类暗色小信息卡只保留标题、图标和值,不再手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`。
|
|
||||||
- 2026-06-09 追加:`PlatformSubpanel` 支持 `as="button"` 与 `interactive`,用于承接普通白底整卡点击列表项的 hover、focus、disabled 和默认 `type="button"`;视觉小说 runtime 历史条目和存档列表已迁移,业务页不再重复手写 `rounded-[1rem] border ... bg-white/78 p-3 hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`。
|
|
||||||
- 2026-06-09 追加:视觉小说结果页角色 / 场景 / 阶段列表项和空态迁移到 `PlatformSubpanel`;列表项使用 `as="button" interactive` 保留整卡点击、hover / focus / disabled chrome 和默认 button type,空态使用静态 `PlatformSubpanel`,结果页不再直接手写 `platform-subpanel min-h-32` 列表卡片。
|
|
||||||
- 2026-06-09 追加:账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行迁移到 `PlatformSubpanel`;账号弹窗只保留换绑、撤销会话、刷新和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
|
|
||||||
- 2026-06-09 追加:RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题迁移到 `PlatformSubpanel` 与 `PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
|
|
||||||
- 2026-06-10 追加:RPG 运行态任务覆盖层里的任务更新提示、地点 / 人物提示和任务日志条目迁移到 `PlatformSubpanel surface="dark"`;运行态只保留任务文案、任务选择和奖励条交互,暗色边框、底色、圆角和条目 hover 外壳不再在业务 JSX 中重复拼。验证命令:`npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`。
|
|
||||||
- 2026-06-09 追加:大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼大圆角白底边框卡片。
|
|
||||||
- 2026-06-09 追加:汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行榜字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
|
|
||||||
- 2026-06-09 追加:自定义世界实体目录世界页的档案规模统计迁移到 `PlatformStatGrid`,世界基调、角色维度和基本设定条目迁移到 `PlatformSubpanel`;目录只保留世界资料读取、编辑入口和标签展示语义,不再直接拼统计卡 grid 或 `platform-subpanel rounded-2xl` 设定块。
|
|
||||||
- 2026-06-09 追加:自定义世界实体目录场景幕级缩略图迁移到 `PlatformSubpanel padding="none"`;目录只保留场景名、幕标题和图片来源语义,不再手写 `platform-subpanel h-12 w-[5.25rem]` 预览框 chrome。
|
|
||||||
- 2026-06-09 追加:自定义世界实体目录 `CatalogCard` 的角色 / 场景媒体框迁移到 `PlatformSubpanel padding="none"`;目录卡片只保留图片、角色动画或占位内容,不再手写媒体框 `platform-subpanel rounded-[1rem]` / `rounded-[1.1rem]` chrome。
|
|
||||||
- 2026-06-09 追加:`PlatformSubpanel` 支持 `surface="danger"` 承接整卡危险选中态,`PlatformPillBadge` 支持 `tone="muted"` 承接白底柔和选择 badge;自定义世界实体目录 `CatalogCard` 整卡壳迁移到 `PlatformSubpanel as="button"`,批量选择的“选择 / 已选”迁移到 `PlatformPillBadge`,目录只保留选择状态和点击回调,不再手写卡片 `role="button"` / 危险选中边框 / 选择 badge chrome。
|
|
||||||
- 2026-06-09 追加:平台媒体预览框统一使用 `src/components/common/PlatformMediaFrame.tsx` 承载图片源、fallback 图、fallback 文案、固定比例、refreshKey、warm / editorDark / plain / soft / bright / none / bare surface 和 overlay;自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview` 和拼图结果页关卡列表正式图框已先迁移,业务页只保留素材地址、可访问名称和业务覆盖层。`surface="soft"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`surface="bright"` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白素材槽,`surface="none"` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容;`PlatformSubpanel` 继续负责白底面板 / 轻量媒体壳 / 整卡点击列表项,不承接需要 fallback 或 overlay 的图片预览状态。
|
|
||||||
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="portrait"` 承接 9:16 竖版预览;拼消消结果页场地底图 / 素材图集预览已迁移到 `PlatformMediaFrame surface="none"`,外层仍用 `PlatformSubpanel surface="flat" padding="none"` 提供白底边框、圆角和 `bg-white/80` 媒体壳,页面不再手写 `ResolvedAssetImage` 与无图占位分支。
|
|
||||||
- 2026-06-09 追加:平台媒体缩略格网格统一使用 `src/components/common/PlatformMediaTileGrid.tsx` 承载列数、间距、白底容器、tile 圆角、边框、图片、refreshKey、可选 tile `testId` 和 fallback 格;跳一跳结果页地块池 / 无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。结果页只保留素材数组切片、素材地址、fallback 内容和玩法色值,不再重复手写 `grid-cols-*`、`rounded-[0.45rem] border border-white/80 bg-white/78` 或直接依赖底层 `ResolvedAssetImage`;网格内部 tile chrome 由 `tileSurface` 承接,内层 `PlatformMediaFrame` 统一使用 `surface="none"`,不再重复加公共 subpanel fill。
|
|
||||||
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackContent` 承接图标型无图占位;方洞结果页图片查看弹窗的 4:3 预览已迁移到 `PlatformMediaFrame aspect="standard" surface="plain"`,页面不再手写图片 / 图标占位分支。
|
|
||||||
- 2026-06-09 追加:宝贝识物结果页素材卡图片框迁移到 `PlatformMediaFrame aspect="square" surface="none"`,占位资源 badge 作为 `previewOverlay` 传入;素材卡只保留外层 `PlatformSubpanel`、素材名、渐变槽局部样式和业务状态,不再手写 `ResolvedAssetImage` 绝对铺满与 overlay 分支。
|
|
||||||
- 2026-06-09 追加:视觉小说结果页封面 4:3 预览和资产字段 16:9 图片预览迁移到 `PlatformMediaFrame`;封面使用 `surface="editorDark"` 和图标型 `fallbackContent`,资产字段使用 `aspect="landscape" surface="none"` 嵌入现有小型白底卡片,页面不再手写 `ResolvedAssetImage`、`aspect-[4/3]` / `aspect-[16/9]` 和无图占位分支。
|
|
||||||
- 2026-06-09 追加:跳一跳结果页地块图集整图 fallback 预览迁移到 `PlatformMediaFrame aspect="square" surface="none"`;单个地块网格和路径平台预览保留专用组合布局,只有纯图片源 + 正方形比例的 atlas 分支进入公共媒体框,图集底色作为局部 `bg-white/78` 保留在媒体框 class。
|
|
||||||
- 2026-06-09 追加:方洞结果页封面和背景两个点击预览按钮内部迁移到 `PlatformMediaFrame aspect="standard" / "landscape" surface="none"`;按钮继续负责打开图片槽位弹窗和承接渐变边框交互壳,公共媒体框只负责 4:3 / 16:9 比例、图片读取和图标型 fallback,占位和图片分支不再写在业务 JSX 中。
|
|
||||||
- 2026-06-09 追加:方洞结果页形状 / 洞口选项里的 80px 贴图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel as="button"` 继续负责打开素材弹窗和亮白交互壳,业务页不再直接依赖底层 `ResolvedAssetImage`,内层媒体框也不再重复承接背景。
|
|
||||||
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `aspect="wide"` 承接 9:5 宽图预览;大鱼吃小鱼素材工坊候选预览迁移到 `PlatformMediaFrame aspect="wide" surface="none"`,工坊只保留 prompt、生成动作和 cyan 主题外观适配,虚线边框与浅青底作为局部 class 保留。
|
|
||||||
- 2026-06-09 追加:拼图发布弹窗封面关卡预览迁移到 `PlatformMediaFrame aspect="square" surface="soft"`;发布弹窗只保留发布检查、泥点提示和发布动作,不再手写封面图片框 `aspect-square`、`ResolvedAssetImage`、白底柔和边框和空图分支。
|
|
||||||
- 2026-06-09 追加:大鱼吃小鱼结果页场地背景竖版预览迁移到 `PlatformMediaFrame aspect="portrait" surface="none"`;结果页保留青色深海背景主题和生成背景动作,不再手写 9:16 图片框与 `ResolvedAssetImage` 分支。
|
|
||||||
- 2026-06-09 追加:大鱼吃小鱼结果页关卡主图缩略图迁移到 `PlatformMediaFrame aspect="square" surface="none"`;关卡卡片只保留关卡文案、状态和工坊入口,不再直接依赖底层 `ResolvedAssetImage`。
|
|
||||||
- 2026-06-10 追加:抓大鹅结果页物品素材列表缩略图和详情大图迁移到 `PlatformMediaFrame aspect="square" surface="bright"`,详情视角缩略图嵌在保留选中态的按钮壳内并使用 `surface="none"`;素材列表卡只保留打开详情、素材名和删除动作,详情预览只保留视角切换状态,不再手写正方形图片 / 图标 fallback / 亮白边框槽;需要测试 id / aria 时通过媒体框容器属性透传。
|
|
||||||
- 2026-06-10 追加:抓大鹅结果页 UI 素材子 Tab 的游戏背景、UI spritesheet 和物品 spritesheet 主图预览迁移到 `PlatformMediaFrame surface="none"`;外层按钮 / 白底预览壳继续负责交互、边框、底色和内边距,媒体框只承接图片读取、fallback 和固定比例。
|
|
||||||
- 2026-06-10 追加:`PlatformMediaFrame` 根节点固定带 `platform-media-frame` 类名,供业务测试断言公共媒体框接入;拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 迁移到 `PlatformMediaFrame aspect="square" surface="none"`,外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
|
|
||||||
- 2026-06-10 追加:认证图形验证码图片使用 `PlatformMediaFrame aspect="auto" surface="soft"`;验证码组件只保留图片 data URL、可访问名称和固定尺寸 class,不再手写 `img + platform-subpanel` 图片框。
|
|
||||||
- 2026-06-09 追加:敲木鱼结果页主 9:16 背景 + 敲击物叠层预览迁移到 `PlatformMediaFrame aspect="portrait" surface="plain"`;页面保留背景图和敲击物的叠放顺序,不再手写固定比例外框、白底边框和无图占位。
|
|
||||||
- 2026-06-09 追加:`PlatformMediaFrame` 支持 `fallbackShellClassName` 承接无图 fallback 区域的局部背景 / 渐变;creative-agent 模板确认预览迁移到 `PlatformMediaFrame aspect="landscape" surface="soft"`,弹窗只保留模板标题、泥点、调整和确认语义,不再手写 16:9 图片 / 图标占位容器,也不再在业务 JSX 中重复拼基础边框和 `bg-white/68`。
|
|
||||||
- 2026-06-09 追加:creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`;工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围,不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
|
|
||||||
- 2026-06-09 追加:非交互中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险图标槽统一使用 `src/components/common/PlatformIconBadge.tsx` 承载图标、尺寸、圆角、neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 底色和可访问隐藏语义;视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标、大鱼吃小鱼发布失败弹窗图标槽、通用创作图片面板空主图上传占位图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移,业务页不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`、白底柔和小圆槽、目标完成图标槽、暗色琥珀图标槽或危险提示红色圆槽。
|
|
||||||
- 2026-06-10 追加:宝贝识物工作台静态玩法预览卡迁移到 `PlatformSubpanel surface="soft"`,卡内礼物图标槽迁移到 `PlatformIconBadge tone="softBright"`;工作台只保留玩法渐变、装饰层和文案,不再手写白底柔和面板边框 / 圆角 / 内边距或图标槽 chrome。
|
|
||||||
- 2026-06-09 追加:平台标签编辑统一使用 `src/components/common/PlatformTagEditor.tsx` 承载标签 chip、删除按钮、新增输入、Enter 提交、Escape 取消、空态、可选 AI 生成动作和错误提示;拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。业务页只保留标签 parse / normalize 规则、最大数量和最终写回,不再重复维护标签编辑 JSX 与本地新增状态机。
|
|
||||||
- 2026-06-10 追加:标签编辑 Module 内部的新增输入行由 `PlatformSubpanel surface="soft" padding="tight"` 承接外壳,输入框由 `PlatformTextField` 承接;公共标签编辑不再把子面板和输入框 chrome 混写在同一段本地 JSX class 中。
|
|
||||||
- 2026-06-09 追加:方形上传入口和紧凑虚线新增入口统一使用 `src/components/common/PlatformUploadTile.tsx` 承载虚线方块、图标、主副文案、button / label 语义和禁用态;`size="compact" showLabel={false}` 用于工作台里的纯图标虚线新增入口,仍保留隐藏可访问名称。上传后的图片预览统一使用 `src/components/common/PlatformUploadPreviewCard.tsx` 承载缩略图壳、预览图片、可选标题行、可选预览点击、横向已选素材条和移除按钮。默认 `layout="square"` 用于方形缩略图,`layout="inline"` 用于“缩略图 + 文件名 / 素材名 + 移除”的已选参考图条,内部横向行复用 `PlatformSubpanel surface="soft" padding="row"`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条和 creation-agent 已选参考图条已先迁移,业务页只保留文件选择、预览数组、预览回调、删除回调、新增回调和校验逻辑。工具栏小图标上传仍使用 `PlatformIconButton asChild="label"`,带大面积缩略图选择的历史素材仍使用 `PlatformAssetPickerGrid`。
|
|
||||||
- 2026-06-09 追加:拼图结果页关卡详情中的只读引用图横条也使用 `PlatformUploadPreviewCard layout="inline"`,由公共组件承载缩略图、`ResolvedAssetImage` 换签、素材名截断和横向白底条 chrome;只读场景不传 `onRemove`,避免结果页额外出现删除按钮。历史素材弹窗仍使用 `PlatformAssetPickerGrid`,结果页只展示选择后的引用关系。
|
|
||||||
- 2026-06-09 追加:白底平台子面板内的无操作空态使用 `PlatformEmptyState surface="subpanel" size="inline"`,由 Module 承载圆角、边框、`bg-white/74`、居中、字号和 soft 文本色;视觉小说 runtime 历史、属性、存档读取 / 空态已先迁移,业务页不再重复拼白底空态 class。
|
|
||||||
- 2026-06-10 追加:个人中心充值弹窗的“暂无可购买套餐”和每日任务弹窗的“暂无任务”使用 `PlatformEmptyState surface="subpanel" size="inline"`;业务组件只保留数据分支,不再手写 `platform-subpanel rounded-2xl px-4 py-8` 空态 chrome。
|
|
||||||
- 2026-06-10 追加:`PlatformEmptyState` 根节点固定带 `platform-empty-state` 类名,并支持 `surface="editorDark"` 承接 RPG 大编辑器和运行态弹窗 / 面板里的暗色虚线纯展示空态;角色槽位、可选角色、关系、技能、物品、交易空列表、赠礼空列表、招募替换空列表、奖励物品空态、任务日志空态、运行态设置保存禁用提示和营地编组空队列只保留业务文案,不再重复拼 `rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4 text-sm text-zinc-500`、`rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500` 或 `rounded-xl border border-dashed border-white/10 bg-black/20 px-3 py-4 text-center text-xs text-zinc-500`。
|
|
||||||
- 2026-06-09 追加:自定义世界实体目录搜索框迁移到 `PlatformTextField density="compact"`,搜索无结果空态迁移到 `PlatformEmptyState surface="dashed"`;目录只保留搜索值、占位符和过滤语义,不再直接拼 `platform-subpanel rounded-2xl` 输入壳或虚线空态。
|
|
||||||
- 2026-06-10 追加:creation-agent 聊天区“暂无消息”迁移到 `PlatformEmptyState surface="subpanel" size="compact"`,composer 文本域迁移到 `PlatformTextField variant="textarea" size="md" density="compact"`;工作台保留消息列表滚动、受控输入、禁用条件、Enter 提交和 Shift+Enter 换行语义,不再手写空态和 textarea chrome。
|
|
||||||
- 2026-06-10 追加:大鱼吃小鱼结果页缺少可编辑草稿提示迁移到 `PlatformEmptyState surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。验证命令:`npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-09 追加:视觉小说 runtime 普通白底面板里的保存主按钮和历史重生成行内动作使用 `PlatformActionButton surface="platform"`;保存使用默认主动作,行内重生成使用 `tone="secondary" size="xs" shape="pill"`,业务页只保留图标、禁用条件和回调。
|
|
||||||
- 影响范围:`src/components/common/UnifiedConfirmDialog.tsx`、`src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx`、`src/components/common/CopyFeedbackMessage.tsx`、`src/components/common/PlatformStatusMessage.tsx`、`src/components/common/PlatformEmptyState.tsx`、`src/components/common/PlatformActionButton.tsx`、`src/components/common/platformActionButtonModel.ts`、`src/components/common/PlatformIconButton.tsx`、`src/components/common/PlatformUploadTile.tsx`、`src/components/common/PlatformUploadPreviewCard.tsx`、`src/components/common/PlatformMediaFrame.tsx`、`src/components/common/PlatformModalCloseButton.tsx`、平台入口壳、公共错误 / 完成 / 分享弹窗、公开详情页、大鱼 runtime / result、账号个人资料区、自定义世界实体目录、RPG 结果页重新生成确认、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说结果页普通按钮和状态提示、历史图片选择弹窗 / RPG 发布检查弹窗 / creative-agent 侧边栏 / creation-agent 参考图 / 敲木鱼结果页 / 拼图结果页普通图标按钮、方洞结果页图片素材弹窗关闭按钮、视觉小说结果页资产 / 音频 / 编辑器弹窗和 runtime 普通面板关闭按钮、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、自定义世界实体目录小动作和状态提示、创作中心错误重试、反馈页 header 返回、认证入口 / 邀请码弹窗关闭按钮、通用生成页重试 / 中断动作、RPG 详情页删除确认、RPG 角色素材工作室泥点确认、RPG 场景编辑器阻断提示、RPG 角色背景章节阻断提示、RPG 编辑器未保存关闭确认、RPG 场景背景 / 作品封面生成退出确认、公开作品深链失效恢复、账户充值 / 泥点账单 / 每日任务 / 兑换码 / 扫码 / 存档 / 玩过作品等个人中心弹窗、RPG 首页 / 公开广场 / 作品架和历史素材选择弹窗空态、个人中心充值 / 任务 / 兑换 / 邀请 / 支付结果弹窗主动作按钮、RPG 作品详情和生成结果恢复面板平台动作按钮、法律信息弹窗 footer、通用创作图片 / 音频输入面板动作按钮和上传 label、统一创作工作台返回 / 生成按钮和错误提示、短信登录 / 密码登录 / 绑定手机号认证表单动作按钮和状态提示、账号安全弹窗动作按钮和状态提示、验证码提示、邀请码弹窗提交按钮和错误提示、错误 / 完成 / 分享弹窗复制按钮外观、结果页 / 工作台后续简单弹窗迁移。
|
|
||||||
- 验证方式:`npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx src/components/common/useCopyFeedback.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/common/CopyFeedbackMessage.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`,迁移页面时补跑对应页面交互测试;实体目录删除确认、角色背景章节阻断与场景编辑器提示补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`;公开作品深链失效恢复补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`;RPG 结果页重新生成确认补跑 `npm run test -- src/components/CustomWorldResultView.test.tsx`;RPG 详情页删除 hook 补跑 `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`;角色素材工作室泥点确认补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`;个人中心弹窗关闭按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|reward code|task center|recharge|save archive|played works"`;认证入口 / 邀请码弹窗关闭按钮迁移补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`;RPG 首页 / 公开广场 / 作品架空态迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile discover|desktop logged in home|profile played works|logged in draft bottom tab|ranking"`;历史素材选择弹窗空态迁移补跑 `npm run test -- src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`;结果页普通动作和状态提示迁移补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`、`npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`、`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`;玩法创作工作台普通动作和错误提示迁移补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`;creation-agent 推荐回复动作迁移补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`;创作中心重试和反馈页返回按钮迁移补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`;通用生成页动作迁移补跑 `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`;统一创作页壳层补跑 `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`;拼图创作工作台返回按钮补跑 `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`;个人中心主动作按钮迁移补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recharge|wallet ledger|task center|reward code|invite|community"`;复制弹窗外观迁移补跑 `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx src/components/common/PublishShareModal.test.tsx`;阶段完成前复扫 `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`。
|
|
||||||
- 2026-06-09 验证补充:通用输入 Composer 图标按钮迁移补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 首页抽屉空态和首页错误提示收口后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 过程面板空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 工作台消息空态收口到 `PlatformEmptyState surface="subpanel" size="compact"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:作品详情顶部和封面轮播图标按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:作品详情底部启动 / 改造动作收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:作品详情点赞按钮收口补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 模板确认弹层“关卡数”行内标题收口到 `PlatformFieldLabel variant="inlineForm"` 后,补跑 `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:平台入口公开编号搜索结果弹层收口到 `UnifiedModal`、`PlatformStatusMessage` 和 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public code search"`。
|
|
||||||
- 2026-06-10 验证补充:平台作品详情主题标签和作品号复制 chip 收口后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:平台作品详情分享复制反馈按状态映射到 `PlatformStatusMessage surface="platform"` 后,补跑 `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:大鱼吃小鱼结果页缺草稿空态收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:大鱼吃小鱼结果页发布校验阻断项收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:通用输入 Composer 面板、文本域和读图错误状态收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creation-agent composer 错误条收口补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:通用创作图片面板历史入口和抓大鹅封面编辑浮动图标按钮收口补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:AI 重绘胶囊开关收口补跑 `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:白底整行开关收口补跑 `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:RPG 大编辑器动作按钮收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`。
|
|
||||||
- 2026-06-09 验证补充:runtime 白底 HUD 收口补跑 `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:历史素材选择卡片收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:RPG 大编辑器历史素材弹窗收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅封面编辑可引用素材网格收口补跑 `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅结果页白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页主信息表单白底输入框和文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项紧凑输入、文本域和下拉框收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼图 / 敲木鱼结果页作品信息输入、拼图关卡名称和智能修订输入收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:通用创作图片输入面板提示词文本域收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:创作工作台白底字段输入和焦点色 tone 收口补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx` 与 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:白底分段 Tab / 二选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅难度四选一收口补跑 `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台统计小卡收口补跑 `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:自定义世界实体目录搜索框和空态收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 大编辑器暗色纯展示空态迁移到 `PlatformEmptyState surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`。
|
|
||||||
- 2026-06-10 验证补充:角色聊天错误提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:营地编组战斗中提示、状态数值、分区 / 同行者卡、空队列和替换位按钮分别收口到 `PlatformStatusMessage surface="editorDark"`、`PlatformPillBadge darkNeutral`、`PlatformSubpanel surface="dark" / "darkSky"`、`PlatformEmptyState surface="editorDark"` 和 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:自定义选择弹窗错误 / 生成中提示收口到 `PlatformStatusMessage surface="editorDark"` 和 `PlatformProgressBar` 后,补跑 `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:地图场景切换目标场景面板、当前 / 前往摘要和方向标签收口到 `PlatformSubpanel surface="darkAmber" / "dark"` 与 `PlatformPillBadge dark*` 后,补跑 `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 构筑标签效果详情收口到 `CharacterInfoShared.BuildContributionDetailPanel` 和 `PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`。
|
|
||||||
- 2026-06-10 验证补充:RPG 实体详情弹窗物品空态和技能详情暗色小卡收口后,补跑 `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`。
|
|
||||||
- 2026-06-09 验证补充:创作中心作品架空态和加载骨架卡收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台胶囊状态标签和宝贝识物结果页白底卡片收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台胶囊状态标签扩展到宝贝识物 / 拼图 / 汪汪声浪工作台和结果页 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台胶囊状态标签扩展到视觉小说 / 抓大鹅工作台 BETA chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台胶囊状态标签扩展到敲木鱼结果页飘字 chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台胶囊状态标签扩展到 creative-agent 过程计数 / 条目 meta chip 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:实心中性状态胶囊和整行状态开关收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformToggleRow.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:媒体紧凑占位浮层收口补跑 `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 阶段时间线柔和步骤圆点收口补跑 `npm run test -- src/components/common/PlatformSlotBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 过程条目柔和图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 模板 / hero / 目标就绪图标圆槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:创作类型弹层锁定卡小圆锁图标收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:大鱼吃小鱼发布失败弹窗危险图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`。
|
|
||||||
- 2026-06-10 追加:`PlatformIconBadge` 根节点固定带 `platform-icon-badge` 稳定类名;个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `PlatformIconBadge size="xl"` 并保留局部 `bg-white/10` 与状态文字色覆盖,支付弹窗不再手写圆形图标容器。验证命令:`npm run test -- src/components/common/PlatformIconBadge.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`。
|
|
||||||
- 2026-06-10 验证补充:宝贝识物工作台静态玩法预览卡和图标槽收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformIconBadge.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:通用创作图片面板空主图上传占位图标槽收口补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:GameCanvas 宝箱遭遇图标槽收口到 `PlatformIconBadge size="xxl" shape="xl" tone="darkAmber"` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:通用创作图片面板按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:抓大鹅创作工作台按钮内泥点消耗胶囊收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:标签编辑新增输入行 soft 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformTagEditor.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:标签编辑新增输入框收口到 `PlatformTextField` 后,补跑 `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心昵称弹窗输入框收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"` 与 `npm run test -- src/components/common/PlatformTextField.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:认证图形验证码图片和答案输入分别收口到 `PlatformMediaFrame` 与 `PlatformTextField` 后,补跑 `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:认证登录、重置密码、绑定手机号、邀请码和账号安全表单字段收口到 `PlatformTextField` 与 `PlatformFieldLabel` 后,补跑 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:通用创作图片输入面板主图 / 提示词字段标题收口到 `PlatformFieldLabel` 后,补跑 `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心存档 / 玩过弹窗简单空态、分区标题和已玩作品按钮卡收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`。
|
|
||||||
- 2026-06-10 验证补充:平台入口壳纯 Suspense fallback 收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`。
|
|
||||||
- 2026-06-10 验证补充:个人中心钱包账单空态和账单行收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"` 与 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心邀请弹窗内部卡片、标题和空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"` 与 `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心任务中心任务条目收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"` 与 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心充值弹窗 Native 支付二维码确认面板收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"` 与 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:个人中心兑换码 / 邀请码输入和充值 / 任务空态收口后,补跑 `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`。
|
|
||||||
- 2026-06-10 验证补充:背包文书按钮收口到暗色 `PlatformSubpanel`、故事档案 QA 提示收口到 `PlatformStatusMessage surface="editorDark"` 后,补跑 `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:NPC 叙事提示和交易详情属性格收口后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:NPC 暗色可选项按钮卡收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:角色素材工作室动作预览格收口到 `PlatformDarkOptionCard` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:上传预览横向已选素材条 soft row 子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creation-agent 无 session / 加载提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creation-agent 聊天空态和 composer 文本域收口后,补跑 `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:拼图首访 onboarding 提示词文本域、输入错误和登录保存错误收口后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformTextField.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:拼图首访 onboarding 生成 / 登录 / 跳过按钮收口到 `PlatformActionButton` 后,补跑 `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`。
|
|
||||||
- 2026-06-10 验证补充:拼图结果页空草稿提示块收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 个人中心未登录提示子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx`,并对 `src/components/rpg-entry/RpgEntryHomeView.tsx` 执行 ESLint / typecheck;游客态当前不暴露“我的”Tab,不新增不可达业务断言。
|
|
||||||
- 2026-06-10 验证补充:拼图图库详情页封面轮播壳收口到 `PlatformSubpanel radius="xl" padding="none"` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:抓大鹅物品详情五视角面板收口到 `PlatformSubpanel radius="xl" padding="sm"` 后,补跑 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼图 / 方洞结果页自动保存 badge 收口补跑 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅结果页自动保存 / 当前难度 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼图结果页关卡生成中 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼结果页终局 / 发布校验成功 badge 收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:大鱼吃小鱼结果页关卡元信息标签收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:宝贝识物占位资源 overlay 和方洞选项删除图标按钮收口补跑 `npm run test -- src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx` 与 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台普通进度条收口补跑 `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡收口到 `PlatformSubpanel` 后,补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:跳一跳结果页公开排行榜小卡收口到 `PlatformSubpanel surface="flat"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:汪汪声浪草稿编译小卡、跳一跳排行榜行卡和排行榜空态收口后,补跑 `npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:跳一跳 / 拼消消结果页媒体预览框收口到 `PlatformSubpanel surface="flat" padding="none"` 后,补跑 `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页标准大面板收口到 `PlatformSubpanel radius="xl"` 后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页形状 / 洞口选项卡和缩略图按钮收口后,补跑 `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 大编辑器场景背景 / 作品封面生成和封面上传状态提示收口到 `PlatformStatusMessage surface="tinted"` 后,补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`。
|
|
||||||
- 2026-06-09 验证补充:creation-agent operation banner 状态外壳收口补跑 `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台只读信息块收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:汪汪声浪预览卡横向只读信息行收口补跑 `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台白底子面板收口补跑 `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼消消创作工作台左侧表单面板收口补跑 `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅创作工作台难度小面板收口补跑 `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:视觉小说创作工作台画风选择小面板收口补跑 `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼消消结果页白底面板收口补跑 `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:creative-agent 标准白底面板收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:creative-agent 模板目录卡和 16:9 预览收口补跑 `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:creative-agent 模板确认预览使用 `PlatformMediaFrame surface="soft"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:通用音频输入面板限制标签收口补跑 `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:RPG 世界详情页白底信息卡与 section 标题收口补跑 `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底卡片收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼结果页白底动作按钮收口补跑 `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:RPG 结果页开发资产诊断面板收口补跑 `npm run test -- src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:自定义世界实体目录世界页统计和基本设定收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:自定义世界实体目录场景幕级缩略图收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:自定义世界实体目录卡片媒体框收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:自定义世界实体目录卡片整卡壳和批量选择 badge 收口补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 实体编辑器基本设定 tag 和角色形象参考图 / 状态小卡收口补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台媒体预览框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞图片查看弹窗媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼消消结果页卡片预览网格收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:宝贝识物结果页素材卡媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:视觉小说结果页封面和资产字段媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:跳一跳结果页地块图集整图媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台媒体缩略格网格收口补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页封面 / 背景点击预览媒体框收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:方洞结果页形状 / 洞口贴图缩略图媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:方洞封面 / 背景、拼消消场地底图 / 素材图集、宝贝识物素材卡、跳一跳图集整图和大鱼媒体槽统一收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:拼图发布封面收口到 `surface="soft"`,拼图关卡列表、视觉小说资产字段和 creative-agent 模板目录卡收口到 `surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`;业务页面不再直接使用 `PlatformMediaFrame surface="bare"`。
|
|
||||||
- 2026-06-10 验证补充:`PlatformMediaTileGrid` 内部媒体框改用 `surface="none"` 并支持 item `testId`,抓大鹅物品 spritesheet 解析分组迁移后,补跑 `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:抓大鹅 UI 素材子 Tab 的背景、UI spritesheet 和物品 spritesheet 主图迁移到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/common/PlatformMediaTileGrid.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:拼图图库详情页封面轮播内层媒体框收口到 `PlatformMediaFrame surface="none"` 后,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:`PlatformMediaFrame` 增加 `aspect="auto"`、容器 `ref` 和 `imageProps` 后,RPG 封面上传裁剪操作区 / 裁剪结果、角色素材工作室形象预览和动作静态预览迁移到公共媒体框,补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面上传会先进入 16:9 裁剪面板再提交到后端"` 与 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 编辑器场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览继续收口到 `PlatformMediaFrame` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色技能动作状态复用暗色平台胶囊标签|场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
|
||||||
- 2026-06-10 验证补充:RPG 大编辑器场景幕角色槽位当前角色 / 可选角色面板,以及幕背景预览 / 预设背景面板收口到本地 `EditorInfoPanel` + `PlatformSubpanel surface="dark"` 后,补跑 `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼素材工坊宽图候选预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼图发布弹窗封面关卡预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼场地背景竖版预览收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:大鱼吃小鱼关卡主图缩略图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:抓大鹅结果页物品素材列表缩略图和详情大图收口补跑 `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:敲木鱼结果页主预览面板和 9:16 叠层预览收口补跑 `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:平台标签编辑器收口补跑 `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:反馈页上传方块和上传预览收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:反馈页查看记录次级动作收口补跑 `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:创作中心作品卡积分激励领取按钮收口补跑 `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`。
|
|
||||||
- 2026-06-10 验证补充:UnifiedModal 头部关闭按钮收口到 `PlatformModalCloseButton platformIcon / pixel` 后,补跑 `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:上传预览卡右上移除按钮收口到 `PlatformIconButton darkMini` 后,补跑 `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:RPG 大编辑器参考图和封面上传入口收口到 `PlatformUploadTile surface="editorDark"`、参考图预览条收口到 `PlatformUploadPreviewCard surface="editorDark"` 后,补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`。
|
|
||||||
- 2026-06-10 验证补充:角色素材工作室参考图入口收口到 `PlatformUploadTile surface="editorDark"` 后,补跑 `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:敲木鱼工作台新增功德词条虚线入口收口补跑 `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:通用创作图片面板参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:抓大鹅封面编辑参考图缩略图收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:横向已选参考图条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`。
|
|
||||||
- 2026-06-09 验证补充:拼图结果页关卡引用图横条收口补跑 `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:汪汪声浪预览 VS chip 收口到 `PlatformPillBadge` 后,补跑 `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`。
|
|
||||||
- 2026-06-10 验证补充:拼图结果页智能修订条 / 关卡卡片收口到 `PlatformSubpanel` / `PlatformIconBadge` 后,补跑 `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
|
||||||
|
|
||||||
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
|
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
|
||||||
|
|
||||||
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
|
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
|
||||||
@@ -569,11 +113,10 @@
|
|||||||
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
|
- 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。
|
||||||
- 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。
|
- 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。
|
||||||
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
|
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
|
||||||
|
|
||||||
- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
|
- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
|
||||||
- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;当前 OpenSSL 3.2 独立运行时自举会安装 `build-essential` 等最小工具,这是满足 api-server/libcurl 运行时符号的受控例外,不代表 provision 承担 api-server 构建职责。非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
|
- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。
|
||||||
- 追加决策(2026-06-10):`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli` 和 `spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。
|
- 追加决策(2026-06-10):`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli` 和 `spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。
|
||||||
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
|
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。
|
||||||
- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
|
- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。
|
||||||
@@ -611,24 +154,6 @@
|
|||||||
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-03 外部内容生成改为持久队列加 worker 角色
|
|
||||||
|
|
||||||
- 背景:拼图首图、图集、音频等外部生成链路长期占用 `api-server` HTTP handler,导致扩容只能放大 API 进程,且 HTTP 超时和外部 provider 波动会直接影响创作入口。
|
|
||||||
- 决策:外部生成任务统一进入 SpacetimeDB `external_generation_job` 持久队列,由 `api-server` 的 `external-generation-worker` 进程角色 claim lease 后执行;HTTP 角色只做鉴权、表单/状态初始化、入队和返回 `queued/running/completed/failed` 操作状态。生产通过 systemd worker 模板增加实例数或提高 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩容,`GENARRATIVE_PROCESS_ROLE=all` 仅用于本地 smoke。拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与 `generate_puzzle_ui_background` 已接入 worker;业务写回必须在 SpacetimeDB transaction 内校验 `external_generation_job` 的 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,其中首图 worker 的前置 `compile_puzzle_agent_draft` 也必须带 guard。worker 核心业务写回失败不能返回内存快照并把 job 标成 completed;失败态业务写回成功后才能把 job 标成 failed,失败态未写回则保留租约等待后续重领。拼图业务失败不自动重试,只保留 lease 过期后的崩溃重领,避免钱包扣退费幂等漂移。生产发布会启用默认 `genarrative-external-generation-worker@1.service` 并等待 worker active,worker 停机时停止 claim 新任务并 drain 当前任务。
|
|
||||||
- 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 使用 `queue|inline` 显式策略;生产和容器扩缩容验证保持 `queue`。本地开发若需要同步等待结果,应通过 `.env.local` 或本机环境显式配置为 `inline`,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容;脚本不得硬编码该策略。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。
|
|
||||||
- 2026-06-11 追加:生产新增固定 `external-generation-controller` 进程角色和 `genarrative-external-generation-controller.service`。controller 只读取 `get_external_generation_queue_stats_and_return` 队列统计并管理 `genarrative-external-generation-worker@N.service`,不监听 HTTP、不执行外部生成任务;默认保留 `@1`,按 `claimable_pending + running_active + expired_running` 计算目标实例数,上限由 `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS` 控制,缩容需要连续空闲轮数且每轮只停最高编号一个实例。
|
|
||||||
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/external_generation_worker_controller.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。
|
|
||||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并在 queue 模式下用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路;本地 inline 排查只确认不创建 `external_generation_job`。
|
|
||||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 2026-06-03 外部生成 worker lease 使用 SpacetimeDB 时间和 token 栅栏
|
|
||||||
|
|
||||||
- 背景:外部生成 worker 支持多进程动态缩扩容后,长任务超过单次 lease、worker 本机时钟漂移或复用 worker id 都可能导致同一任务被重复领取并被过期执行者回写。
|
|
||||||
- 决策:`external_generation_job` 新增末尾字段 `lease_token`;`claim` 使用 SpacetimeDB `ctx.timestamp` 计算 lease,生成本次 claim token;worker 执行期间调用 `renew_external_generation_job_lease_and_return` 续租;`complete/fail` 必须带 `worker_id + lease_token` 才能回写。拼图 `compile_puzzle_draft` 的 dedupe key 包含本次 `extgen-` job id,避免同一 session 的失败或完成 job 吞掉后续重新生成。拼图首图前置 `compile_puzzle_agent_draft`、图片保存、UI 背景与失败态业务写回同样必须携带 lease guard,并在 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed`、`mark_puzzle_level_generation_failed` 的 SpacetimeDB 事务内校验。
|
|
||||||
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/module-puzzle/src/commands.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`。
|
|
||||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`GENARRATIVE_PROCESS_ROLE=all npm run dev` 后检查 `/healthz`。
|
|
||||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
|
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
|
||||||
|
|
||||||
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
||||||
@@ -754,7 +279,7 @@
|
|||||||
- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。
|
- 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。
|
||||||
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
|
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
|
||||||
- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。
|
- 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。
|
||||||
- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard / pause / props 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;只要调用方传入 Runtime Guest Token,所有正式 runtime 请求都统一带局部 Authorization、`skipAuth` 与 `skipRefresh`。
|
- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。
|
||||||
- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
|
- 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
|
||||||
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
|
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
|
||||||
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
||||||
@@ -903,7 +428,7 @@
|
|||||||
## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式
|
## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式
|
||||||
|
|
||||||
- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。
|
- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。
|
||||||
- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo-runtime-hud.webp` 产品 logo 小图;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。
|
- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。
|
||||||
- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。
|
- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。
|
||||||
- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。
|
- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
@@ -1002,7 +527,7 @@
|
|||||||
- 决策:平台壳在生成失败时必须同时标记草稿 notice 和 pending 作品架条目为 `failed`,不得删除 pending 条目。失败 notice 要保存错误消息并在用户离开生成页后触发带来源的 `PlatformErrorDialog`;作品架本地失败 notice 要覆盖持久化生成中摘要,失败草稿仍显示为草稿卡但不显示“生成中”。点击失败草稿必须优先恢复失败 / 重试页,不能按持久化 `generating` 重新启动生成;拼图契约已允许 `generationStatus=failed`,pending 拼图和后端失败回写都按 session 独立落失败态,跳一跳 / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败态。
|
- 决策:平台壳在生成失败时必须同时标记草稿 notice 和 pending 作品架条目为 `failed`,不得删除 pending 条目。失败 notice 要保存错误消息并在用户离开生成页后触发带来源的 `PlatformErrorDialog`;作品架本地失败 notice 要覆盖持久化生成中摘要,失败草稿仍显示为草稿卡但不显示“生成中”。点击失败草稿必须优先恢复失败 / 重试页,不能按持久化 `generating` 重新启动生成;拼图契约已允许 `generationStatus=failed`,pending 拼图和后端失败回写都按 session 独立落失败态,跳一跳 / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败态。
|
||||||
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/custom-world-home/CustomWorldCreationHub.tsx`、玩法链路文档和失败态交互测试。
|
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/custom-world-home/CustomWorldCreationHub.tsx`、玩法链路文档和失败态交互测试。
|
||||||
- 验证方式:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败后返回草稿 Tab 应看到对应新增草稿,且没有“生成中”标记;后台失败应弹出错误来源,点击失败草稿应进入失败 / 重试页。
|
- 验证方式:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败后返回草稿 Tab 应看到对应新增草稿,且没有“生成中”标记;后台失败应弹出错误来源,点击失败草稿应进入失败 / 重试页。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
## 2026-05-23 所有玩法生成页统一圆环主视觉
|
## 2026-05-23 所有玩法生成页统一圆环主视觉
|
||||||
|
|
||||||
@@ -1155,7 +680,7 @@
|
|||||||
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换,避免在 Windows workspace 里二次清理触发权限拒绝。
|
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换,避免在 Windows workspace 里二次清理触发权限拒绝。
|
||||||
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
|
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
|
||||||
- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
|
- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
|
||||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
|
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
|
||||||
|
|
||||||
@@ -1185,7 +710,7 @@
|
|||||||
|
|
||||||
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
||||||
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
||||||
- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||||
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
||||||
- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
|
- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
|
||||||
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
||||||
@@ -1257,9 +782,9 @@
|
|||||||
- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。
|
- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。
|
||||||
- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。
|
- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。
|
||||||
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块
|
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
|
||||||
|
|
||||||
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
||||||
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
|
- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。
|
||||||
@@ -1605,7 +1130,7 @@
|
|||||||
|
|
||||||
- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。
|
- 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。
|
||||||
- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run dev:api-server` 并检查 `/healthz`。
|
- 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run dev:api-server` 并检查 `/healthz`。
|
||||||
- 影响范围:`AGENTS.md`、`docs/technical/`、`docs/project-memory/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。
|
- 影响范围:`AGENTS.md`、`docs/technical/`、`.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。
|
||||||
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
|
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
|
||||||
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。
|
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。
|
||||||
|
|
||||||
@@ -1668,7 +1193,7 @@
|
|||||||
## 2026-05-07 视觉小说 VN-11 负向扫描门禁
|
## 2026-05-07 视觉小说 VN-11 负向扫描门禁
|
||||||
|
|
||||||
- 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。
|
- 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。
|
||||||
- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/`、`packages/shared/src/`、`server-rs/crates/`、`docs/` 与 `docs/project-memory/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。
|
- 决策:新增 `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` 增量改动、同类新玩法负向扫描脚本。
|
- 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。
|
||||||
- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。
|
- 验证方式:执行 `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`。
|
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`。
|
||||||
@@ -1685,7 +1210,7 @@
|
|||||||
|
|
||||||
- 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。
|
- 背景:视觉小说模板主链已经落地完成,需要把 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 迁移文档仅保留历史参考地位。
|
- 决策:视觉小说后续维护的正式入口固定为 `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 收口、技术文档索引、经验文档索引、项目共享记忆和后续维护阅读顺序。
|
- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。
|
||||||
- 验证方式:打开上述文档即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。
|
- 验证方式:打开上述文档即可获得当前实现边界、表目录、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`。
|
- 关联文档:`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`。
|
||||||
|
|
||||||
@@ -1789,9 +1314,9 @@
|
|||||||
|
|
||||||
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
||||||
- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。
|
- 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。
|
||||||
- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/shared-memory/`。
|
- 影响范围:`AGENTS.md`、`.hermes/README.md`、`.hermes/shared-memory/`。
|
||||||
- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `docs/project-memory/shared-memory/` 文件。
|
- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `.hermes/shared-memory/` 文件。
|
||||||
- 关联文档:`.hermes/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。
|
- 关联文档:`.hermes/README.md`、`.hermes/shared-memory/team-conventions.md`。
|
||||||
|
|
||||||
## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB
|
## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB
|
||||||
|
|
||||||
@@ -1890,10 +1415,10 @@
|
|||||||
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-05-28 跳一跳重设计为 5x5 地块图集与弹弓拖拽
|
## 2026-05-28 跳一跳重设计为 UV 地板图集与长按蓄力
|
||||||
|
|
||||||
- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
|
- 背景:旧跳一跳模板仍保留角色生图、有限路径、score/combo 和 `2x3` 地块图集口径,和当前“俯视角平台跳跃 + 主题生成地块池 + 无限路径”的产品需求不一致。
|
||||||
- 决策:`jump-hop` v1 创作端只保留主题输入;image2 生成一张 `5x5`、共 25 个 2D 地块图标的图集,后端按均匀网格切出 25 个 `JumpHopTileAsset`。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为按住后拉蓄力、松手反向弹出,前端提交 `chargeMs + dragVectorX + dragVectorY`,后端裁决落点。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
- 决策:`jump-hop` v1 创作端只保留主题输入;image2 只生成一张 `1024x1536` 竖版图集,按 `3列*6行` 容纳 18 个立方体主题物体 UV 展开包装,每个大单元内部固定 `4列*3行` UV 网并切出 `top/front/right/back/left/bottom` 六张面贴图,后端共持久化 108 张 `256x256` 不透明 PNG。`JumpHopTileAsset.faceAssets` 保存六面贴图,历史 `imageSrc/imageObjectKey/assetObjectId` 写 top 面作为旧单贴图 fallback;旧作品没有 `faceAssets` 时运行态仍可把单张贴图应用到立方体所有面。角色不再单独生图,运行态使用陶泥儿 logo 透明 PNG 角色;运行态输入为长按蓄力、松手起跳,前端只提交蓄力值,后端始终沿当前地块中心到下一块地块中心方向裁决真实落点;`dragVectorX/dragVectorY` 仅作为旧客户端兼容字段保留且不参与裁决。草稿试玩必须使用 `runtimeMode=draft`,正式作品使用 `runtimeMode=published`;排行榜按作品维度每玩家只保留 1 条最佳记录,排序为成功跳跃次数降序、游戏时长升序、更新时间升序。
|
||||||
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
|
- 决策补充:跳一跳创作入口的事实源仍是 SpacetimeDB `creation_entry_type_config`。默认种子和旧默认行都必须同步迁移到 `subtitle=主题驱动平台跳跃`、`image_src=/creation-type-references/jump-hop.webp`;后端只在系统默认旧值命中时自动纠偏,避免覆盖后台手动配置。
|
||||||
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
|
- 影响范围:`jump-hop` PRD、`api-server` 生成编排、`module-jump-hop` 领域规则、`spacetime-module` / `spacetime-client` 跳一跳契约、前端工作台 / 结果页 / runtime / 平台壳调用链。
|
||||||
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
|
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、跳一跳工作台和 runtime 定向前端测试。
|
||||||
@@ -1907,10 +1432,10 @@
|
|||||||
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-02 跳一跳起跳距离减半并加入飞行动画缓冲
|
## 2026-06-02 跳一跳飞行动画缓冲与真实落点展示
|
||||||
|
|
||||||
- 背景:用户反馈当前跳跃到目标位置需要拖得太远,且松手后缺少角色翻腾到目标地块的过渡动画,导致跳跃手感偏硬。
|
- 背景:用户反馈长按蓄力版本的跳跃手感偏硬,成功后角色容易被吸回地块中心,且后端回包或相机推进时会出现飞过很远再瞬间拉回的闪现。
|
||||||
- 决策:`jump-hop` 的 `chargeToDistanceRatio` 统一从 `0.004` 提升到 `0.008`,让同等跳跃距离所需拖动距离减半;前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测落点后若新 run 尚未返回,必须停在预测落点等待,再进入约 `1440ms` 的相机层推进过渡。推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,从旧目标地块位置斜向滑到新当前地块聚焦位置,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
|
- 决策:`jump-hop` 当前长按蓄力统一使用 `chargeToDistanceRatio=0.004`,相同蓄力时间的世界跳跃距离比上一轮 `0.008` 降低一半。前端 runtime 把“后端真实 run”和“当前屏幕显示态”拆开,松手瞬间先生成 `visualJump`,用当前角色位置作为起点、前端预测真实落点作为终点,播放约 `560ms` 的飞行动画;该路径不得等待后端新 run。角色弹到预测真实落点后若新 run 尚未返回,必须停在预测真实落点等待。成功落地后角色位置必须保留 `lastJump.landedX/landedY` 映射出的真实偏移,不得吸附回目标地块中心。相机推进以旧窗口真实落点和新窗口真实落点为锚点,使用约 `1440ms` 过渡;推进期间地块 DOM 层和 DOM 角色层统一包在同一个 camera layer 下移动,旧当前地块自然离开视野,新预览地块从上方露出,避免 p1/p2 单独 top/left 过渡导致角色和地块不同步。相机推进必须同时使用 X/Y 偏移,不能先横向瞬切居中再纵向推进。地块保留当前 / 目标 / 预览的深度尺寸差异,但该差异通过固定基准宽高上的 CSS transform scale 表达,并在相机推进期间同样使用 `1440ms` 缓动;当前态不再额外叠 CSS scale。
|
||||||
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
|
- 影响范围:`server-rs/crates/module-jump-hop/src/application.rs`、`src/services/jump-hop/jumpHopRuntimeModel.ts`、`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、跳一跳运行态定向测试。
|
||||||
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
|
- 验证方式:`npm test -- src/services/jump-hop/jumpHopRuntimeModel.test.ts src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx`、`cargo test -p module-jump-hop --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
@@ -1918,7 +1443,7 @@
|
|||||||
## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
|
## 2026-06-03 跳一跳角色形象改为陶泥儿 logo 透明 PNG
|
||||||
|
|
||||||
- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
|
- 背景:跳一跳运行态此前仍使用旧内置 / CSS 角色形象,和用户要求的陶泥儿 logo 角色不一致,也容易和 DOM 地块层出现遮挡层级问题。
|
||||||
- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色沿拖拽方向明显拉长,落地后向反方向回弹两次。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
|
- 决策:`jump-hop` v1 不再渲染内置 3D 角色几何体;运行态和结果页统一使用 `public/branding/jump-hop-taonier-character.png`,该文件由陶泥儿 logo 处理为透明 PNG 后接入。蓄力时角色只做垂直压缩,落地后保留真实落点并轻量回弹。`characterAsset` 继续仅作为历史兼容描述字段,不能重新打开角色生图槽或把角色图片作为创作者可配置输入。
|
||||||
- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
|
- 影响范围:`src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`、`src/components/jump-hop-result/JumpHopResultView.tsx`、跳一跳 PRD 和平台链路文档。
|
||||||
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
|
- 验证方式:跳一跳运行态 / 结果页测试需要断言角色图片 src 为 `/branding/jump-hop-taonier-character.png`,并确认旧默认角色 fallback 不再出现。
|
||||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
@@ -2163,33 +1688,6 @@
|
|||||||
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
|
||||||
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
|
||||||
|
|
||||||
## 2026-06-11 前端组件收口补记
|
|
||||||
|
|
||||||
- 背景:个人中心 profile 弹层已抽成独立组件,但 `error / loading / empty / content` 仍在多个 modal 中重复分支,继续沿业务页各写一套会让后续 profile 面板收口越来越碎。
|
|
||||||
- 决策:新增 `src/components/common/PlatformAsyncStatePanel.tsx` 作为互斥异步状态骨架,只承接 `errorState / loadingState / emptyState / children` 四类 slot 的优先级切换;`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfileRechargeModal.tsx`、`PlatformProfilePlayedWorksModal.tsx` 与 `PlatformProfileReferralModal.tsx` 已接入。若错误或成功提示需要与内容并存,继续留在业务组件外层,不把 `PlatformAsyncStatePanel` 扩成全能状态机。
|
|
||||||
- 决策:`src/components/common/PlatformSegmentedTabs.tsx` 支持 `layout="scroll"`,用于横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 以及 `RpgEntryHomeView.tsx` 的排行 / 分类筛选已接入。共享组件先负责 tab 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心中重复出现时,沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset,业务页不再重复复制长 `itemClassName`。
|
|
||||||
- 决策:`src/components/PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层统一复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 现在负责 `absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 点击拦截,业务 importer 不再各自维护像素风关闭按钮壳和冒泡控制。
|
|
||||||
- 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。
|
|
||||||
- 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。
|
|
||||||
- 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。
|
|
||||||
- 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。
|
|
||||||
- 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。
|
|
||||||
- 决策:`CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续收口到 `PlatformActionButton`,`pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。
|
|
||||||
- 决策:白底 / 暗色面板里的轻量空态和普通 CTA 继续向共享组件收口。`PuzzleResultView.tsx` 的缺草稿提示、`RpgCreationAssetDebugPanel.tsx` 的空诊断提示、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState`,`Match3DResultView.tsx` 的引用素材列表直接复用 `PlatformAssetPickerGrid` 自己的空态;`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgCreationRoleAssetStudioModalImpl.tsx`、`RpgCreationEntityEditorShared.tsx` 里的局部 `ActionButton` 包装层,以及 `RpgAdventurePanel.tsx` / `RpgAdventurePanelOverlays.tsx` 里标准 runtime CTA 都改为委托 `PlatformActionButton surface="editorDark"`。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若业务仍需 `stopPropagation`、tone 映射、运行态 icon 排版或局部字号,可保留薄包装层,但不要再直接写原生 `<button>` 基础 chrome。
|
|
||||||
- 决策:白底 / 浅色结果页和工作台顶部的“左箭头 + 返回文案”轻量返回入口统一收口到 `src/components/common/PlatformBackActionButton.tsx`;共享组件固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的返回按钮骨架,并只开放 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前已覆盖 `PuzzleResultView.tsx`、`SquareHoleResultView.tsx`、`Match3DResultView.tsx`、`VisualNovelResultView.tsx`、`PuzzleClearResultView.tsx`、`JumpHopResultView.tsx`、`WoodenFishResultView.tsx` 与 `BabyObjectMatchResultView.tsx`;暖色生成页继续走 `GenerationHeaderBackButton`,`BigFishResultView.tsx` 这类 dark hero / 强品牌返回入口继续走 `PlatformIconButton darkMini`,不把三条视觉语义线硬并成一个组件。
|
|
||||||
- 决策:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton` 和 `SkillEffectPreview.tsx` 的“重新预览”按钮也继续并入这条暗色按钮收口线,统一委托 `PlatformActionButton surface="editorDark"`;局部包装层只保留 `stopPropagation`、图标排布、`tone` 映射和极少量视觉微调。后续暗色编辑器里的局部动作按钮若只是普通 CTA,不再新增原生 `<button>` 实现,优先沿用“薄包装 + 共享按钮本体”模式。
|
|
||||||
- 决策:RPG 创作侧标准 dark header / footer 动作也继续纳入同一条按钮收口线。`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”以及 `RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`;局部壳层只保留布局、宽度/字号贴合和少量 tone 语义,不再为标准 dark close / cancel / save CTA 单独维护原生 `<button>` 基础 chrome。
|
|
||||||
- 决策:RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 也继续纳入这条收口线。`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 保持 runtime 专属语义,不继续硬并到普通平台按钮。
|
|
||||||
- 决策:`PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
|
|
||||||
- 决策:`PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
|
||||||
- 决策:`PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,后续逐个迁移并补对应交互测试。
|
|
||||||
- 决策:认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该壳层只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 只保留各自表单状态和提交流程。
|
|
||||||
- 决策:账号弹窗可以继续复用 `PlatformAuthModalShell` 的平台主题 overlay 与 auth card 壳层,但通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号 direct mode 的唯一 dialog 语义与 safe-area 布局,不把账号安全详情、换绑手机号或修改密码子面板并进登录表单语义。
|
|
||||||
- 决策:运行态弹窗先按玩法目录沉淀薄壳,只有跨玩法接口真正稳定后才上升到 `common/`。拼图运行态用 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx` 承接道具确认、设置、退出改造、失败和通关结算的 overlay / dialog / footer / button 骨架;抓大鹅和跳一跳结算分别保留在各自 runtime shell 内抽本地 settlement shell / summary / actions。`PlatformToolModalShell` 继续只服务平台白底工具弹窗,不强塞到像素风或游戏运行态 overlay;拖拽 ghost、飞行动画、原图查看和全屏 runtime 容器不按旧 modal 债务处理。
|
|
||||||
- 决策:NPC dark modal footer 和暗色明细空态也继续纳入同一条收口线。`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带独立业务语义的控件继续保留局部实现。
|
|
||||||
- 决策:详情页头部动作组合统一收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`。`PlatformDetailTopbar` 只负责返回按钮、标题居中槽位和右侧动作槽位的布局,可在 `pill` / `icon` 返回入口之间切换;`PlatformDetailShareActions` 只负责“前置 badge 区块 + 作品号复制 + 分享复制”这组稳定动作,并允许按页面关闭复制或分享其中一项。`RpgEntryWorldDetailView.tsx` 已接入 overlay 版完整动作组,`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,同时继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续详情页若只是复用返回、标题、作品号复制或分享动作排列,优先组合这两个薄组件,不把作者、摘要、封面、轮播或业务 CTA 塞进共享配置对象。
|
|
||||||
- 验证方式:`npm run test -- src/components/common/PlatformAsyncStatePanel.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/common/PlatformSegmentedTabs.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/PixelCloseButton.test.tsx src/components/CharacterChatModal.test.tsx src/components/MapModal.test.tsx`、`npm run test -- src/components/common/useMudPointConfirmController.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-creation-result/RpgCreationResultActionBar.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
|
|
||||||
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||||
|
|
||||||
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
||||||
@@ -2215,14 +1713,6 @@
|
|||||||
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`。
|
- 验证方式:后台修改 `mudPointCost` 后保存,`GET /api/creation-entry/config` 返回同名数字字段;底部加号创作入口卡显示前端格式化后的泥点消耗;创作表单泥点不足提示和后端实际钱包扣费都使用该数字;关闭态卡片仍只显示 `暂未开放`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-11 拼图与拼消消运行态剩余阻断层继续局部收口
|
|
||||||
|
|
||||||
- 背景:账号弹窗、拼图 runtime、抓大鹅结算、跳一跳结算和拼图 onboarding 收口后,允许范围内仍剩拼图“正在准备下一关”阻断层与拼消消 runtime 的等待 / 结算层各自手写 overlay;它们结构相近,但又都带着玩法本地语义。
|
|
||||||
- 决策:平台入口里的拼图“正在准备下一关”只在 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 下新增 `PuzzleRuntimeBlockingOverlay.tsx` 做本地薄壳,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板上推到 `common/`。拼消消 runtime 则在 `src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,统一 `!activeRun`、`level_cleared`、`finished`、`level_failed` 三类局部 overlay 的结构和动作出口。拖拽 ghost、swap flight、补牌 / 消除动画和全屏 runtime 容器继续视为玩法专属视觉层,不算旧 modal 债务。
|
|
||||||
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、相关测试与 PlatformUiKit 收口文档。
|
|
||||||
- 验证方式:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
- 关联文档:`docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md`。
|
|
||||||
|
|
||||||
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
|
## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
|
||||||
|
|
||||||
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
|
- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
|
||||||
@@ -2238,11 +1728,3 @@
|
|||||||
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
|
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
|
||||||
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
|
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款
|
|
||||||
|
|
||||||
- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。
|
|
||||||
- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。拼图首图后台生成互斥改为 SpacetimeDB `puzzle_background_compile_task` 表,使用 `task_id + request_id` 作为 claim id,释放时校验 claim id,避免旧任务误删新租约。
|
|
||||||
- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。
|
|
||||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`node scripts/check-server-rs-ddd-boundaries.mjs`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。
|
|
||||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
# 开发工作流
|
# 开发工作流
|
||||||
|
|
||||||
> 用途:给本地 Agent 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。
|
> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。
|
||||||
|
|
||||||
## 标准任务流程
|
## 标准任务流程
|
||||||
|
|
||||||
```text
|
```text
|
||||||
同步代码 → 读取 AGENTS.md → 读取 docs/project-memory/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交
|
同步代码 → 读取 AGENTS.md → 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交
|
||||||
```
|
```
|
||||||
|
|
||||||
## 建议启动方式
|
## 建议启动方式
|
||||||
|
|
||||||
在项目根目录启动本地 Agent:
|
在项目根目录启动 Hermes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/Genarrative
|
cd /path/to/Genarrative
|
||||||
@@ -30,7 +30,7 @@ hermes
|
|||||||
- [ ] 当前分支是否正确
|
- [ ] 当前分支是否正确
|
||||||
- [ ] 是否已拉取最新代码
|
- [ ] 是否已拉取最新代码
|
||||||
- [ ] 是否阅读 `AGENTS.md`
|
- [ ] 是否阅读 `AGENTS.md`
|
||||||
- [ ] 是否阅读 `docs/project-memory/shared-memory/` 相关文件
|
- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件
|
||||||
- [ ] 是否阅读 `README.md` 中的运行和检查命令
|
- [ ] 是否阅读 `README.md` 中的运行和检查命令
|
||||||
- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README
|
- [ ] 是否阅读 `docs/README.md` 及任务相关分类 README
|
||||||
- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档
|
- [ ] 是否存在足够具体的 PRD / 设计 / 技术文档
|
||||||
@@ -52,8 +52,6 @@ npm run dev
|
|||||||
|
|
||||||
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
|
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
|
||||||
|
|
||||||
本地 `npm run dev`、`npm run dev:spacetime` 和 `npm run dev:api-server` 会在 Rust 子进程环境中绕过项目默认 `sccache` wrapper,避免损坏的本机 cache daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。生产 / Jenkins 构建仍按流水线自身的 sccache 策略执行。
|
|
||||||
|
|
||||||
该命令会启动:
|
该命令会启动:
|
||||||
|
|
||||||
- SpacetimeDB standalone
|
- SpacetimeDB standalone
|
||||||
@@ -97,15 +95,6 @@ npm run dev:web
|
|||||||
npm run dev:admin-web
|
npm run dev:admin-web
|
||||||
```
|
```
|
||||||
|
|
||||||
本地 SSH 服务器管理面板:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run server-manager:panel
|
|
||||||
```
|
|
||||||
|
|
||||||
该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart <unit>` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。
|
|
||||||
面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。
|
|
||||||
|
|
||||||
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
`npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-<timestamp>.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||||
|
|
||||||
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。
|
||||||
@@ -120,16 +109,6 @@ npm run server-manager:panel
|
|||||||
npm run dev:spacetime:logs
|
npm run dev:spacetime:logs
|
||||||
```
|
```
|
||||||
|
|
||||||
本机隔离验证外部生成 worker 队列、API-only 更新和 worker 动态扩缩容时,优先使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
该命令生成 `deploy/container/worker-smoke/` 下的 gitignored env 与端口 state,启动独立 compose project 和独立 SpacetimeDB,用 unsupported job 验证 worker claim / fail 回写;排查时用 `api-update` 确认 API 重建不触碰 worker,用 `scale <n>` 调整 worker 数量。
|
|
||||||
`external_generation_job` 是 private table,worker-smoke 通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 查询队列表。
|
|
||||||
worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 依赖官方大镜像下载。若容器内 Cargo 下载依赖不稳定,追加 `--local-binary`,让容器内 Cargo 复用本机 Cargo 缓存构建当前 `api-server` 二进制,并把产物放进 Debian bookworm smoke runtime;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;隔离端口或库数据需要重建时追加 `--force`。
|
|
||||||
|
|
||||||
后台管理前端:
|
后台管理前端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -161,15 +140,6 @@ Codex 项目级 hook 保存在 `.codex/config.toml` 与 `.codex/hooks/`:
|
|||||||
|
|
||||||
个人 token、模型路由、MCP server 仍属于个人环境;需要时由成员本机执行 `codegraph install` 或查看 `codegraph install --print-config codex`,不要提交个人全局配置。
|
个人 token、模型路由、MCP server 仍属于个人环境;需要时由成员本机执行 `codegraph install` 或查看 `codegraph install --print-config codex`,不要提交个人全局配置。
|
||||||
|
|
||||||
Agent 本地 RAG 文档索引:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:index
|
|
||||||
npm run rag:search -- --query "搜索内容"
|
|
||||||
```
|
|
||||||
|
|
||||||
RAG 主要供 Agent 检索项目上下文,开发者仍按 `AGENTS.md`、`docs/README.md` 和 `docs/project-memory/` 阅读正式文档。RAG 仅索引项目文档和项目共享记忆,默认不把 LanceDB、Transformers.js 或本地 embedding 模型装入根 `package.json`。需要启用 RAG 时,Agent 必须先询问用户是否安装本地运行时依赖;用户确认后只安装到 gitignored 的 `.rag/runtime/`,模型缓存和向量库也留在 `.rag/`。具体命令见 `scripts/rag/README.md`。
|
|
||||||
|
|
||||||
## 常用检查命令
|
## 常用检查命令
|
||||||
|
|
||||||
- 后端通用用户行为埋点统一通过 `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`。
|
- 后端通用用户行为埋点统一通过 `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`。
|
||||||
@@ -285,17 +255,17 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- 工程修改要同步更新对应文档。
|
- 工程修改要同步更新对应文档。
|
||||||
- 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。
|
- 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。
|
||||||
- `docs/project-memory/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。
|
- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。
|
||||||
- 如果 `docs/project-memory/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
|
- 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。
|
||||||
|
|
||||||
## 提交前建议让 Agent 执行
|
## 提交前建议让 Hermes 执行
|
||||||
|
|
||||||
涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路、Phase 2 之后的跨玩法回归或本地 dev 栈时,先按 `quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 和对应单项门禁文档执行自动脚本与体验检查。
|
涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路、Phase 2 之后的跨玩法回归或本地 dev 栈时,先按 `quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 和对应单项门禁文档执行自动脚本与体验检查。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请检查当前 git diff,指出:
|
请检查当前 git diff,指出:
|
||||||
1. 是否违反 AGENTS.md 或 docs/project-memory/shared-memory 约定;
|
1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定;
|
||||||
2. 是否需要补充 docs;
|
2. 是否需要补充 docs;
|
||||||
3. 是否有长期知识需要写入 docs/project-memory/shared-memory;
|
3. 是否有长期知识需要写入 .hermes/shared-memory;
|
||||||
4. 建议的测试命令和提交信息。
|
4. 建议的测试命令和提交信息。
|
||||||
```
|
```
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
| 场景 | 优先阅读 |
|
| 场景 | 优先阅读 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 建立项目背景 | `README.md`、`AGENTS.md`、`docs/project-memory/shared-memory/project-overview.md` |
|
| 建立项目背景 | `README.md`、`AGENTS.md`、`.hermes/shared-memory/project-overview.md` |
|
||||||
| 找当前文档 | `docs/README.md` |
|
| 找当前文档 | `docs/README.md` |
|
||||||
| 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` |
|
| 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` |
|
||||||
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
| 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` |
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
通用复杂任务:
|
通用复杂任务:
|
||||||
|
|
||||||
1. `AGENTS.md`
|
1. `AGENTS.md`
|
||||||
2. `docs/project-memory/shared-memory/`
|
2. `.hermes/shared-memory/`
|
||||||
3. `docs/README.md`
|
3. `docs/README.md`
|
||||||
4. 与任务匹配的当前融合文档
|
4. 与任务匹配的当前融合文档
|
||||||
|
|
||||||
@@ -50,5 +50,5 @@
|
|||||||
- 新增工程实现时,如果已有对应当前文档,必须同步更新。
|
- 新增工程实现时,如果已有对应当前文档,必须同步更新。
|
||||||
- 如果没有合适位置,新文档文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`。
|
- 如果没有合适位置,新文档文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`。
|
||||||
- 阶段性流水账、一次性修复记录和已关闭实验不要再新增为长期文档。
|
- 阶段性流水账、一次性修复记录和已关闭实验不要再新增为长期文档。
|
||||||
- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `docs/project-memory/shared-memory/`。
|
- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `.hermes/shared-memory/`。
|
||||||
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。
|
- 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。
|
||||||
@@ -50,4 +50,4 @@
|
|||||||
## 是否需要更新团队记忆
|
## 是否需要更新团队记忆
|
||||||
|
|
||||||
- [ ] 不需要
|
- [ ] 不需要
|
||||||
- [ ] 需要,建议更新:`docs/project-memory/shared-memory/...`
|
- [ ] 需要,建议更新:`.hermes/shared-memory/...`
|
||||||
@@ -15,38 +15,6 @@
|
|||||||
- 关联:相关文件、文档、提交或 Issue
|
- 关联:相关文件、文档、提交或 Issue
|
||||||
```
|
```
|
||||||
|
|
||||||
## 外部生成 worker 业务失败重试会撞上钱包扣退费幂等
|
|
||||||
|
|
||||||
- 现象:同一个外部生成 job 如果第一次业务失败后退款,再用同一个业务资源 ID 自动重试并成功,钱包 `consume` ledger 可能因为同 ID 已存在而跳过,最终出现“失败已退、成功不再扣”的余额漂移。
|
|
||||||
- 原因:资产操作扣费和退款都用稳定 ledger id 做幂等;这能保护 lease 过期后的崩溃重领不重复扣费,但不适合“已明确失败且已退款”的自动业务重试。
|
|
||||||
- 处理:拼图 `puzzle_compile_draft` 首期设置 `max_attempts=1`,业务失败直接 failed,只保留 running lease 过期后的崩溃重领。后续若要恢复自动 retry,必须先引入 attempt-aware billing 或可配对撤销的账本接口。
|
|
||||||
- 验证:检查 `external_generation_job.max_attempts`、worker 失败回写和钱包 ledger;失败后草稿进入 failed,重试应由用户重新触发新任务,而不是旧 job 自动 pending。
|
|
||||||
- 关联:`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
|
||||||
|
|
||||||
## 外部生成队列不再由 HTTP 进程兜底执行
|
|
||||||
|
|
||||||
- 现象:拼图首关生成接口返回 `queued`,但生成页长时间不完成,重启 `genarrative-api.service` 也没有推进任务。
|
|
||||||
- 原因:HTTP 角色只入队,不再直接调用外部 provider;如果没有运行 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 或 `all` 的进程,`external_generation_job` 会停留在 `pending/running`,直到有 worker claim。
|
|
||||||
- 处理:生产用 `systemctl enable --now genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service` 启动保底 worker 和 controller;首次 API deploy 会在默认 worker pattern 下自动启用并启动 `@1`、等待 worker active,并重启验活 controller。扩容默认交给 controller 按队列统计启动 `@2.service` 等实例,手动扩缩容只作为兜底;worker 收到停机信号后会停止 claim 新任务并等待当前任务完成。本地 smoke 可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev`;本地若只想同步排查可通过 `.env.local` 或本机环境设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,但这不会创建 job,也不能验证 worker 扩缩容。
|
|
||||||
- 验证:`systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'` 能看到 controller 和 worker 实例;queue 模式下任务被 claim 后 `worker_id` 与 `lease_expires_at` 会更新,完成后 session 进入 ready 或 failed;inline 模式下不应产生新的 `external_generation_job`。
|
|
||||||
- 关联:`deploy/systemd/genarrative-external-generation-worker@.service`、`deploy/systemd/genarrative-external-generation-controller.service`、`deploy/env/external-generation-controller.env.example`、`server-rs/crates/spacetime-module/src/external_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 外部生成 worker 业务写回必须同事务校验 lease guard
|
|
||||||
|
|
||||||
- 现象:worker `complete/fail` 已校验 `worker_id + lease_token`,但如果玩法 session / work profile 写回在此之前单独调用,过期 worker 仍可能先写入业务状态,随后才在 job complete/fail 阶段失败;带计费包装的旧 worker 还可能因为 stale guard 错误触发补偿退款。
|
|
||||||
- 原因:队列状态栅栏只保护 `external_generation_job` 自身,不会自动保护玩法 procedure。业务写回必须自己带 claim 后的 `job_id / worker_id / lease_token`,并在同一个 SpacetimeDB transaction 内校验 job 仍为 `running`、lease 未过期、job kind、owner 和 source entity 匹配。
|
|
||||||
- 处理:拼图首图 worker 的前置 `compile_puzzle_agent_draft`、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 已接入 `external_generation_job` lease guard;api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,错误文本包含 `external_generation_job 当前不是 running 状态` 或 `external_generation_job 不存在` 时也按 stale guard 处理。inline 模式只允许 `job_id / worker_id / lease_token` 三项同时为空,半空 guard 仍拒绝。后续迁移其它玩法 worker 时必须复用该模式,不能只在 worker 进程内保存一份 token。
|
|
||||||
- 验证:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
|
||||||
- 关联:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/asset_billing.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
|
||||||
|
|
||||||
## 外部生成 worker 核心业务写回失败不能完成 job
|
|
||||||
|
|
||||||
- 现象:worker 已经生成图片并拿到本地合成 session 快照,但 SpacetimeDB 业务写回因连接、旧 wasm 或 lease guard 失败没有真实落库;如果此时仍把 `external_generation_job` 标成 `completed`,前端只会看到队列完成而 session 长时间不变化,后续也没有 worker 会重领修复。
|
|
||||||
- 原因:同步 HTTP handler 的“外部 provider 已成功但 SpacetimeDB 短暂不可用时返回内存快照”降级语义,不能直接搬进异步 worker。worker 的完成状态必须代表核心业务事实已经持久化。
|
|
||||||
- 处理:worker 路径的 `save_puzzle_generated_images` / `save_puzzle_ui_background` 等核心业务写回失败时直接返回错误;只有核心写回已经成功后的非关键投影回写才允许降级记录 warning。业务失败态也必须先写回 session / work profile,写回成功后才允许把队列 job 标为 failed;失败态未写回时保留租约,等待 lease 过期后重领。生产首装和首次 API deploy 都必须至少启用一个 worker 实例,例如 `systemctl enable --now genarrative-external-generation-worker@1.service`。
|
|
||||||
- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server asset_operation_billing_does_not_refund_stale_worker_lease_errors --manifest-path server-rs/Cargo.toml`,并在 smoke 时确认 queued 任务被 worker 消费后 session 真实更新。
|
|
||||||
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。
|
|
||||||
|
|
||||||
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
|
## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复
|
||||||
|
|
||||||
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。
|
- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。
|
||||||
@@ -103,14 +71,6 @@
|
|||||||
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。
|
- 验证:`cargo test -p spacetime-module custom_world_public_interactions_accept_legacy_missing_published_at --manifest-path server-rs/Cargo.toml`。
|
||||||
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。
|
- 关联:`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md`。
|
||||||
|
|
||||||
## 拼图公开推荐不要只按 Published 判断
|
|
||||||
|
|
||||||
- 现象:后台把拼图作品隐藏后,作品不在公开列表里显示,但玩家通关其它拼图后的推荐下一作品仍可能出现这条隐藏作品。
|
|
||||||
- 原因:拼图隐藏只把 `puzzle_work_profile.visible` 置为 `false`,不会把 `publication_status` 从 `Published` 改走;通关推荐候选曾只通过 `by_puzzle_work_publication_status().filter(Published)` 取数,漏掉可见性判断。
|
|
||||||
- 处理:拼图公开消费路径统一使用 `Published + visible=true`,范围包括 `puzzle_gallery_view`、`puzzle_gallery_card_view`、兼容 gallery/detail procedure、公开点赞 / Remix、正式公开 runtime 启动和通关后的 `recommended_next_works` 候选。
|
|
||||||
- 验证:`cargo test -p spacetime-module hidden_published_puzzle_work_is_not_public_visible_candidate --manifest-path server-rs/Cargo.toml`,并在需要时用后台隐藏一个已发布拼图后重试通关推荐。
|
|
||||||
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 推荐页 WF 点赞不要落到 RPG / custom-world
|
## 推荐页 WF 点赞不要落到 RPG / custom-world
|
||||||
|
|
||||||
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。
|
- 现象:推荐页里给 `WF-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。
|
||||||
@@ -139,8 +99,8 @@
|
|||||||
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
|
## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
|
||||||
|
|
||||||
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
|
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
|
||||||
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。若认证成功后把 `daily_login` 当普通埋点写入,或历史 `profile_task_config` 仍保留旧 `profile.login.daily` 事件键,新业务日也可能写了登录事件却查不到任务进度。
|
- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。
|
||||||
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。后端认证成功统一走 `SpacetimeClient::record_daily_login_tracking_event(...)` 与 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return`,默认每日登录任务读取时会把结算字段自愈到 canonical `daily_login`。
|
- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。
|
||||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
|
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
|
||||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||||
|
|
||||||
@@ -240,30 +200,6 @@
|
|||||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。
|
||||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 内嵌泥点确认弹窗必须自带平台主题作用域
|
|
||||||
|
|
||||||
- 现象:拼图 / 抓大鹅统一创作页点击生成后,“确认消耗泥点”弹窗正文和按钮存在,但弹窗面板背景透明,只剩遮罩和文字。
|
|
||||||
- 原因:`PlatformMudPointConfirmDialog` 作为二级确认常以 `portal={false}` 内嵌到工作台局部 DOM,局部节点不一定继承 `.platform-theme`;`platform-modal-shell` 依赖 `--platform-modal-fill` 等主题变量,变量缺失时面板底色解析为空。
|
|
||||||
- 处理:共享泥点确认弹窗默认在 overlay 上带 `platform-theme platform-theme--<theme>`、`platform-modal-backdrop` 和实色遮罩,在 panel 上带 `platform-modal-shell platform-remap-surface`;单按钮状态弹窗也要有默认 light 主题,避免未来独立调用复现。
|
|
||||||
- 验证:浏览器触发 `/creation/puzzle` 与 `/creation/match3d` 的泥点确认弹窗,检查 overlay 最近主题 class 存在、`--platform-modal-fill` 有值且面板为实底;聚焦测试覆盖默认 overlay / panel class。
|
|
||||||
- 关联:`src/components/common/PlatformMudPointConfirmDialog.tsx`、`src/components/common/PlatformStatusDialog.tsx`、`src/components/unified-creation/workspaces/PuzzleCreationWorkspace.tsx`、`src/components/unified-creation/workspaces/Match3DCreationWorkspace.tsx`。
|
|
||||||
|
|
||||||
## 拼图结果页关卡图不要裁切,嵌套图片预览要高于详情弹窗
|
|
||||||
|
|
||||||
- 现象:拼图结果页“拼图关卡”列表里的关卡图底部被裁掉;进入关卡详情后点击画面图,看起来没有打开全屏预览。
|
|
||||||
- 原因:关卡列表复用 `PlatformMediaFrame aspect="standard"` 默认 `object-cover`,方图或竖向生成图会在 4:3 框内被裁切;关卡详情弹窗自身层级高于 `CreativeImageInputPanel` 默认图片预览层级,预览实际打开但被压在详情弹窗后面。
|
|
||||||
- 处理:结果页关卡缩略图显式传 `imageClassName="h-full w-full object-contain"` 保留完整画面;`CreativeImageInputPanel` 提供 `mainImagePreviewZIndexClassName`,嵌套在高层级弹窗内时由调用方传更高层级。
|
|
||||||
- 验证:聚焦测试断言关卡缩略图使用 `object-contain` 且没有 `object-cover`,并断言关卡详情内主图预览 overlay 层级高于详情弹窗;浏览器里检查列表完整显示图片,详情内点击画面图能打开可见预览。
|
|
||||||
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.test.tsx`。
|
|
||||||
|
|
||||||
## 图片大图预览不要复用白底工具弹窗
|
|
||||||
|
|
||||||
- 现象:点击图像输入面板里的参考图或主图预览后,页面只出现白底非全屏弹窗,背后原页面透出,不能缩放或拖拽查看细节。
|
|
||||||
- 原因:图片查看和工具弹窗共用了 `UnifiedModal` 白底壳层;该壳层适合编辑 / 选择工具,不适合沉浸式看图,也没有图片边界拖拽状态。
|
|
||||||
- 处理:纯图片预览统一走 `PlatformImagePreviewModal`,全屏黑底展示,初始 contain 保证完整图片可见,缩放夹在 `1x-4x`,拖拽位移按缩放后的图片边界夹取,避免把图片拖到露出背景。
|
|
||||||
- 验证:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx` 应覆盖黑底全屏、缩放上限、拖拽边界和关闭按钮。
|
|
||||||
- 关联:`src/components/common/PlatformImagePreviewModal.tsx`、`src/components/common/CreativeImageInputPanel.tsx`。
|
|
||||||
|
|
||||||
## 玩法入口分类字段缺失要前端兜底
|
## 玩法入口分类字段缺失要前端兜底
|
||||||
|
|
||||||
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。
|
||||||
@@ -434,7 +370,7 @@
|
|||||||
|
|
||||||
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`,但服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误。
|
||||||
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作。
|
||||||
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml` 的 `spacetimedb = "..."` 对齐;必要时执行 `spacetime version install <version> && spacetime version use <version>`,然后重启 `npm run dev:spacetime`。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免继续复用旧宿主。
|
||||||
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout。
|
||||||
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`、`server-rs/Cargo.toml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
@@ -553,14 +489,6 @@
|
|||||||
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。
|
||||||
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## release otelcol 217/USER 和备份 timer inactive 分开处理
|
|
||||||
|
|
||||||
- 现象:release 巡检中 `otelcol-contrib.service` 持续 `activating (auto-restart)`,日志出现 `status=217/USER` / `Failed to determine user credentials`;同时 `genarrative-database-backup.timer` 显示 `enabled` 但 `inactive/dead`,`NEXT` / `Trigger` 为空。
|
|
||||||
- 原因:otelcol 的 systemd unit 使用 `User=otelcol` / `Group=otelcol`,但目标机缺少该系统用户和 `/etc/otelcol/genarrative-debug.yaml`;备份 timer 在 missed window 后未处于 active waiting 状态,直接重启 Persistent timer 可能在白天立刻补跑冷备份并停止 SpacetimeDB。
|
|
||||||
- 处理:先创建系统用户 / 组 `otelcol`,补齐 `/var/lib/otelcol`、`/etc/otelcol/genarrative-debug.yaml` 和 `/var/log/genarrative`,再重启 `otelcol-contrib.service`;修 timer 时先 `touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer`,再 `systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,避免当前窗口立即补跑冷备份。
|
|
||||||
- 验证:`otelcol-contrib.service` 为 `active (running)` 且监听 `127.0.0.1:4317/4318`;`systemctl list-timers genarrative-database-backup.timer --all` 显示下一次触发约为次日 `03:20`;`/healthz`、`/readyz`、`/v1/ping` 仍通过。
|
|
||||||
- 关联:`scripts/jenkins-server-provision.sh`、`deploy/systemd/otelcol-contrib.service`、`deploy/otelcol/genarrative-debug.yaml`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
|
||||||
|
|
||||||
## 外部 API 失败没法追溯先查 external_api_call_failure
|
## 外部 API 失败没法追溯先查 external_api_call_failure
|
||||||
|
|
||||||
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。
|
||||||
@@ -1221,7 +1149,6 @@
|
|||||||
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||||
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。
|
||||||
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
- 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。
|
||||||
- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`。
|
|
||||||
- 验证:`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"`。
|
- 验证:`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`。
|
- 关联:`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`。
|
||||||
|
|
||||||
@@ -1352,8 +1279,8 @@
|
|||||||
|
|
||||||
- 现象: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` 可以执行,但构建在调用包装器时失败。
|
- 现象: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` 可能先超时。
|
- 原因:环境、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"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 由 `scripts/dev.mjs` 给 Rust 子进程注入直通 wrapper,自动绕过项目默认 sccache,避免损坏的 daemon 阻断 `spacetime publish` 或 `api-server` 启动;显式设置的非 sccache 自定义 wrapper 会被保留。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`。
|
- 处理:保留 `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` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 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.mjs`、`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`。
|
- 关联:`scripts/dev.mjs`、`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 / 一体化脚本
|
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||||
@@ -2012,9 +1939,9 @@
|
|||||||
|
|
||||||
- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
|
- 现象:`http://127.0.0.1:3001/` 打不开,但 `3000 / 3101 / 8082` 仍有进程;`npm run dev` 直接退出,没有把新栈拉起来。
|
||||||
- 原因:旧 worktree 的 `api-server`、`spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
|
- 原因:旧 worktree 的 `api-server`、`spacetime-standalone` 和 Vite 还活着,或者当前 worktree 的本机 SpacetimeDB CLI 默认版本低于仓库锁定版本,`scripts/dev.mjs` 会先校验版本再启动并直接报错退出。
|
||||||
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list`,确认本机 CLI/standalone 与 `server-rs/Cargo.toml` 锁定版本一致;不一致时先直接升级 / 切换到锁定版本,再重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。
|
- 处理:先停掉占用端口的旧进程,再执行 `spacetime version list` 和 `spacetime version use 2.4.1`,确认本机 CLI/standalone 与仓库一致后重新启动 `npm run dev -- --no-interactive --web-port 3001 --api-port 8083 --spacetime-port 3103 --admin-web-port 3104`。
|
||||||
- 验证:`http://127.0.0.1:3001/`、`http://127.0.0.1:8083/healthz`、`http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。
|
- 验证:`http://127.0.0.1:3001/`、`http://127.0.0.1:8083/healthz`、`http://127.0.0.1:3103/v1/ping` 都返回 200,且进程命令行指向当前 worktree 路径而不是别的仓库。
|
||||||
- 关联:`scripts/dev.mjs`、`docs/project-memory/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/dev.mjs`、`.hermes/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
## 微信历史孤儿作品不要让新注册账号顶替
|
## 微信历史孤儿作品不要让新注册账号顶替
|
||||||
|
|
||||||
@@ -2262,11 +2189,3 @@
|
|||||||
- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。
|
- 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。
|
||||||
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
|
- 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=<id>` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。
|
||||||
- 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋
|
|
||||||
|
|
||||||
- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。
|
|
||||||
- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。
|
|
||||||
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
|
|
||||||
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
|
|
||||||
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Genarrative 项目共享概览
|
# Genarrative 项目共享概览
|
||||||
|
|
||||||
更新时间:`2026-06-12`
|
更新时间:`2026-06-03`
|
||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
|
|||||||
server-rs + Axum + SpacetimeDB
|
server-rs + Axum + SpacetimeDB
|
||||||
```
|
```
|
||||||
|
|
||||||
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.5.0` 对齐;遇到版本不匹配时先升级到 `server-rs/Cargo.toml` 锁定版本,升级后重启对应 SpacetimeDB 进程再重试。
|
当前 SpacetimeDB crate、SDK、CLI / standalone、生成 bindings 和容器压测镜像统一按 `2.4.1` 对齐。
|
||||||
|
|
||||||
职责边界:
|
职责边界:
|
||||||
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
# 团队协作约定
|
# 团队协作约定
|
||||||
|
|
||||||
> 用途:约定 3 名开发人员在各自本地开发环境和 Agent 中协作开发、共享项目记忆的方式。
|
> 用途:约定 3 名开发人员在各自本地 Hermes 中协作开发、共享项目记忆的方式。
|
||||||
|
|
||||||
## 基本模式
|
## 基本模式
|
||||||
|
|
||||||
- 每位开发人员在自己的电脑上使用本地 Agent。
|
- 每位开发人员在自己的电脑上使用本地 Hermes。
|
||||||
- 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支。
|
- 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支。
|
||||||
- 团队共享内容优先放在本仓库 `docs/project-memory/` 与 `docs/` 中,通过 Git 同步。
|
- 团队共享内容优先放在本仓库 `.hermes/` 与 `docs/` 中,通过 Git 同步。
|
||||||
- 不共享个人 `~/.hermes` 目录。
|
- 不共享个人 `~/.hermes` 目录。
|
||||||
|
|
||||||
## 共享与禁止共享
|
## 共享与禁止共享
|
||||||
|
|
||||||
推荐共享:
|
推荐共享:
|
||||||
|
|
||||||
- `docs/project-memory/shared-memory/` 团队级长期记忆
|
- `.hermes/shared-memory/` 团队级长期记忆
|
||||||
- `docs/project-memory/plans/` 阶段性实施计划
|
- `.hermes/plans/` 阶段性实施计划
|
||||||
- `docs/project-memory/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
|
- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划
|
||||||
- `.hermes/skills/` Hermes 专用仓库级 skills
|
- `.hermes/skills/` 未来可复用仓库级 skills
|
||||||
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
|
- `docs/` 中 PRD、设计、技术、经验、审计、查询手册
|
||||||
- `AGENTS.md` 项目级 Agent 约束
|
- `AGENTS.md` 项目级 Agent 约束
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
1. 拉取最新代码。
|
1. 拉取最新代码。
|
||||||
2. 阅读 `AGENTS.md`。
|
2. 阅读 `AGENTS.md`。
|
||||||
3. 阅读 `docs/project-memory/shared-memory/` 中与任务相关的文件。
|
3. 阅读 `.hermes/shared-memory/` 中与任务相关的文件。
|
||||||
4. 阅读 `docs/README.md` 和任务相关分类 README。
|
4. 阅读 `docs/README.md` 和任务相关分类 README。
|
||||||
5. 阅读对应 PRD、设计、技术、经验或审计文档。
|
5. 阅读对应 PRD、设计、技术、经验或审计文档。
|
||||||
6. 如果文档不足以指导编码,先补充或修正文档。
|
6. 如果文档不足以指导编码,先补充或修正文档。
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
1. 运行与修改范围匹配的测试或验证命令。
|
1. 运行与修改范围匹配的测试或验证命令。
|
||||||
2. 更新相关 `docs/` 文档。
|
2. 更新相关 `docs/` 文档。
|
||||||
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
|
3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。
|
||||||
4. 若产生长期有效知识,更新 `docs/project-memory/shared-memory/`。
|
4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。
|
||||||
5. 若形成 Hermes 专用可复用流程,考虑沉淀到 `.hermes/skills/`。
|
5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。
|
||||||
6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。
|
6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。
|
||||||
|
|
||||||
## 文档阅读顺序
|
## 文档阅读顺序
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
1. `README.md`
|
1. `README.md`
|
||||||
2. `AGENTS.md`
|
2. `AGENTS.md`
|
||||||
3. `docs/project-memory/shared-memory/`
|
3. `.hermes/shared-memory/`
|
||||||
4. `docs/README.md`
|
4. `docs/README.md`
|
||||||
5. `docs/experience/README.md`
|
5. `docs/experience/README.md`
|
||||||
6. `docs/audits/README.md`
|
6. `docs/audits/README.md`
|
||||||
@@ -284,7 +284,7 @@ fn anonymous_user_cannot_publish_generated_draft() {
|
|||||||
| 正式产品验收 / PRD 场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】<功能名>BDD场景-YYYY-MM-DD.md` | 产品、测试、开发都需要长期参考的验收标准、用户故事、功能边界。 |
|
| 正式产品验收 / PRD 场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】<功能名>BDD场景-YYYY-MM-DD.md` | 产品、测试、开发都需要长期参考的验收标准、用户故事、功能边界。 |
|
||||||
| 技术/API/领域行为场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【技术验收】<功能名>BDD场景-YYYY-MM-DD.md` | 后端 API、领域规则、状态机、SpacetimeDB reducer/table、SSE/异步任务、埋点副作用。 |
|
| 技术/API/领域行为场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【技术验收】<功能名>BDD场景-YYYY-MM-DD.md` | 后端 API、领域规则、状态机、SpacetimeDB reducer/table、SSE/异步任务、埋点副作用。 |
|
||||||
| 自动化 Gherkin feature 文件 | `tests/features/*.feature` 或 `e2e/features/*.feature` | 项目已接入 Cucumber/Playwright BDD 等 Gherkin runner 时。未接入前不要随意新建测试 runner 目录。 |
|
| 自动化 Gherkin feature 文件 | `tests/features/*.feature` 或 `e2e/features/*.feature` | 项目已接入 Cucumber/Playwright BDD 等 Gherkin runner 时。未接入前不要随意新建测试 runner 目录。 |
|
||||||
| 稳定流程或团队经验 | `docs/project-memory/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 |
|
| 稳定流程或团队经验 | `.hermes/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 |
|
||||||
|
|
||||||
默认规则:
|
默认规则:
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ e2e/features/invite-code.feature
|
|||||||
- 实施计划:当前任务上下文或 `.tmp/<task-name>.md`
|
- 实施计划:当前任务上下文或 `.tmp/<task-name>.md`
|
||||||
- 产品/验收文档:当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】中文标题-YYYY-MM-DD.md`
|
- 产品/验收文档:当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】中文标题-YYYY-MM-DD.md`
|
||||||
- 技术设计:当前 `docs/` 融合文档,必要时新增 `docs/【技术方案】中文标题-YYYY-MM-DD.md`
|
- 技术设计:当前 `docs/` 融合文档,必要时新增 `docs/【技术方案】中文标题-YYYY-MM-DD.md`
|
||||||
- 共享经验或稳定流程:`docs/project-memory/shared-memory/` 或 `.hermes/skills/`
|
- 共享经验或稳定流程:`.hermes/shared-memory/` 或 `.hermes/skills/`
|
||||||
|
|
||||||
BDD 文档建议包含:
|
BDD 文档建议包含:
|
||||||
|
|
||||||
@@ -389,4 +389,4 @@ npm run test -- --run <相关测试文件>
|
|||||||
```
|
```
|
||||||
|
|
||||||
- [ ] 若涉及后端 Rust/API,按相关 DDD/SpacetimeDB 文档运行对应 cargo/npm/API smoke 验证。
|
- [ ] 若涉及后端 Rust/API,按相关 DDD/SpacetimeDB 文档运行对应 cargo/npm/API smoke 验证。
|
||||||
- [ ] 若产生长期有效经验,已同步到 `docs/project-memory/shared-memory/` 或合适的仓库级 skill。
|
- [ ] 若产生长期有效经验,已同步到 `.hermes/shared-memory/` 或合适的仓库级 skill。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ metadata:
|
|||||||
- 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。
|
- 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。
|
||||||
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
|
- 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。
|
||||||
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
|
- 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。
|
||||||
- 修改本地联调文档或 `docs/project-memory/shared-memory/pitfalls.md` 中的 dev 启动口径。
|
- 修改本地联调文档或 `.hermes/shared-memory/pitfalls.md` 中的 dev 启动口径。
|
||||||
|
|
||||||
## 当前端口职责
|
## 当前端口职责
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range`
|
|||||||
- `scripts/dev.mjs`
|
- `scripts/dev.mjs`
|
||||||
- `scripts/dev-utils.mjs`
|
- `scripts/dev-utils.mjs`
|
||||||
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
|
- `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`
|
||||||
- `docs/project-memory/shared-memory/pitfalls.md`
|
- `.hermes/shared-memory/pitfalls.md`
|
||||||
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
|
2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。
|
||||||
3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。
|
3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。
|
||||||
4. 修改 watch 时保持模块边界:SpacetimeDB 只监听 `spacetime-module` 且改动后重新 publish,不重启 standalone 宿主;api-server 排除 `spacetime-module`;web/admin-web 源码变化交给 Vite 自身 HMR,外层调度器不要再监听前端目录重启 Vite。
|
4. 修改 watch 时保持模块边界:SpacetimeDB 只监听 `spacetime-module` 且改动后重新 publish,不重启 standalone 宿主;api-server 排除 `spacetime-module`;web/admin-web 源码变化交给 Vite 自身 HMR,外层调度器不要再监听前端目录重启 Vite。
|
||||||
@@ -124,5 +124,5 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap
|
|||||||
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
|
- [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。
|
||||||
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
|
- [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。
|
||||||
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。
|
- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。
|
||||||
- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。
|
- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`。
|
||||||
- [ ] 修改中文文件后运行 `npm run check:encoding`。
|
- [ ] 修改中文文件后运行 `npm run check:encoding`。
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ description: 在 Genarrative 新增、开放或重构玩法创作工具时,按
|
|||||||
先读:
|
先读:
|
||||||
|
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `docs/project-memory/shared-memory/`
|
- `.hermes/shared-memory/`
|
||||||
- `CONTEXT.md`
|
- `CONTEXT.md`
|
||||||
- `docs/README.md`
|
- `docs/README.md`
|
||||||
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
|
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
|
||||||
- 相关玩法 PRD 或设计文档
|
- 相关玩法 PRD 或设计文档
|
||||||
|
|
||||||
如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `docs/project-memory/shared-memory/`。
|
如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `.hermes/shared-memory/`。
|
||||||
|
|
||||||
### 2. 定玩法边界
|
### 2. 定玩法边界
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
11. 接 `api-server`:
|
11. 接 `api-server`:
|
||||||
- `src/runtime_profile.rs`:Query params / parser / handler / response builder。
|
- `src/runtime_profile.rs`:Query params / parser / handler / response builder。
|
||||||
- `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint;选择路径前确认产品定位。
|
- `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint;选择路径前确认产品定位。
|
||||||
12. 最后更新当前 `docs/` 文档和必要的 `docs/project-memory/shared-memory/` 摘要,并确认 diff 不只是生成物。
|
12. 最后更新当前 `docs/` 文档和必要的 `.hermes/shared-memory/` 摘要,并确认 diff 不只是生成物。
|
||||||
|
|
||||||
## 验证命令示例
|
## 验证命令示例
|
||||||
|
|
||||||
|
|||||||
29
AGENTS.md
29
AGENTS.md
@@ -1,24 +1,16 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
## 项目共享记忆
|
## 团队 Hermes 共享记忆
|
||||||
- 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员和各自本地 Agent 之间同步长期项目知识。
|
- 本仓库的团队级 Hermes 共享内容位于 [`.hermes/`](.hermes/),用于在 3 名开发人员各自本地 Hermes 之间同步长期项目记忆。
|
||||||
- [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。
|
|
||||||
- 开始复杂开发任务前,除阅读本文件外,还应优先读取:
|
- 开始复杂开发任务前,除阅读本文件外,还应优先读取:
|
||||||
- [`.hermes/README.md`](.hermes/README.md)
|
- [`.hermes/README.md`](.hermes/README.md)
|
||||||
- [`docs/project-memory/README.md`](docs/project-memory/README.md)
|
- [`.hermes/shared-memory/project-overview.md`](.hermes/shared-memory/project-overview.md)
|
||||||
- [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md)
|
- [`.hermes/shared-memory/team-conventions.md`](.hermes/shared-memory/team-conventions.md)
|
||||||
- [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md)
|
- [`.hermes/shared-memory/development-workflow.md`](.hermes/shared-memory/development-workflow.md)
|
||||||
- [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.md)
|
- 与任务相关的 [`.hermes/shared-memory/decision-log.md`](.hermes/shared-memory/decision-log.md) 和 [`.hermes/shared-memory/pitfalls.md`](.hermes/shared-memory/pitfalls.md)
|
||||||
- 与任务相关的 [`docs/project-memory/shared-memory/decision-log.md`](docs/project-memory/shared-memory/decision-log.md) 和 [`docs/project-memory/shared-memory/pitfalls.md`](docs/project-memory/shared-memory/pitfalls.md)
|
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `.hermes/shared-memory/` 中对应文件。
|
||||||
- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件。
|
- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
|
||||||
- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。
|
- 若 `.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
|
||||||
|
|
||||||
## Agent 本地 RAG
|
|
||||||
- 本仓库提供面向 Agent 的本地文档 RAG,入口位于 [`scripts/rag/`](scripts/rag/);RAG 主要用于 Agent 检索项目上下文,不替代人工阅读 `AGENTS.md`、`docs/README.md` 和 `docs/project-memory/`。
|
|
||||||
- 开始复杂任务、跨模块任务或不确定文档入口时,Agent 可先用 `npm run rag:search -- --query "问题或关键词" --limit 8 --max-chars 12000` 取候选上下文;需要刷新索引时运行 `npm run rag:index`。
|
|
||||||
- RAG 输出只作为候选上下文。涉及精确代码或文档修改时,仍需打开对应源文件核对;来源冲突时,以当前代码和最新 `docs/` 为准。
|
|
||||||
- 默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`。需要启用时,Agent 必须先询问用户是否安装,并在确认后只安装到 gitignored 的 `.rag/runtime/`;详细命令见 [`scripts/rag/README.md`](scripts/rag/README.md)。
|
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
@@ -75,10 +67,11 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture
|
|||||||
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
|
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
|
||||||
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
|
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
|
||||||
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
|
||||||
|
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
|
||||||
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。
|
- 涉及 `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) 执行。
|
- 涉及 `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` 执行。
|
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。
|
||||||
- 涉及前端或 Node 侧的 SpacetimeDB 订阅、绑定使用时,按当前生成绑定、项目代码和官方文档核对;本仓库不再维护单独 TypeScript / C# / Unity SpacetimeDB skill。
|
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。
|
||||||
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
|
||||||
- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
|
- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。
|
||||||
- 数据库表结构更改后,需要对齐migration.rs
|
- 数据库表结构更改后,需要对齐migration.rs
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ Docker Compose
|
|||||||
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
├─ spacetimedb :3101,独立数据卷,供 api-server 连接
|
||||||
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||||
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||||
├─ external-generation-worker,独立 worker 进程,消费 external_generation_job 队列
|
|
||||||
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||||
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||||
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
||||||
容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。
|
|
||||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||||
|
|
||||||
@@ -57,7 +55,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
|||||||
|
|
||||||
## 构建工具链
|
## 构建工具链
|
||||||
|
|
||||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||||
|
|
||||||
## 启动与验证
|
## 启动与验证
|
||||||
|
|
||||||
@@ -76,7 +74,6 @@ curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
|||||||
```bash
|
```bash
|
||||||
npm run container:logs -- nginx
|
npm run container:logs -- nginx
|
||||||
npm run container:logs -- api-server
|
npm run container:logs -- api-server
|
||||||
npm run container:logs -- external-generation-worker
|
|
||||||
npm run container:logs -- otelcol
|
npm run container:logs -- otelcol
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -88,73 +85,6 @@ npm run container:config -- --print
|
|||||||
|
|
||||||
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
||||||
|
|
||||||
动态扩缩容外部生成 worker 时,只调整 `external-generation-worker` service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:up -- --scale external-generation-worker=3 external-generation-worker
|
|
||||||
npm run container:up -- --scale external-generation-worker=1 external-generation-worker
|
|
||||||
```
|
|
||||||
|
|
||||||
动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`;`inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。
|
|
||||||
|
|
||||||
### 外部生成 Worker 隔离 Smoke
|
|
||||||
|
|
||||||
如果只想在本机隔离验证 worker 模式,不复用 `deploy/container/api-server.env`,使用专用脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
该脚本会生成 gitignored 的 `deploy/container/worker-smoke/api-server.env` 与端口 state,使用独立 compose project、独立 SpacetimeDB 数据卷和独立 host 端口,完成 `build -> up-spacetime -> publish -> up -> enqueue -> api-update -> enqueue`。测试 job 使用 `worker_smoke_unsupported` 类型,不访问真实 VectorEngine、LLM 或 OSS;预期结果是 worker 领取队列任务后按“不支持的任务类型”执行失败分支,从而验证队列 claim、lease、失败回写路径和 API / worker 进程隔离。`external_generation_job` 是 private table,脚本通过 worker 日志里的 job_id 和 unsupported 记录确认消费,不通过 CLI SQL 绕过权限。`smoke` 默认只启动 `api-server` 与 `external-generation-worker`,避免无关前端 / Nginx 镜像构建;需要同时验证 Nginx 时可分步执行 `up --with-nginx`。
|
|
||||||
|
|
||||||
分步排查时可执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- init --force
|
|
||||||
npm run container:worker-smoke -- build
|
|
||||||
npm run container:worker-smoke -- up-spacetime
|
|
||||||
npm run container:worker-smoke -- publish
|
|
||||||
npm run container:worker-smoke -- up
|
|
||||||
npm run container:worker-smoke -- enqueue before-update
|
|
||||||
npm run container:worker-smoke -- api-update
|
|
||||||
npm run container:worker-smoke -- enqueue after-update
|
|
||||||
npm run container:worker-smoke -- status
|
|
||||||
```
|
|
||||||
|
|
||||||
如果隔离端口或库数据需要重置:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- smoke --force
|
|
||||||
```
|
|
||||||
|
|
||||||
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测仍默认使用 `clockworklabs/spacetime:v2.4.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- smoke --local-binary
|
|
||||||
```
|
|
||||||
|
|
||||||
`api-update` 只会 `--force-recreate api-server`,并校验 `external-generation-worker` 容器 ID 不变;如要同时重建 API 镜像,使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- api-update --build
|
|
||||||
```
|
|
||||||
|
|
||||||
验证 worker 动态扩缩容:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- scale 3
|
|
||||||
npm run container:worker-smoke -- ps
|
|
||||||
npm run container:worker-smoke -- enqueue scaled-workers
|
|
||||||
npm run container:worker-smoke -- scale 1
|
|
||||||
```
|
|
||||||
|
|
||||||
查看或清理隔离环境:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run container:worker-smoke -- logs external-generation-worker
|
|
||||||
npm run container:worker-smoke -- down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
停止:
|
停止:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ FROM rust:1.93-bookworm AS rust-builder
|
|||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY server-rs ./server-rs
|
COPY server-rs ./server-rs
|
||||||
COPY public ./public
|
|
||||||
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
||||||
cp server-rs/target/release/api-server /tmp/api-server
|
cp server-rs/target/release/api-server /tmp/api-server
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ GENARRATIVE_API_PORT=8082
|
|||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
GENARRATIVE_API_WORKER_THREADS=4
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
# 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。
|
|
||||||
GENARRATIVE_PROCESS_ROLE=api
|
|
||||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
|
||||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: genarrative-container-loadtest
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
spacetimedb:
|
spacetimedb:
|
||||||
image: ${GENARRATIVE_CONTAINER_SPACETIME_IMAGE:-clockworklabs/spacetime:v2.4.1}
|
image: clockworklabs/spacetime:v2.4.1
|
||||||
user: root
|
user: root
|
||||||
command:
|
command:
|
||||||
[
|
[
|
||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
cpus: "2.0"
|
cpus: "2.0"
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
env_file:
|
env_file:
|
||||||
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
- ./api-server.env
|
||||||
environment:
|
environment:
|
||||||
GENARRATIVE_API_HOST: 0.0.0.0
|
GENARRATIVE_API_HOST: 0.0.0.0
|
||||||
GENARRATIVE_API_PORT: 8082
|
GENARRATIVE_API_PORT: 8082
|
||||||
@@ -69,32 +69,6 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
external-generation-worker:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: deploy/container/api-server.Dockerfile
|
|
||||||
target: api-runtime
|
|
||||||
cpus: "2.0"
|
|
||||||
mem_limit: 1g
|
|
||||||
env_file:
|
|
||||||
- ${GENARRATIVE_CONTAINER_API_ENV_FILE:-./api-server.env}
|
|
||||||
environment:
|
|
||||||
GENARRATIVE_PROCESS_ROLE: external-generation-worker
|
|
||||||
GENARRATIVE_TRACKING_OUTBOX_DIR: /var/lib/genarrative/tracking-outbox-worker
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
|
|
||||||
OTEL_SERVICE_NAME: genarrative-external-generation-worker
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 4096
|
|
||||||
hard: 4096
|
|
||||||
depends_on:
|
|
||||||
spacetimedb:
|
|
||||||
condition: service_healthy
|
|
||||||
otelcol:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
|
|||||||
8
deploy/env/api-server.env.example
vendored
8
deploy/env/api-server.env.example
vendored
@@ -7,14 +7,6 @@ GENARRATIVE_API_PORT=8082
|
|||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
GENARRATIVE_API_WORKER_THREADS=4
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
# api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
|
|
||||||
GENARRATIVE_PROCESS_ROLE=api
|
|
||||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
|
||||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# 复制到 /etc/genarrative/external-generation-controller.env 后按机器容量调整。
|
|
||||||
# controller 只管理 systemd worker 实例;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
|
||||||
# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-controller。
|
|
||||||
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS=1
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS=8
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER=2
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS=10000
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS=6
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE=genarrative-external-generation-worker@{}.service
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN=false
|
|
||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
|
||||||
OTEL_SERVICE_NAME=genarrative-external-generation-controller
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 复制到 /etc/genarrative/external-generation-worker.env 后按机器容量调整。
|
|
||||||
# 该文件只覆盖 worker 专属参数;SpacetimeDB、外部 provider 密钥继续复用 api-server.env。
|
|
||||||
# systemd 模板会强制设置 GENARRATIVE_PROCESS_ROLE=external-generation-worker
|
|
||||||
# 和 GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i,避免多实例 ID 冲突。
|
|
||||||
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
|
||||||
# 单次 lease 会由 worker 自动续租;该值覆盖心跳抖动窗口即可。
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=3600
|
|
||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
|
||||||
OTEL_SERVICE_NAME=genarrative-external-generation-worker
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Genarrative External Generation Worker Controller
|
|
||||||
After=network-online.target spacetimedb.service
|
|
||||||
Wants=network-online.target
|
|
||||||
Requires=spacetimedb.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/opt/genarrative/current
|
|
||||||
EnvironmentFile=/etc/genarrative/api-server.env
|
|
||||||
EnvironmentFile=-/etc/genarrative/external-generation-controller.env
|
|
||||||
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
|
||||||
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-controller GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/controller OTEL_SERVICE_NAME=genarrative-external-generation-controller /opt/genarrative/current/api-server
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
KillSignal=SIGINT
|
|
||||||
TimeoutStopSec=120
|
|
||||||
LimitNOFILE=65535
|
|
||||||
TasksMax=512
|
|
||||||
|
|
||||||
# controller 需要调用 systemctl 管理 worker@N 实例,因此不降为 genarrative 用户。
|
|
||||||
# 它只复用 api-server 发布包和 SpacetimeDB 配置,不直接执行外部生成任务。
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Genarrative External Generation Worker %i
|
|
||||||
After=network-online.target spacetimedb.service
|
|
||||||
Wants=network-online.target
|
|
||||||
Requires=spacetimedb.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=genarrative
|
|
||||||
Group=genarrative
|
|
||||||
WorkingDirectory=/opt/genarrative/current
|
|
||||||
EnvironmentFile=/etc/genarrative/api-server.env
|
|
||||||
EnvironmentFile=-/etc/genarrative/external-generation-worker.env
|
|
||||||
Environment="LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib"
|
|
||||||
ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=external-generation-worker GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=%H-%i GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox/%H-%i OTEL_SERVICE_NAME=genarrative-external-generation-worker /opt/genarrative/current/api-server
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
KillSignal=SIGINT
|
|
||||||
TimeoutStopSec=7200
|
|
||||||
LimitNOFILE=65535
|
|
||||||
TasksMax=2048
|
|
||||||
|
|
||||||
# worker 复用 api-server 发布目录;外部生成审计与临时运行态只写服务端私有目录。
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ReadWritePaths=/opt/genarrative /var/lib/genarrative
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
|
|
||||||
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
|
||||||
|
|
||||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.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)。
|
生产部署切换到 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)。
|
||||||
|
|
||||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||||
@@ -85,8 +83,6 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon
|
|||||||
|
|
||||||
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
|
||||||
|
|
||||||
平台 UI Kit 的提示 / 确认弹窗收口到 `src/components/common/UnifiedConfirmDialog.tsx`,复制反馈收口到 `src/components/common/useCopyFeedback.ts`、`src/components/common/CopyFeedbackButton.tsx`、`src/components/common/CopyCodeButton.tsx` 与 `src/components/common/CopyFeedbackMessage.tsx`,基础状态提示收口到 `src/components/common/PlatformStatusMessage.tsx`,运行态短错误 / 成功 / 反馈 toast 收口到 `src/components/common/PlatformRuntimeStatusToast.tsx`,平台空态 / 轻量加载态收口到 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮收口到 `src/components/common/PlatformActionButton.tsx`,平台白底子面板 / 小型列表卡片收口到 `src/components/common/PlatformSubpanel.tsx`,平台输入框 / 文本域收口到 `src/components/common/PlatformTextField.tsx`,平台字段标题收口到 `src/components/common/PlatformFieldLabel.tsx`,平台媒体预览框收口到 `src/components/common/PlatformMediaFrame.tsx`,平台胶囊状态标签收口到 `src/components/common/PlatformPillBadge.tsx`,平台图片全屏预览收口到 `src/components/common/PlatformImagePreviewModal.tsx`,平台 / 个人中心弹窗关闭按钮收口到 `src/components/common/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、全屏黑底图片查看、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md)。
|
|
||||||
|
|
||||||
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
- 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。
|
- 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。
|
||||||
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `docs/project-memory/shared-memory/`。
|
- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `.hermes/shared-memory/`。
|
||||||
- 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。
|
- 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
| 阶段 | 状态 | 说明 |
|
| 阶段 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md`、`docs/README.md` 和 `docs/project-memory/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 |
|
| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md`、`docs/README.md` 和 `.hermes/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 |
|
||||||
| Phase 1 首批统一壳 | 已收口 | `puzzle`、`match3d`、`jump-hop`、`wooden-fish` 已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,竖屏滚动和字段契约已回归。 |
|
| Phase 1 首批统一壳 | 已收口 | `puzzle`、`match3d`、`jump-hop`、`wooden-fish` 已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,竖屏滚动和字段契约已回归。 |
|
||||||
| Phase 1 补充统一壳 | 已收口 | `jump-hop` 也已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,统一创作页现在接管拼图、抓大鹅、跳一跳和敲木鱼四条入口的可见外壳与滚动。 |
|
| Phase 1 补充统一壳 | 已收口 | `jump-hop` 也已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,统一创作页现在接管拼图、抓大鹅、跳一跳和敲木鱼四条入口的可见外壳与滚动。 |
|
||||||
| Phase 2 契约与配置治理 | 已完成 | `creationTypes[].unifiedCreationSpec`、前端 fallback、后台配置校验和文档门禁已按现有测试与 schema 检查收口。 |
|
| Phase 2 契约与配置治理 | 已完成 | `creationTypes[].unifiedCreationSpec`、前端 fallback、后台配置校验和文档门禁已按现有测试与 schema 检查收口。 |
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
状态:已完成。
|
状态:已完成。
|
||||||
|
|
||||||
- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md`、`docs/project-memory/shared-memory/document-map.md` 中补上规划入口。
|
- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md`、`.hermes/shared-memory/document-map.md` 中补上规划入口。
|
||||||
- 补齐 `当前进度`、`执行轮次` 和可并行任务表,后续每个 phase 完成后更新本文档的状态、验收命令和风险。
|
- 补齐 `当前进度`、`执行轮次` 和可并行任务表,后续每个 phase 完成后更新本文档的状态、验收命令和风险。
|
||||||
|
|
||||||
退出条件:
|
退出条件:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
|
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块内部再用自适应 blob+gradient 算法提取 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
|
||||||
|
|
||||||
首版目标:
|
首版目标:
|
||||||
|
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色
|
- 单图资产槽位:无独立角色图槽位;v1 固定使用陶泥儿 logo 透明 PNG 角色
|
||||||
- 系列素材槽位:
|
- 系列素材槽位:
|
||||||
- `batchId = jump-hop-tile-atlas`
|
- `batchId = jump-hop-tile-atlas`
|
||||||
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
|
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
|
||||||
- `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId`
|
- `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot,所有 slot 必须对应唯一 OSS path / `assetObjectId`
|
||||||
- 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG
|
- 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界(y₀/y₁/y₂/y₃、x₀/x₁/x₂/x₃),按此边界划分为 3×3 block 并保留 5 个有效 block,将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG
|
||||||
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位,后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理
|
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位;后端先对图集做洋红去背(BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘
|
||||||
- 失败回写:生成失败时 session 保持 failed,可从生成页重试
|
- 失败回写:生成失败时 session 保持 failed,可从生成页重试
|
||||||
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
|
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2;前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
|
||||||
- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`
|
- API 命名空间:`/api/creation/jump-hop/*`、`/api/runtime/jump-hop/*`
|
||||||
@@ -60,11 +60,11 @@
|
|||||||
|
|
||||||
## 5. 地板贴图图集
|
## 5. 地板贴图图集
|
||||||
|
|
||||||
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图;不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
|
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体,并在固定 `4列*3行` UV 网中提供六张面贴图(AI prompt 侧不变);后端通过自适应 blob+gradient 算法检测面的实际位置并切图,不再依赖固定像素坐标均分。不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
|
||||||
|
|
||||||
图集要求:
|
图集要求:
|
||||||
|
|
||||||
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`;
|
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求(prompt 侧不变)。后端切图改为自适应 blob+gradient 算法检测面的实际像素区域,不再依赖固定像素坐标均分。
|
||||||
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
|
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
|
||||||
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
|
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
|
||||||
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
|
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
|
||||||
@@ -187,7 +187,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
|
|||||||
|
|
||||||
1. 创作页只显示主题输入;
|
1. 创作页只显示主题输入;
|
||||||
2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图;
|
2. 生成链路只调用一次地板贴图图集 image2,不再调用角色生图;
|
||||||
3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG;
|
3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG;
|
||||||
4. 结果页不依赖旧角色图片槽;
|
4. 结果页不依赖旧角色图片槽;
|
||||||
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
|
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
|
||||||
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;
|
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# 项目记忆目录
|
|
||||||
|
|
||||||
本目录保存可以进入 Git 的项目级长期知识,供开发者和 Agent 读取。`.hermes/` 只保留 Hermes 工具专用资源,不再作为项目知识库。
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/project-memory/
|
|
||||||
├─ README.md
|
|
||||||
├─ shared-memory/
|
|
||||||
│ ├─ project-overview.md
|
|
||||||
│ ├─ team-conventions.md
|
|
||||||
│ ├─ development-workflow.md
|
|
||||||
│ ├─ document-map.md
|
|
||||||
│ ├─ decision-log.md
|
|
||||||
│ ├─ pitfalls.md
|
|
||||||
│ └─ handoff-template.md
|
|
||||||
├─ plans/
|
|
||||||
└─ todos/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用原则
|
|
||||||
|
|
||||||
- 开发前先读 `AGENTS.md`,再按任务读取 `docs/project-memory/shared-memory/` 和当前 `docs/` 文档。
|
|
||||||
- 长期有效的架构约定、接口变化、排障经验、开发流程和协作规则写入 `shared-memory/`。
|
|
||||||
- 阶段性计划写入 `plans/`,已确定但暂未实施的共享 TODO 写入 `todos/`。
|
|
||||||
- 如果本目录内容与代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期记忆。
|
|
||||||
- 禁止写入个人配置、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
|
||||||
|
|
||||||
## RAG 索引
|
|
||||||
|
|
||||||
本目录是 Agent 本地 RAG 的高权重索引源。RAG 主要用于 Agent 检索上下文,不替代人工阅读入口或正式文档地图。索引脚本位于 `scripts/rag/`,本地生成的 `.rag/` 数据不提交 Git。
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。
|
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。
|
||||||
|
|
||||||
`docs/project-memory/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。
|
`.hermes/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。
|
||||||
|
|
||||||
本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。
|
本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。
|
||||||
|
|
||||||
|
|||||||
@@ -1,531 +0,0 @@
|
|||||||
# PlatformUiKit 弹窗与状态组件收口计划
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
前端已经有 `UnifiedModal` 统一遮罩、无障碍属性、Escape 关闭和移动端底部贴边布局,但业务页面仍反复手写提示弹窗、确认弹窗和 footer 按钮。平台入口的泥点提示、作品删除确认、发布失败提示等都在页面实现内拼接同类 `UnifiedModal` 属性和按钮样式,导致后续调整主题、按钮状态或移动端布局时需要改多个页面。
|
|
||||||
|
|
||||||
## 收口目标
|
|
||||||
|
|
||||||
- `src/components/common/UnifiedModal.tsx` 继续作为底层模态窗口 Module,负责遮罩、panel、header、body、footer 与关闭路径。
|
|
||||||
- 新增 `src/components/common/UnifiedConfirmDialog.tsx` 作为提示 / 确认弹窗 Module,统一承载标题、说明、正文、主按钮、副按钮、危险动作、处理中禁用和主题样式。
|
|
||||||
- 新增 `src/components/common/useCopyFeedback.ts` 作为复制反馈 Module,统一承载剪贴板写入、`idle / copied / failed` 状态、定时复位和卸载清理。
|
|
||||||
- 新增 `src/components/common/CopyFeedbackButton.tsx` 作为复制反馈按钮 Module,统一承载默认复制 / 成功图标、反馈文案、`aria-label` / `title`、纯图标按钮模式和胶囊 action 外观入口。
|
|
||||||
- 新增 `src/components/common/CopyCodeButton.tsx` 作为代码复制按钮 Module,统一承载作品号 / 用户号等短代码 chip 的三态文案、默认可访问名称、标题和胶囊 action 外观透传。
|
|
||||||
- 新增 `src/components/common/CopyFeedbackMessage.tsx` 作为复制反馈提示 Module,统一承载成功 / 失败 toast 或行内状态提示。
|
|
||||||
- 新增 `src/components/common/PlatformStatusMessage.tsx` 作为平台状态提示 Module,统一承载错误、成功、信息和警告提示条的基础边框、底色、文字颜色和默认间距。
|
|
||||||
- 新增 `src/components/common/PlatformEmptyState.tsx` 作为平台空态 / 轻量加载态 Module,统一承载作品架、公开广场和素材选择弹窗的空面板外观。
|
|
||||||
- 新增 `src/components/common/PlatformAssetPickerCard.tsx` 作为平台历史素材选择 Module,统一承载历史图片 / 历史素材的缩略图卡片、读取态、错误态、空态和响应式网格外观。
|
|
||||||
- 新增 `src/components/common/PlatformActionButton.tsx` 作为平台动作按钮 Module,统一承载平台按钮、个人中心主动作按钮和暗色编辑 / 运行面板普通动作按钮的样式族、尺寸、圆角、宽度和禁用态 class。
|
|
||||||
- 新增 `src/components/common/PlatformIconButton.tsx` 作为平台图标动作按钮 Module,统一承载普通 icon button / 图标上传 label / 白底短标签浮动图标按钮的可访问名称、默认 `type="button"`、title 和基础外观。
|
|
||||||
- 新增 `src/components/common/PlatformIconBadge.tsx` 作为平台非交互图标徽章 Module,统一承载弹窗标题、列表项和小卡片里的中性图标槽。
|
|
||||||
- 新增 `src/components/common/PlatformUploadTile.tsx` 作为平台虚线入口 Module,统一承载图片 / 附件上传方块和紧凑虚线动作入口的图标、主副文案、button / label 语义和禁用态。
|
|
||||||
- 新增 `src/components/common/PlatformUploadPreviewCard.tsx` 作为平台上传预览 Module,统一承载上传后的缩略图壳、预览图片、右上角移除按钮和禁用态。
|
|
||||||
- 新增 `src/components/common/PlatformPillSwitch.tsx` 作为平台胶囊开关 Module,统一承载图片面板中类似 AI 重绘的 label + switch 语义、轨道、圆点、禁用态和白底浮层 chrome。
|
|
||||||
- 新增 `src/components/common/PlatformToggleRow.tsx` 作为平台整行开关 Module,统一承载设置面板和结果页配置里的白底 label + checkbox / 状态行。
|
|
||||||
- 新增 `src/components/common/PlatformTextField.tsx` 作为平台输入字段 Module,统一承载白底 / 暗色 input、textarea 和下拉框共用 chrome,认证表单也只保留受控值、原生属性和业务校验。
|
|
||||||
- 新增 `src/components/common/PlatformFieldLabel.tsx` 作为平台字段标签 Module,统一承载结果页、编辑弹窗和创作工作台中的普通字段名、分区标题、表单字段标题、胶囊字段标题和强调胶囊字段标题。
|
|
||||||
- 新增 `src/components/common/PlatformSegmentedTabs.tsx` 作为平台分段选择 Module,统一承载白底结果页 Tab、编辑弹窗二选一和轻量配置 Tab 的容器、按钮、选中态、禁用态、列数、尺寸和截断标签。
|
|
||||||
- 新增 `src/components/common/PlatformStatGrid.tsx` 作为平台统计小卡 Module,统一承载结果页里的数值 / 标签摘要、轻量状态 chip、响应式列数、密度、surface 和 label/value 顺序。
|
|
||||||
- 新增 `src/components/common/PlatformPillBadge.tsx` 作为平台胶囊状态标签 Module,统一承载结果页、作品卡和配置摘要里的单个状态 / 标签 chip。
|
|
||||||
- 新增 `src/components/common/PlatformProgressBar.tsx` 作为平台进度条 Module,统一承载 `progressbar` 语义、`platform-progress-track` 壳、填充宽度、最小可见宽度、未知进度语义、条内覆盖层和局部主题色。
|
|
||||||
- 新增 `src/components/common/PlatformInfoBlock.tsx` 作为平台只读信息块 Module,统一承载弹窗和详情页中的短标签、白底内容壳、单行 / 多行正文排版。
|
|
||||||
- 新增 `src/components/common/PlatformReportDialog.tsx` 作为平台可复制报告弹窗 Module,统一承载来源 / 状态 / 错误这类字段块展示、报告拼装、复制反馈按钮和标准 footer。
|
|
||||||
- 新增 `src/components/common/PlatformSubpanel.tsx` 作为平台白底子面板 Module,统一承载结果页、创作工作台和普通白底面板内的小型列表卡片里的 `platform-subpanel` / flat 外壳、标题行、右侧动作区、圆角、响应式内边距和交互态。
|
|
||||||
- 新增 `src/components/common/PlatformMediaFrame.tsx` 作为平台媒体预览框 Module,统一承载图片源、fallback 图、fallback 文案、固定比例、surface 和可选 overlay。
|
|
||||||
- 新增 `src/components/common/PlatformMediaTileGrid.tsx` 作为平台媒体缩略格网格 Module,统一承载结果页里同尺寸素材 tile 的列数、间距、白底容器、圆角、边框、图片和 fallback 格。
|
|
||||||
- 新增 `src/components/common/PlatformTagEditor.tsx` 作为平台标签编辑 Module,统一承载结果页里的标签 chip、删除、新增输入、Enter / Escape 键盘行为、空态、可选 AI 生成动作和错误提示。
|
|
||||||
- 新增 `src/components/common/PlatformModalCloseButton.tsx` 作为平台弹窗关闭按钮 Module,统一承载个人中心弹窗和平台浮层关闭按钮的尺寸、圆形视觉、默认图标和可访问名称。
|
|
||||||
- 新增 `src/components/common/squareImageCropModel.ts` 作为正方形图片裁剪数学 Module,统一承载居中初始裁剪、尺寸边界和坐标 clamp,头像裁剪和拼图参考图裁剪不再从弹窗组件文件导入 helper。
|
|
||||||
- 平台页面遇到“知道了”“确认 / 取消”“危险确认”这三类弹窗时,优先使用 `UnifiedConfirmDialog`,不再在业务 JSX 中手写 `UnifiedModal` footer。
|
|
||||||
- 带复制反馈的弹窗和详情页优先组合使用 `useCopyFeedback`、`CopyFeedbackButton` 与 `CopyFeedbackMessage`,不再重复写 `useState + setTimeout + clearTimeout` 的复制状态机,也不在业务 JSX 中手写 copied / failed 文案分支。
|
|
||||||
- 白底平台弹窗、详情页、结果页、个人页、认证入口、统一创作工作台和通用创作输入区中的普通错误 / 成功 / 信息 / 警告 / 中性提示条优先使用 `PlatformStatusMessage`,不再在业务 JSX 中重复拼 `border-rose-* / bg-rose-* / text-rose-*`、`border-emerald-* / bg-emerald-* / text-emerald-*`、`platform-banner--danger / success / info / warning / neutral` 或个人页 token 色值 class。
|
|
||||||
- 平台公开列表、作品架、分类列表、素材选择弹窗、RPG 暗色编辑器和 RPG 运行态弹窗 / 面板中的“正在读取 / 暂无内容 / 当前筛选下没有内容 / 还没有配置”等无操作空态优先使用 `PlatformEmptyState`,业务页只传展示内容和必要的 `surface` / `size`,不再重复写 `platform-surface--soft`、虚线空态面板或暗色编辑器 dashed 空态 class。
|
|
||||||
- 平台弹窗、个人中心弹窗、认证入口、公共确认弹窗 footer、统一创作工作台、创作面板和 RPG 暗色弹窗 / 运行面板中的普通主动作 / 次动作按钮优先使用 `PlatformActionButton`,业务页只传 `surface`、`tone`、`size`、`shape`、`fullWidth` 和动作回调,不再重复拼 `platform-button` / `platform-primary-button`、暗色按钮边框 / 底色、圆角、px/py、字号和禁用态 class。
|
|
||||||
- 普通图标动作按钮、图标上传 label 和白底短标签浮动图标按钮优先使用 `PlatformIconButton`,业务页只传 `label`、`icon`、可选 `children`、可选 `title`、`asChild="label"` 和局部尺寸 class,不再重复手写 `platform-icon-button`、浮动白底按钮、`type="button"` 与 aria。平台浮层、个人中心弹窗和资料面板中只承担“关闭当前弹窗”的圆形图标按钮优先使用 `PlatformModalCloseButton`,业务页只传 `label`、`onClick` 和必要的 `variant` / `icon`,不再重复手写 `platform-modal-close`、绝对定位白底关闭按钮或关闭按钮 aria。
|
|
||||||
- 弹窗标题、列表项和小卡片里的非交互中性图标槽优先使用 `PlatformIconBadge`,业务页只传 icon、尺寸和形状,不再重复拼 `grid h-* w-* place-items-center bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]`。
|
|
||||||
- 平台表单和结果页中的方形上传入口、紧凑虚线新增入口优先使用 `PlatformUploadTile`,业务页只传 `label`、`hint`、可选 `icon`、`size`、`showLabel`、`disabled`、`asChild="label"` 或点击回调,不再重复手写虚线边框、图标、提示文案和 hover / 禁用态 class。上传后的方形图片预览优先使用 `PlatformUploadPreviewCard`,业务页只保留文件读取、预览数组和删除回调,不再重复手写缩略图壳、`object-cover` 图片和右上角移除按钮。
|
|
||||||
- 特殊内容弹窗仍可直接使用 `UnifiedModal`,但只有在正文需要复杂网格、媒体预览、渠道按钮或运行态专属布局时才保留自定义 footer。
|
|
||||||
- `UnifiedModal` 补充:平台入口公开编号搜索结果弹层使用 `size="sm"`、`closeLabel="关闭搜索结果"` 和 `closeOnBackdrop={false}`;壳层只保留搜索状态机、命中 / 未命中分支和关闭时清空结果状态,不再手写 overlay、header 和平台 close button 布局。
|
|
||||||
|
|
||||||
## 当前接口
|
|
||||||
|
|
||||||
- `open`:是否展示弹窗。
|
|
||||||
- `title` / `description` / `children`:标题、说明和正文。
|
|
||||||
- `onClose`:关闭弹窗,取消按钮、遮罩和关闭图标共用。
|
|
||||||
- `confirmLabel` / `onConfirm` / `confirmTone` / `confirmDisabled` / `confirmClassName`:主操作按钮;`confirmClassName` 只用于整行按钮、局部主题等外观适配,不让业务页重新手写 footer。
|
|
||||||
- `cancelLabel` / `showCancel` / `cancelDisabled`:副操作按钮。
|
|
||||||
- `busy` / `busyConfirmLabel`:执行中禁用关闭路径,并替换主按钮文案。
|
|
||||||
- `portal`:默认挂到 `document.body`;已有弹窗栈内的二级确认使用 `portal={false}`,避免脱离当前局部遮罩和层级。
|
|
||||||
- `variant`:默认 `platform`;RPG 编辑器内需要像素风确认时使用 `pixel`,不再为简单确认另写专用壳层和按钮。
|
|
||||||
- `overlayClassName` / `panelClassName` / `zIndexClassName`:保留主题和层级 Adapter,不把主题选择写死在组件内。
|
|
||||||
- `useCopyFeedback().copyText(value)`:调用统一剪贴板写入并更新反馈状态。
|
|
||||||
- `useCopyFeedback().copyState`:调用方按 `idle / copied / failed` 渲染文案或图标。
|
|
||||||
- `useCopyFeedback().resetCopyState()`:业务上下文切换时主动清空旧反馈。
|
|
||||||
- `CopyFeedbackButton`:接收 `state`、`idleLabel`、`copiedLabel`、`failedLabel`、三态图标、`showIcon`、`showLabel`、`labelClassName`、`accessibleLabel`、`actionSurface`、`actionTone`、`actionSize`、`actionFullWidth`、`actionAppearance="pill"`、`actionPillTone` 和 `actionPillSize`;文本按钮、chip 按钮和运行态纯图标分享按钮都应走同一 Module。需要平台主按钮外观时通过 `actionSurface="platform"` 或 `actionSurface="profile"` 复用 `PlatformActionButton` 样式,不在业务 JSX 中传整串 `platform-button` class;需要可点击胶囊复制 / 分享 chip 时用 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome,不在业务 JSX 中传 `platform-pill`。
|
|
||||||
- `CopyCodeButton`:接收 `state`、`code`、`codeLabel`、`copiedSuffix`、`failedSuffix`、`codeClassName`、`suffixClassName`、`actionAppearance="pill"`、`actionPillTone`、`actionPillSize` 和复制按钮透传属性;作品号、用户号等短代码 chip 优先用它,不在业务 JSX 中重复写 `{code} + 已复制 / 复制失败` fragments,也不直接传 `platform-pill` class。
|
|
||||||
- `CopyCodeButton` 补充:作品详情页作品号复制按钮使用 `actionAppearance="pill" actionPillTone="neutralSolid" actionPillSize="sm"`;详情页只保留顶部外边距和复制回调,不再把代码 chip 基础 chrome 写在 `platform-work-detail__code`。
|
|
||||||
- `CopyFeedbackMessage`:接收 `state`、`copiedLabel` 和 `failedLabel`;toast 或行内状态只展示成功 / 失败时使用,不在业务页手写三态分支。若场景需要按成功 / 失败切换状态条色值,可在业务壳层继续使用 `useCopyFeedback` 状态机,并组合 `PlatformStatusMessage` 渲染对应 tone。
|
|
||||||
- `PlatformStatusMessage`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"`、`surface="light" | "tinted" | "platform" | "profile" | "editorDark"`、`size="xs" | "sm" | "md"`、`remapSurface`、`children` 和 `className`;根节点固定带 `platform-status-message` 稳定类名,业务测试可断言公共状态条接入。局部可覆盖圆角、外边距和网格布局,但状态色值、基础内边距和字号由 Module 统一控制。结果页、发布检查、素材生成面板和 creation-agent composer 错误条等需要复用旧 `platform-banner--danger / success / info / warning / neutral` token 外观时使用 `surface="platform"`;需要在局部 platform token 作用域内重映射 CSS 变量的提示条传 `remapSurface`,不在业务 JSX 手写 `platform-remap-surface platform-banner`。个人中心弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区需要 profile token 外观时使用 `surface="profile"`,RPG 暗色编辑 / 运行面板内的普通状态提示使用 `surface="editorDark"`;背包故事档案 QA、NPC 叙事提示、角色聊天错误提示、营地编组战斗中提示、自定义选择弹窗错误 / 生成中提示等暗色状态条已迁移。旧 `platform-profile-error` / `platform-profile-success`、暗色手写 `border-*-300/15 bg-*-500/10 text-*-50/90` 和 `platform-banner--danger / success / info / warning / neutral` 不再作为业务 JSX 接口。
|
|
||||||
- `PlatformStatusMessage` 补充:大鱼吃小鱼结果页发布校验阻断项使用 `tone="warning" surface="platform" size="xs"`;结果页只保留阻断项裁剪和文案,不再手写 amber 文本列表。
|
|
||||||
- `PlatformStatusMessage` 补充:个人中心邀请弹窗里的邀请奖励说明使用 `tone="warning" surface="profile" size="md"`;弹窗只保留奖励文案和两行排版,不再手写 amber 提示块。
|
|
||||||
- `PlatformStatusMessage` 补充:拼图首访 onboarding 的输入错误和登录保存错误使用 `surface="editorDark"`;onboarding 只保留错误文案和条件渲染,不再手写暗色红色错误条。
|
|
||||||
- `PlatformStatusMessage` 补充:平台作品详情页分享复制反馈使用 `surface="platform"` 并按 `shareState` 映射 `success / error`;详情页只保留复制状态机和文案,不再为失败态复用成功 toast chrome。
|
|
||||||
- `PlatformStatusMessage` 补充:creative-agent 首页错误提示使用 `tone="error" surface="platform" size="md"`;首页只保留宽度对齐布局 class 和错误文案,不再手写 danger panel chrome。
|
|
||||||
- `PlatformStatusMessage` 补充:平台入口公开编号搜索未命中结果使用 `tone="neutral" surface="platform" size="md"`;壳层只保留搜索错误文案,不再手写普通文本提示块。
|
|
||||||
- `PlatformRuntimeStatusToast`:接收 `tone="error" | "success" | "info" | "warning" | "neutral"`、`surface="light" | "dark" | "solid"`、`size="xs" | "sm" | "md"`、`shape="pill" | "rounded"`、`children` 和 `className`;根节点固定带 `platform-runtime-status-toast` 稳定类名,默认按 `tone` 写入 `role="alert/status"` 与 `aria-live`。它只承接运行态 HUD 中短错误、成功和反馈 chip 的圆角、字号、阴影、色值和可访问语义,具体浮层位置、玩法资产按钮、计分牌、蓄力提示、强品牌 primary 按钮仍由玩法 runtime 控制。跳一跳、拼图、敲木鱼、方洞和宝贝爱画运行态的短错误 / 成功 / 投放反馈已先迁移;后续同类短 toast 不再手写 `rounded-full bg-white/* text-*`、暗色 `border-rose/emerald bg-*/text-*` 或单玩法 `*-runtime-error-chip`。
|
|
||||||
- `PlatformDarkOptionCard`:接收 `selected`、`tone="emerald" | "sky" | "rose" | "amber"`、`radius="sm" | "md" | "lg"`、`padding="sm" | "md" | "lg"`、`children`、`className` 和原生 button props;根节点固定带 `platform-dark-option-card` 稳定类名,统一承接 RPG 暗色弹窗 / 面板中的 selected / idle / hover / disabled 可选项卡按钮外观。NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移;业务页只保留选中判断、点击回调和内容布局,不再重复手写 `rounded-* border px-3 py-*`、`border-*-400/* bg-*-500/10` 或 `border-white/* bg-black/20 hover:border-white/15`。
|
|
||||||
- `PlatformEmptyState`:接收 `surface="soft" | "dashed" | "subpanel" | "editorDark"`、`size="compact" | "panel" | "inline"`、`tone="base" | "soft"`、`children` 和 `className`;根节点固定带 `platform-empty-state` 稳定类名,业务测试可断言公共空态接入。`soft + compact` 用于公开广场、排行和作品架内的轻量空态,`soft + panel` 用于创作中心作品架整块空态,`dashed + panel` 用于素材选择、历史资源等弹窗的大面积空态或读取态,`subpanel + inline` 用于视觉小说 runtime、个人中心充值 / 任务等白底子面板内的无操作空态,`editorDark + compact/inline` 用于 RPG 大编辑器、实体详情弹窗、营地编组、角色聊天和运行态设置弹窗等暗色面板里的纯展示空态 / 禁用提示。组件只承接外观,不内置业务文案。
|
|
||||||
- `PlatformEmptyState` 补充:个人中心存档弹窗和玩过弹窗里的简单“暂无存档 / 暂无玩过”也使用 `surface="subpanel" size="inline"`;玩过弹窗可通过 `tone="base"` 和局部 `text-left` 保留原有白底列表语境,不在业务 JSX 重复写 `rounded-xl bg-zinc-50 px-4 py-* text-sm`。
|
|
||||||
- `PlatformEmptyState` 补充:个人中心钱包账单弹窗里的“暂无账单记录”使用 `surface="subpanel" size="inline"`;业务组件只保留外边距和纵向留白,不再手写白底空态边框、字号和居中文案。
|
|
||||||
- `PlatformEmptyState` 补充:个人中心邀请弹窗里的“已填写邀请码 / 暂无成功邀请”使用 `surface="subpanel"`;业务组件保留面板分支和邀请状态机,不再为无操作提示手写白底空态。
|
|
||||||
- `PlatformEmptyState` 补充:creation-agent 聊天区里的“暂无消息”使用 `surface="subpanel" size="compact"`;工作台保留消息列表滚动容器和文案,不再手写居中空态字号、颜色和高度 class。
|
|
||||||
- `PlatformEmptyState` 补充:大鱼吃小鱼结果页缺少可编辑草稿时使用 `surface="subpanel" size="compact"`;结果页只保留草稿分支和文案,不再为白底无操作提示手写 `PlatformSubpanel` 空面板。
|
|
||||||
- `PlatformEmptyState` 补充:creative-agent 首页抽屉“暂无创作记录”使用 `surface="subpanel" size="inline"`;抽屉只保留分组和历史条目分支,不再手写白底 bordered empty chrome。
|
|
||||||
- `PlatformEmptyState` 补充:creative-agent 工作台消息区“发一句想法,或加一张参考图。”使用 `surface="subpanel" size="compact"`;工作台只保留消息分支和文案,不再为白底空消息面板手写 `PlatformSubpanel` 外壳。
|
|
||||||
- `PlatformEmptyState` 补充:creative-agent 过程面板空态“等待新的创作输入”使用 `surface="subpanel" size="compact"`;过程面板只保留空态分支和文案,非空时继续复用 `PlatformSubpanel` + `PlatformPillBadge` 承接过程列表。
|
|
||||||
- `PlatformTextField`:接收 `variant="input" | "textarea"`、`surface="platform" | "editorDark"`、`size="xs" | "sm" | "md" | "lg"`、`density="default" | "compact" | "roomy"`、`tone="warm" | "rose" | "emerald" | "sky"`、`className` 和原生 input / textarea props;统一承接平台白底与 RPG 暗色弹窗里的圆角输入框、文本域、禁用态、密度、字号 / 行高和焦点色,暗色 surface 根节点固定带 `platform-text-field--editor-dark` 稳定类名。`PlatformSelectField` 复用同一套输入 chrome 承接下拉框。业务页继续持有 `value`、`onChange`、`aria-label`、`rows`、`placeholder`、`option` 等语义,不再重复拼 `rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3`、`rounded-[0.85rem] bg-white/90 px-3 py-2`、`bg-white/90 px-4 py-3`、暗色 `border-white/10 bg-black/30 px-4 py-3` 或 `focus:border-* focus:ring-*`。抓大鹅结果页作品信息、封面描述、素材名称和批量物品名称,方洞结果页主信息表单和形状 / 洞口选项字段,拼图 / 敲木鱼结果页作品信息字段,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段已先迁移;自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述和角色聊天草稿等暗色字段使用 `surface="editorDark"`。通用创作图片输入面板的提示词文本域也使用该 Module,只通过局部 class 保留高度和底部浮动上传按钮避让。认证图形验证码答案、短信 / 密码登录、重置密码、绑定手机号、邀请码和账号安全表单字段,以及个人中心兑换码 / 邀请码输入使用 `surface="platform"`,业务层只保留认证 / 兑换流程、受控值、原生属性和校验提示。
|
|
||||||
- `PlatformTextField` 补充:个人中心昵称弹窗输入框使用 `surface="editorDark" size="lg" density="roomy"`,业务组件保留外层原生 `label` / sr-only “新昵称”、`autoFocus`、`maxLength`、Enter 提交和保存状态;局部 class 只保留暗色弹窗里的 `bg-white/10`、文字色和焦点边框,不再手写 input chrome。
|
|
||||||
- `PlatformTextField` 补充:`PlatformTagEditor` 内部新增标签输入框使用 `density="compact" size="xs"` 复用同一输入 chrome;标签编辑器只保留新增输入状态、解析、Enter / Escape 行为和按钮组合,不再手写输入框边框、白底、字号、焦点色或禁用态。
|
|
||||||
- `PlatformTextField` 补充:creation-agent composer 文本域使用 `variant="textarea" size="md" density="compact"`;工作台只保留受控值、禁用条件、Enter / Shift+Enter 行为和局部布局 class,不再手写 textarea chrome。
|
|
||||||
- `PlatformTextField` 补充:拼图首访 onboarding 提示词文本域使用 `variant="textarea" surface="editorDark" density="roomy" size="lg"`;onboarding 保留受控输入、生成 / 已生成禁用和沉浸式壳层,不再手写 textarea 基础 chrome。
|
|
||||||
- `PlatformTextField` 补充:平台反馈页问题描述和联系电话字段使用 `surface="platform"`;反馈页保留外层原生 `label`、受控值、长度限制和透明嵌入式局部 class,不再手写 textarea / input 基础语义和重复 chrome。
|
|
||||||
- `PlatformFieldLabel`:接收 `variant="field" | "section" | "form" | "inlineForm" | "pill" | "accentPill"`、`children` 和 `className`;`field` 用于视觉小说等结果页的普通字段名,`section` 用于平台白底面板内小标题,`form` 用于创作工作台、通用创作输入面板和认证表单普通字段标题,`inlineForm` 用于模板确认弹层这类行内字段标题,`pill` / `accentPill` 用于汪汪声浪等工作台里的胶囊字段标题。业务页只传字段文案和必要的局部 class,不再重复写 `text-xs font-bold text-[var(--platform-text-soft)]`、`text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]`、`mb-2 block text-sm font-black`、`text-sm font-bold text-[var(--platform-text-base)]`、普通胶囊或 rose 强调胶囊 class。视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证表单中的手机号 / 验证码 / 密码 / 邀请码标题已先迁移。认证和提示词字段继续保留外层原生 `label` 关联,不把可访问命名交给装饰性标题组件。
|
|
||||||
- `PlatformFieldLabel` 补充:个人中心玩过弹窗内的“可继续 / 玩过”分区标题使用 `variant="section"`;业务组件只传分区文案和 `mb-2 block` 局部布局,不再手写 `text-xs font-black text-zinc-500`。
|
|
||||||
- `PlatformFieldLabel` 补充:个人中心邀请弹窗里的“邀请码 / 成功邀请”小标题使用 `variant="section"`;业务组件只保留必要的居中或深色文本局部 class,不再手写同类小标题字体。
|
|
||||||
- `PlatformFieldLabel` 补充:平台反馈页问题描述和联系电话标题使用 `variant="form"`,并保留外层原生 `label htmlFor` 负责可访问名称;反馈页不再手写字段标题字体和颜色。
|
|
||||||
- `PlatformFieldLabel` 补充:creative-agent 模板确认弹层里的“关卡数”行内标题使用 `variant="inlineForm"`,并继续保留外层原生 `label` 与 `PlatformTextField aria-label="计划关卡数"` 的可访问命名;弹层不再手写紧凑行内字段标题字体。
|
|
||||||
- `PlatformSegmentedTabs`:接收 `items`、`activeId`、`onChange`、`columns="one" | "two" | "three" | "four" | "threeToSix"`、`gap="sm" | "md"`、`radius="md" | "lg" | "xl"`、`size="sm" | "md" | "compact" | "choice" | "tab"`、`surface="default" | "soft" | "transparent"`、`tone="neutral" | "warm" | "rose" | "underline"`、`frame="panel" | "bare"`、`semantics="segment" | "tabs"`、`ariaLabel`、`truncateLabels`、`disabled`、`className` 和 `itemClassName`;普通分段统一写入 `aria-pressed`,Tab 语义统一写入 `role="tablist"` / `role="tab"` / `aria-selected`,并承载禁用阻断、白底选中态、空闲 hover、焦点 ring、响应式列数、裸分段外壳、下划线选中态和 label 截断。拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页、creative-agent 模板确认弹窗和认证入口短信 / 密码登录切换已先迁移。后续白底结果页 Tab、弹窗分段选择、四选一配置项或认证 / 设置类下划线 Tab 只传选项、当前值和变更回调,不再重复 `grid + border + bg-white/62 + button aria-pressed` 或本地 `role="tab"` 下划线按钮。
|
|
||||||
- `PlatformStatGrid`:接收 `items`、`columns="two" | "three" | "four" | "twoToFour"`、`density="compact" | "default"`、`order="valueFirst" | "labelFirst"`、`surface="soft" | "plain"`、`textAlign="left" | "center"`、`className` 和 `itemClassName`;统一承载平台结果页里的统计小卡、状态 chip、白底摘要卡、label/value 排版和响应式列数。拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移,业务页只传统计项数组和少量布局参数,不再重复写 `grid + rounded + bg-white/* + text-xl/text-xs`。
|
|
||||||
- `PlatformPillBadge`:接收 `tone="muted" | "neutral" | "neutralSolid" | "lightOverlay" | "success" | "warning" | "danger" | "cool" | "profile" | "profileAccent" | "darkSoft" | "darkNeutral" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`、`size="xxs" | "xs" | "sm"`、可选 `icon`、`children`、`className` 和原生 span props;统一承接单个状态 / 标签 chip 的圆角、边框、底色、字号和图标间距,并通过 `platformPillBadgeModel.ts` 的 `getPlatformPillBadgeClassName` 给复制类交互按钮复用同一视觉 chrome。`xxs` 用于实体目录卡片等密集元信息 chip,`muted` 用于平台白底柔和选择态和地图节点当前状态,`lightOverlay` 用于主动作按钮内部的泥点消耗等浅色叠层小胶囊,`danger` 用于删除 / 选中危险态,`profile` / `profileAccent` 用于个人中心玫瑰色信息 / 分类 chip,`dark*` 用于 RPG 暗色弹窗和角色详情里的纯展示 chip。宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点方向标签 / 地图场景切换方向标签 / 营地编组状态数值 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、拼图图库详情页题材标签、自定义世界作品卡二级 badge、生成失败 chip,以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续作品卡状态、结果页标签、个人中心轻量信息、按钮内消耗标签和轻量配置 chip 优先使用该 Module;多项数值 / 标签摘要仍使用 `PlatformStatGrid`,可交互标签编辑仍使用 `PlatformTagEditor`,可点击复制 / 分享 chip 使用 `CopyCodeButton` / `CopyFeedbackButton actionAppearance="pill"`。
|
|
||||||
- `PlatformPillBadge` 补充:大鱼吃小鱼结果页 hero 顶部的玩法摘要 chip 使用 `tone="lightOverlay"` 并保留局部 `bg-white/10` 覆盖;hero 只保留 `coreFun / ecologyTheme / levelCount` 文案,不再手写三段 `rounded-full bg-white/10 px-3 py-1` 静态标签。
|
|
||||||
- `PlatformPillBadge` 补充:RPG 实体编辑器基本设定里的拆分标签也使用 `tone="darkSoft"`;这类标签只表达解析后的静态词条,不把可编辑标签输入、删除按钮或点击选择态塞进静态 badge。
|
|
||||||
- `PlatformPillBadge` 补充:`tone="neutralSolid"` 承接无强调、无业务状态色的实心中性胶囊;`PlatformToggleRow mode="status"` 的开启 / 关闭状态已改用该 tone。后续只读开关状态或类似轻量状态值优先复用它,不在业务 JSX 中重复拼 `rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black`。
|
|
||||||
- `PlatformPillBadge` 补充:平台作品详情页的主题标签使用 `tone="neutralSolid" size="sm"`;详情页只保留标签数组映射,不再手写 `platform-work-detail__chip` 的边框、底色、圆角、字号和内边距。
|
|
||||||
- `PlatformProgressBar`:接收 `value`、可选 `minVisibleValue`、`size="xs" | "sm" | "md" | "lg"`、`ariaLabel`、`labelledBy`、`indeterminate`、`className`、`fillClassName`、`fillStyle`、`trackStyle` 和 `children`;内部 clamp 到 0-100,并统一写入 `role="progressbar"`、`aria-valuemin/max/now`、`platform-progress-track`、填充宽度和最小可见宽度。`children` 仅用于条内倒计时、加载图标等覆盖层;没有准确百分比的脉冲占位条使用 `indeterminate`,避免暴露假的 `aria-valuenow`。creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移;后续普通平台进度条只传业务进度值、标签关联、局部主题色和必要覆盖内容,不再重复手写 aria、track/fill div 和 `Math.max(...)` 宽度计算。
|
|
||||||
- `PlatformSubpanel`:接收 `as="section" | "div" | "article" | "aside" | "button"`、`title`、`titleVariant="section" | "strong"`、`actions`、`interactive`、`padding="tight" | "row" | "xs" | "sm" | "md" | "lg" | "none"`、`radius="xs" | "sm" | "md" | "lg" | "xl"`、`surface="platform" | "flat" | "soft" | "dark" | "darkSky" | "darkEmerald" | "darkAmber" | "darkRose" | "danger"`、`className`、`headerClassName`、`titleClassName`、`actionsClassName`、`bodyClassName` 和 `children`;静态 element 透传 `aria-*`、`data-*` 等原生属性,`as="button"` 时透传普通 button 属性并默认 `type="button"`。Module 统一承接平台结果页 / 工作台 / 个人中心子面板外壳、`PlatformFieldLabel variant="section"` 标题、强标题、右侧动作区、内容容器和普通白底列表卡片的 hover / focus / disabled 交互态。`surface="platform"` 复用 `platform-subpanel` token;`surface="soft" + padding="tight"` 用于标签编辑新增输入行等白底柔和紧凑行,`surface="soft" + padding="row"` 用于上传预览横向已选素材条等白底柔和横向行;`surface="danger"` 用于整卡危险选中态;`radius="xl" + padding="lg"` 用于方洞等更大圆角的标准结果页面板;`surface="platform" + radius="xl" + padding="none"` 用于只需要公共边框 / 背景 / 大圆角且内部自带固定比例内容的静态封面壳,`surface="platform" + radius="xl" + padding="sm"` 可用局部 `sm:p-5` 保留物品详情类响应式内容面板;`surface="flat" + radius="sm" + padding="sm"` 用于素材 / 音频 / 排行榜 / 选项编辑 / 局部进度状态等小型白底卡片,`surface="flat" + radius="sm" + padding="none"` 仅用于只包已有图片、图集、角色或路径预览且不需要 fallback / overlay 的白底壳;需要图片源、fallback、固定比例或 overlay 时优先使用 `PlatformMediaFrame`。需要整卡点击或缩略图点击时组合 `as="button" interactive`。拼图结果页作品名称 / 描述 / 标签编辑 / 智能修订条 / 关卡卡片、拼图图库详情页封面轮播壳 / 题材标签 / 关卡摘要、敲木鱼结果页主预览面板 / 作品标题 / 简介 / 主题标签 / 飘字 / 音效、敲木鱼工作台功德词条面板、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 个人中心未登录提示、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 当前难度摘要小卡 / 物品详情五视角面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳、创作中心作品架加载骨架卡,以及 creative-agent 工作台目录 / 目标就绪 / 空消息 / 过程 / 关卡计划 / 关卡计划小卡 / 模板确认理由面板已先迁移;`PlatformTagEditor` 内部新增输入行使用 `surface="soft" padding="tight"`,`PlatformUploadPreviewCard layout="inline"` 内部横向已选素材条使用 `surface="soft" padding="row"`。后续同类白底面板、白底轻量媒体壳或白底交互列表卡片只传标题、动作、内容、可访问属性和点击回调,不再重复写 `platform-subpanel rounded-[1.25rem] p-4`、`rounded-[1.35rem] p-4 sm:p-5`、`platform-subpanel rounded-[1.5rem] p-4 sm:p-5`、`rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]`、`rounded-[1rem] border ... bg-white/72 p-3`、`rounded-[1rem] border ... bg-white/68 p-2`、`rounded-[1rem] border ... bg-white/68 px-3 py-2`、`rounded-[1.1rem] border ... bg-white/58 p-3`、`rounded-[1rem] border ... bg-white/80`、`hover:bg-white disabled:cursor-not-allowed disabled:opacity-55`、标题行 flex 和 `text-xs font-bold tracking-[0.18em]`。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心玩过弹窗里的已玩作品按钮卡使用 `as="button" surface="flat" radius="sm" padding="md" interactive`,业务组件只保留作品标题 / 副标题 / 类型胶囊 / 作品号 / 最近游玩 / 时长内容和粉色 hover 边框,不再手写白底按钮卡 chrome。
|
|
||||||
- `PlatformSubpanel` 补充:平台入口壳纯 Suspense fallback、作品详情读取 / 错误提示和 Agent 工作区恢复提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳,业务层只保留居中布局、提示文案和局部内边距;生成结果恢复面板使用 `radius="xl" padding="none"` 保留恢复动作与固定内容间距。玩法 runtime overlay 仍保留专用层级语义,后续单独评估。
|
|
||||||
- `PlatformSubpanel` 补充:平台入口公开编号搜索命中用户卡使用 `surface="flat" radius="sm" padding="md"` 和 `titleVariant="strong"`;壳层只保留用户摘要字段和关闭分支,不再手写白底 bordered 搜索结果卡。
|
|
||||||
- `PlatformSubpanel` 补充:RPG runtime 主阶段路由里的平台首页、角色选择和冒险面板懒加载提示使用 `radius="sm" padding="none"` 承接原 `platform-subpanel` 外壳;路由器只保留 Suspense 分流和提示文案,运行态 HUD / overlay 继续保留专用层级语义。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心钱包账单行使用 `as="div" surface="flat" radius="xs" padding="none"`,业务组件只保留来源、时间、收入 / 支出色值、余额右对齐和局部 `px-3 py-3 shadow-sm`;后续同类白底数据行优先从该组合扩展。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心邀请弹窗里的社区二维码卡、邀请码展示卡、成功邀请容器和邀请用户行使用 `surface="flat" | "soft"` 的白底子面板;复制按钮、奖励说明卡和弹窗状态机不并入本轮。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心任务中心里的任务条目使用 `radius="sm" padding="md"` 承接原 `platform-subpanel` 外壳;业务组件只保留任务标题、进度、奖励、状态和领取按钮逻辑。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心充值弹窗里的微信 Native 支付二维码确认面板使用 `radius="sm" padding="md"`;业务组件保留二维码生成、扫码提示和确认支付按钮,不再手写 `platform-subpanel` 外壳。
|
|
||||||
- `PlatformSubpanel` 补充:个人中心充值弹窗里的商品整卡按钮使用 `as="button" interactive radius="sm" padding="none"`;业务组件只保留商品标题、金额、角标、购买状态和下单回调,不再手写 `platform-subpanel` 按钮壳、hover、focus 或 disabled chrome。
|
|
||||||
- `PlatformSubpanel` 补充:发布分享弹窗里的渠道 tile 按钮使用 `as="button" surface="flat" radius="sm" padding="tight" interactive`;弹窗只保留渠道枚举、品牌图标和复制分享文本回调,不再手写白底 tile 的圆角、边框、底色、hover 或 focus chrome。
|
|
||||||
- `PlatformSubpanel` 补充:平台入口创作类型弹层里的玩法卡片使用 `as="button" surface="platform" radius="xl" padding="none"`;卡片只保留玩法图片、锁定态、标题、副标题和选择分流,外层按钮语义、标准圆角和已开放卡 hover / focus chrome 由公共子面板承接。
|
|
||||||
- `PlatformSubpanel` 补充:creation-agent 工作台聊天区外壳使用 `radius="xl" padding="none"`;工作台只保留消息列表、引用图预览、错误提示和输入区语义,不再手写聊天面板外层圆角、边框和底色。
|
|
||||||
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `padding="xs"`、`radius="xs"` 和 `surface="dark"`,用于 RPG 暗色编辑器 / 运行态里的非交互小信息卡。任务目标、区域、进度、描述、角色维度、角色形象状态、自定义选择弹窗当前角色、地图场景切换当前 / 前往摘要、营地编组分区、同行者卡、营地气氛小卡、角色聊天状态和聊天总结这类只展示信息的小卡走该组合;暗色 HUD、动作按钮、可点击卡片和强玩法品牌面板仍保留业务布局。
|
|
||||||
- `PlatformSubpanel` 补充:当前 Interface 额外支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于 RPG 暗色编辑器 / 运行态中带业务色强调的结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板,以及 `CharacterInfoShared.MultiplierContributionList` 状态标签外壳已迁移,后续同类 sky / emerald / amber / rose 暗色信息壳不再手写 `border-*-400/18 bg-*-500/8`。
|
|
||||||
- `PlatformSubpanel` 补充:RPG 大编辑器里的标题型暗色信息块通过本地 `EditorInfoPanel` 适配到 `surface="dark" radius="md" padding="md"`;场景幕角色槽位的当前角色 / 可选角色面板、幕背景预览面板和预设背景面板已迁移。业务 JSX 只保留标题、内容和局部 grid,不再重复拼 `rounded-2xl border border-white/8 bg-black/20 px-4 py-4`。
|
|
||||||
- `PlatformSubpanel` 补充:RPG 队伍面板和实体详情弹窗中的构筑标签效果详情统一由 `CharacterInfoShared.BuildContributionDetailPanel` 承接,标签概览、属性加成明细和无属性明细提示都组合 `surface="dark"` 的公共子面板;业务弹窗只传选中标签行和属性 rows,不再重复手写同一段标签效果 JSX 或 `rounded-2xl border border-white/8 bg-black/20 p-4` / `rounded-xl border border-white/8 bg-black/25 px-4 py-3` 暗色面板 chrome。
|
|
||||||
- `PlatformSubpanel` 补充:实体详情弹窗的技能预览 fallback、伤害 / 法力 / 冷却 / 距离数值卡、技能说明和附带状态标签区使用 `surface="dark" radius="xs"`;实体详情只保留技能数值、文案和状态标签数据,不再重复手写 `rounded-xl border border-white/8 bg-black/20 px-* py-*` 暗色小卡。
|
|
||||||
- `PlatformSubpanel` 补充:宝贝识物工作台玩法预览卡使用 `surface="soft" radius="md" padding="md"`,只通过局部 `className` 保留玩法渐变和装饰层;工作台不再直接手写该类静态白底柔和卡片的边框、圆角和内边距。
|
|
||||||
- `PlatformSubpanel` 补充:creation-agent 无 session / 加载提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;工作台只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`。
|
|
||||||
- `PlatformSubpanel` 补充:拼图结果页空草稿提示块使用 `radius="sm" padding="lg"` 承接普通居中提示面板;结果页只传提示文案,不再手写 `platform-subpanel rounded-2xl px-5 py-4`。
|
|
||||||
- `PlatformMediaFrame`:接收 `src`、`fallbackSrc`、`alt`、`fallbackLabel`、`fallbackContent`、`aspect="auto" | "square" | "standard" | "landscape" | "wide" | "portrait" | "video"`、`surface="warm" | "editorDark" | "plain" | "soft" | "bright" | "none" | "bare"`、`loading`、`refreshKey`、`imageClassName`、`imageProps`、`className`、`fallbackShellClassName`、`fallbackClassName`、`previewOverlay`、`overlayInteractive`、`children` 和原生 `div` 的 `aria-*` / `data-*` 等属性;内部使用 `ResolvedAssetImage`,统一承接图片换签、fallback 图、无图 fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例、圆角、surface 背景和绝对定位 overlay。`standard` 用于 4:3 关卡 / 封面预览,`wide` 用于 9:5 宽图候选预览,`portrait` 用于 9:16 竖版场地底图 / 海报类资产,`soft` 用于由媒体框自身承接 `border border-[var(--platform-subpanel-border)] bg-white/68` 的白底柔和预览,`bright` 用于素材缩略图等需要 `border border-[var(--platform-subpanel-border)] bg-white/82` 的亮白预览槽,`none` 用于嵌在已有按钮 / 卡片交互壳里的纯图片与 fallback 内容,不抢外层边框、背景和选中态,`bare` 用于外层卡片已经提供边框和圆角的内嵌媒体框。自定义世界实体目录场景图片框、RPG 实体编辑器本地 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览,以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图和 UI 素材背景 / spritesheet 主图已先迁移;拼图关卡列表正式图、拼消消场地底图 / 素材图集这类外层白底媒体壳、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、抓大鹅 UI 素材页白底预览壳,以及方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图这类外层按钮已承接渐变、边框、选中或 hover 交互壳的场景,内层统一使用 `surface="none"`;拼图发布弹窗封面关卡和 creative-agent 模板确认预览这类由媒体框自身承接白底柔和槽的场景使用 `surface="soft"`。后续只是“图片 / fallback / 比例 / overlay”的预览框优先使用该 Module;历史素材选择继续使用 `PlatformAssetPickerCard`,上传后预览继续使用 `PlatformUploadPreviewCard`,整块白底面板继续使用 `PlatformSubpanel`。
|
|
||||||
- `PlatformMediaFrame` 补充:组件根节点固定带 `platform-media-frame` 稳定类名,业务测试可断言公共媒体框接入,不再依赖局部 Tailwind 色值作为组件归属判断。
|
|
||||||
- `PlatformMediaFrame` 补充:拼图图库详情页封面轮播的内层正方形图片 / 暂无封面 fallback / 轮播 overlay 已迁移到 `PlatformMediaFrame aspect="square" surface="none"`;外层 `PlatformSubpanel radius="xl" padding="none"` 继续承接面板边框、圆角和裁切。
|
|
||||||
- `PlatformMediaFrame` 补充:RPG 角色形象参考图缩略框和营地编组同行者头像框使用 `surface="editorDark"` 与固定尺寸 class 复用媒体框;这类只展示图片源 / fallback / 圆角边框的缩略框不再在业务 JSX 中手写 `img + overflow-hidden + border`。
|
|
||||||
- `PlatformMediaFrame` 补充:需要运行时计算比例、裁剪或拖拽测量的媒体区域使用 `aspect="auto"`、`ref` 和 `imageProps`,由业务层只传动态 `style`、`draggable` 等图片属性和 overlay 操作层;RPG 作品封面上传裁剪操作区 / 结果预览、角色素材工作室形象预览 / 动作静态预览、场景幕背景预设、技能编辑 fallback 预览、技能列表缩略图和角色编辑顶部形象预览已迁移。后续“图片 + 动态比例 / 不可拖拽 / overlay 操作层”的场景优先扩展 `PlatformMediaFrame`,不在业务 JSX 中重新手写 `ResolvedAssetImage`、固定图片壳和 fallback 文案。
|
|
||||||
- `PlatformMediaTileGrid`:接收 `items`、`columns="five" | "six"`、`gap="xs" | "sm"`、`aspect="auto" | "square"`、`surface="none" | "soft"`、`tileSurface="white" | "slate" | "bare"`、默认 `fallbackLabel`、默认图片 / fallback class 和局部 class;每个 item 接收稳定 `id`、`src`、`alt`、`refreshKey`、`fallbackLabel`、`fallbackContent`、`testId` 与局部 class。tile 的边框 / 底色 / 阴影统一由 `tileSurface` 承接,内部 `PlatformMediaFrame` 使用 `surface="none"`,避免重复叠加公共媒体框底色。跳一跳结果页地块池、跳一跳无图集 fallback 地块池、拼消消结果页卡片预览网格和抓大鹅物品 spritesheet 解析预览分组已先迁移。后续结果页只是展示一组同尺寸正方形素材 tile 时优先使用该 Module;单张大图预览继续用 `PlatformMediaFrame`,历史素材选择继续用 `PlatformAssetPickerGrid`,上传预览继续用 `PlatformUploadPreviewCard`。
|
|
||||||
- `PlatformTagEditor`:接收 `title`、`tags`、`disabled`、`maxTags`、`error`、`addLabel`、`generateLabel`、`inputLabel`、`inputPlaceholder`、`emptyLabel`、`parseInput`、`onChange`、可选 `onGenerate` / `generateIcon`、`radius`、`padding` 和 `tone="amber" | "warm"`;内部持有新增输入态,统一处理标签去重、添加、删除、Enter 提交、Escape 取消、空态、可选 AI 生成按钮和错误提示。拼图结果页作品标签、敲木鱼结果页主题标签和抓大鹅结果页作品标签已先迁移。后续结果页只保留业务标签规范化函数和写回回调,不再重复手写 tag chip、删除按钮、输入框、添加 / 取消按钮和 AI 生成按钮。
|
|
||||||
- `PlatformTagEditor` 补充:新增输入行外壳继续由 `PlatformSubpanel surface="soft" padding="tight"` 承接,输入框由 `PlatformTextField` 承接;标签编辑 Module 内部也遵守公共输入 / 子面板分工,不再把白底 input chrome 写成本地 class。
|
|
||||||
- `PlatformAssetPickerCard`:接收 `imageSrc`、`imageAlt`、可选 `assetTitle` / `subtitle`、`surface="platform" | "editorDark"`、`selectLabel`、`selected`、`disabled`、`onClick`、`aria-label`、`cardRadiusClassName`、`imageShellClassName`、`imageClassName` 和 `bodyClassName`;图片读取统一走 `ResolvedAssetImage`,按钮禁用态、选中态、边框、hover、缩略图外壳和可选卡片内选择按钮由 Module 统一控制,`assetTitle` 专指卡片内展示标题,不占用原生 button `title` 属性。`PlatformAssetPickerGrid`:接收素材数组、读取 / 错误 / 空态、`getKey`、`getImageSrc`、`getImageAlt`、`getTitle`、`getSubtitle`、`getAriaLabel`、`isSelected`、`cardClassName` 和 `onSelect`;默认组合 `PlatformStatusMessage`、`PlatformEmptyState` 与 `PlatformAssetPickerCard`,业务页只保留素材字段映射、文案、选中判断和选择回调,不再重复手写缩略图卡片、选中 ring、虚线读取 / 空态和网格 JSX。白底平台弹窗使用默认 `platform` surface;RPG 大编辑器等暗色弹窗使用 `editorDark`,并通过 `imageShellClassName` 保留场景横图比例。视觉小说等同一弹窗里混有上传 / AI 生成错误时,可继续由外层错误条承接动作错误,只把历史素材读取 / 空态 / 网格交给 `PlatformAssetPickerGrid`。
|
|
||||||
- `PlatformActionButton`:接收 `tone="primary" | "secondary" | "ghost" | "danger" | "success" | "warning" | "accent" | "accentSoft"`、`surface="platform" | "profile" | "editorDark"`、`size="xxs" | "xs" | "sm" | "md" | "lg"`、`shape="default" | "pill"`、`align="center" | "start"`、`fullWidth`、`children` 和原生 button props;`surface="platform"` 复用 `platform-button` 样式族,`surface="profile"` 的主按钮复用个人中心 `platform-primary-button`,`surface="editorDark"` 统一承接 RPG 暗色弹窗 / 运行面板里的普通取消、确认、刷新和编组动作,`tone="accent"` 承接琥珀实心 CTA,`tone="accentSoft"` 承接带局部 accent 变量的柔和强调按钮,根节点固定带 `platform-action-button--accent` / `platform-action-button--accent-soft` 稳定类名。认证表单的 48px 高按钮使用 `size="lg"`,暗色微型刷新 / 工具动作使用 `size="xxs" shape="pill"`,需要文件上传等 label 语义时使用 `asChild="label"` 复用同一套按钮外观,不把上传控件改成普通 button。推荐回复、列表内动作等需要左对齐时使用 `align="start"`,不要在业务 JSX 中重复写 `justify-start text-left`;创作中心错误重试、反馈页 header 返回和暗色次要动作等普通 ghost 动作同样走 `tone="ghost"` 与 `shape="pill"`,不在业务 JSX 中直接拼按钮 class。复制按钮仍使用 `CopyFeedbackButton`,可选项按钮卡仍使用 `PlatformDarkOptionCard`,像素风发送 / 强品牌动作继续保留专用布局。
|
|
||||||
- `PlatformActionButton` 补充:反馈页内的“查看反馈与投诉记录”这类页面内次级文本动作使用 `tone="ghost" shape="pill" size="xs"`;业务组件只保留点击反馈,不再手写居中、字号、内边距和冷色文本按钮 class。
|
|
||||||
- `PlatformActionButton` 补充:作品详情底部“作品改造 / 作品编辑”和“启动”使用 `surface="platform" shape="pill" size="lg" fullWidth`,保留 `platform-work-detail__remix / start` 局部 class 控制 sticky 底部栏位置、比例和品牌背景。
|
|
||||||
- `PlatformActionButton` 补充:作品详情点赞按钮使用 `tone="accentSoft"` 并通过局部 `--platform-action-accent` 变量复用柔和强调 chrome;详情页只保留纵向排布、尺寸和可访问名称,不再手写点赞按钮边框、底色、文字和阴影。
|
|
||||||
- `PlatformActionButton` 补充:创作中心作品卡积分激励的“领取积分 / 领取中”按钮使用 `tone="secondary" size="xxs"`;作品卡保留 `creation-work-card-incentive__button` 局部 class 控制三列布局、移动端跨列、紧凑高度和玻璃底,不再手写原生按钮 chrome。
|
|
||||||
- `PlatformActionButton` 补充:拼图首访 onboarding 生成 / 登录 CTA 使用 `surface="editorDark" tone="accent" size="lg" fullWidth`,跳过按钮使用 `surface="editorDark" tone="ghost" shape="pill"` 并只保留右上角定位 class;首访页不再手写按钮基础 chrome。
|
|
||||||
- `PlatformIconButton`:接收 `label`、`icon`、可选 `children`、可选 `variant="platformIcon" | "surfaceFloating" | "darkMini"`、`title`、`className`、`asChild="label"` 和原生 button / label props;默认 `platformIcon` 用于平台弹窗 header、搜索结果弹窗、工具栏、结果页选项删除等普通图标动作按钮,也用于保持 file input 原生语义的图标上传 label;`surfaceFloating` 用于通用创作图片面板里覆盖在图片或输入区上的白底圆形图标动作,短文案入口通过 `children` 渲染可见短标签但仍由 `label` 提供可访问名称;`darkMini` 用于上传预览卡右上角等覆盖在缩略图上的暗色小型图标动作。creation-agent composer 中的上传文档 / 上传参考图入口使用默认 `platformIcon`,只保留动态 label、title、busy 和 picker 回调;作品详情顶部返回 / 分享与封面轮播上一张 / 下一张入口也使用默认 `platformIcon`,并通过局部 class 保留详情页专属位置和尺寸。发送按钮、点赞按钮、带复制三态或强品牌动作继续保留专用布局。关闭语义复杂或属于个人中心 / 浮层关闭按钮时仍优先使用 `PlatformModalCloseButton`,带复制三态时使用 `CopyFeedbackButton`。同一面板内存在主图上传和提示词参考图上传时,两个 file input 必须使用不同可访问名称,避免业务测试或读屏用户只能看到多个同名“上传参考图”入口。
|
|
||||||
- `PlatformIconBadge`:接收 `icon`、可选 `label`、`size="xs" | "sm" | "base" | "md" | "lg" | "xl" | "xxl"`、`shape="circle" | "rounded" | "xl"`、`tone="neutral" | "soft" | "softBright" | "hero" | "heroMuted" | "darkAmber" | "success" | "danger"` 和 `className`;统一承接非交互图标槽的中性 / 柔和 / hero / 暗色琥珀 / 成功 / 危险底色、文字色、尺寸、圆角和 `aria-hidden` / `aria-label`。根节点固定带 `platform-icon-badge` 稳定类名,业务测试可断言共享图标槽接入。视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽不再重复手写 `grid h-* w-*`、`inline-flex h-* w-* items-center justify-center`、`rounded-full`、`rounded-[0.85rem]`、`rounded-2xl`、neutral token class、白底柔和小圆槽、暗色琥珀图标槽或危险提示红色圆槽。
|
|
||||||
- `PlatformIconBadge` 补充:宝贝识物工作台玩法预览卡内礼物图标槽使用 `size="xl" shape="rounded" tone="softBright"`,业务页只保留玩法色和投影覆盖,不再手写 `grid h-14 w-14 place-items-center rounded-* bg-white/*`。
|
|
||||||
- `PlatformIconBadge` 补充:个人中心充值结果弹窗和支付确认遮罩里的 56px 圆形图标槽使用 `size="xl"` 并通过局部 `bg-white/10`、状态文字色 class 覆盖;弹窗只保留支付结果文案、支付状态图标和确认动作,不再手写 `flex h-14 w-14 items-center justify-center rounded-full bg-white/10` 图标容器。
|
|
||||||
- `PlatformUploadTile`:接收 `label`、可选 `hint`、`icon`、`size="square" | "compact" | "panel"`、`surface="platform" | "editorDark"`、`showLabel`、`disabled`、`className`、`asChild="label"` 和原生 button / label props;默认渲染 `type="button"` 的平台虚线上传方块,`compact + showLabel={false}` 用于工作台里的纯图标虚线新增入口,`panel` 用于整行上传说明入口,`editorDark` 用于 RPG 大编辑器等暗色弹窗。label 模式保留 file input 原生关联语义,禁用时写入 `aria-disabled` 并阻断 label 默认点击。反馈页上传凭证、敲木鱼工作台新增功德词条入口、RPG 大编辑器参考图入口、角色素材工作室参考图入口和封面上传入口已迁移,后续图片 / 附件上传方块或紧凑虚线新增入口只保留业务选择文件 / 新增动作,不再重复写虚线入口 chrome。
|
|
||||||
- `PlatformUploadPreviewCard`:接收 `imageSrc`、`imageAlt`、`removeLabel`、可选 `layout="square" | "inline"`、`surface="platform" | "editorDark"`、`caption`、`previewLabel`、`onPreview`、`onRemove`、`disabled`、`resolveAsset`、`imageRefreshKey`、`className`、`imageClassName`、`imageShellClassName`、`captionClassName`、`previewButtonProps`、`removeIcon` 和 `removeButtonProps`;默认 `square` 渲染平台缩略图壳、`object-cover` 预览图、可选标题行和可选移除按钮,square 右上移除按钮复用 `PlatformIconButton variant="darkMini"`,`inline + platform` 通过 `PlatformSubpanel surface="soft" padding="row"` 渲染白底横向已选素材条,`inline + editorDark` 通过 `PlatformSubpanel surface="dark" padding="row"` 渲染暗色编辑器横向参考图条。需要点击预览的参考图传 `previewLabel/onPreview`,需要 generated / OSS 资产换签的缩略图传 `resolveAsset`,需要展示文件名 / 素材名的参考图传 `caption`,不要在业务 JSX 中额外包一层缩略图标题栏或横向参考图条。反馈页上传凭证预览、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer、creation-agent 已选参考图条、拼图结果页关卡引用图横条和 RPG 大编辑器参考图预览条已迁移,后续上传预览只保留素材数据、预览回调和删除回调,不在业务 JSX 中重复写预览卡 chrome。
|
|
||||||
- `PlatformPillSwitch`:接收 `label`、`checked`、`disabled`、`className` 和原生 input props;内部固定 `role="switch"`、`type="checkbox"` 和 `sr-only` 输入,视觉层统一白底胶囊、开关轨道、圆点位置、hover / 禁用态。通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已迁移,后续同类胶囊开关只传受控 checked / onChange,不再手写 switch 轨道和圆点。
|
|
||||||
- `PlatformToggleRow`:接收 `label`、`checked`、`onChange`、`disabled`、`mode="checkbox" | "status"`、`icon`、`onLabel`、`offLabel`、`onClick`、`surface="soft" | "plain"`、`className` 和 `labelClassName`;`checkbox` 模式用于结果页运行配置和角色可见性,`status` 模式用于 runtime 设置面板的只读开关状态,可选 `onClick` 时自身渲染为 button。视觉小说结果页运行配置 / 玩家可见开关、视觉小说 runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移,业务页不再重复手写 `flex min-h-12 ... bg-white/74 px-3`、checkbox class 或“开启 / 关闭”状态 pill。
|
|
||||||
- `PlatformInfoBlock`:接收可选 `label`、`children`、`multiline`、`className`、`labelClassName` 和 `valueClassName`;统一承载平台弹窗 / 详情页中的短标签、无标签只读正文、白底圆角边框、内容换行、单行加粗排版和横向只读信息行的标签 / 值局部排版。错误弹窗与生成完成弹窗的来源、错误、状态块、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移,后续同类只读信息展示只传 label、内容和必要局部排版 class,纯正文块可省略 label,不在业务 JSX 中重复写 `rounded-[1rem] border ... bg-white/72 px-3 py-2`、`rounded-[1.25rem] border ... bg-white/72 p-4` 或 `rounded-[0.85rem] bg-white/74 px-* py-*`。
|
|
||||||
- `PlatformInfoBlock` 补充:当前 Interface 支持 `variant="compactRow"` 承接预览卡里的密集横向 label / value 行,标签、值、圆角、白底和响应式内边距由公共组件控制;汪汪声浪预览卡四个信息行已去掉本地 `PREVIEW_INFO_*` class 常量。
|
|
||||||
- `PlatformModalCloseButton`:接收 `label`、`variant="profile" | "profileCompact" | "floating" | "floatingPlain" | "platformIcon" | "pixel" | "editorDark"`、`icon` 和原生 button props;`profile` 复用个人中心 `platform-modal-close` 圆形按钮,`profileCompact` 复用个人中心小弹窗 `platform-profile-icon-button` 关闭按钮,`floating` 复用平台浮层右上角白底关闭按钮,`floatingPlain` 复用个人中心邀请 / 社区浮层的透明右上角关闭按钮,`platformIcon` 复用平台弹窗头部 `platform-icon-button` 关闭入口,`pixel` 复用 `UnifiedModal variant="pixel"` 的像素风圆形关闭入口,`editorDark` 承接 RPG 暗色弹窗中非像素风的圆形 X 关闭入口并固定带 `platform-modal-close-button--editor-dark` 稳定类名。认证入口、邀请码弹窗等平台弹窗头部关闭按钮使用 `variant="platformIcon"`,像素风 `UnifiedModal` 使用 `variant="pixel"`,自定义选择弹窗使用 `variant="editorDark"`,业务页可以追加局部 class,但不重新声明基础尺寸、圆角、默认图标和 `aria-label`。
|
|
||||||
- `squareImageCropModel`:导出 `SquareImageCropRect`、`buildCenteredSquareImageCropRect(imageSize)` 和 `clampSquareImageCropRect(imageSize, crop)`;可复用裁剪数学留在 model,`SquareImageCropModal` 只承接弹窗 UI、拖拽交互和提交动作。
|
|
||||||
|
|
||||||
## 迁移顺序
|
|
||||||
|
|
||||||
1. 先迁移平台入口壳中的泥点提示和作品删除确认,验证普通提示与危险确认两个分支。
|
|
||||||
2. 迁移 `PlatformErrorDialog`、`PlatformTaskCompletionDialog`、`PublishShareModal` 的复制反馈到 `useCopyFeedback` 与 `CopyFeedbackButton`,验证成功、失败和上下文切换复位。
|
|
||||||
3. 迁移公开作品详情、RPG 作品详情、拼图广场详情、大鱼 runtime 分享和账号个人资料区中的作品号 / 用户号复制与分享复制状态;短代码 chip 使用 `CopyCodeButton`,分享按钮继续按场景使用 `CopyFeedbackButton` 或 `CopyFeedbackMessage`,避免页面继续散落 `copyState / shareState + setTimeout` 或三态按钮 JSX。
|
|
||||||
4. 再迁移结果页、工作台和账号区域中只有单个确认按钮或确认 / 取消按钮的简单弹窗;拼图结果页关卡画面生成、抓大鹅结果页物品素材生成 / 重新生成的“确认消耗泥点”已使用 `UnifiedConfirmDialog` 的内嵌渲染模式,拼图 / 抓大鹅创作工作台的初始泥点确认已使用默认 portal 模式,大鱼吃小鱼结果页发布失败提示已通过 `confirmClassName` 保持整行确认按钮外观。
|
|
||||||
5. 自定义世界实体目录的删除确认、批量删除确认和“至少保留一个可扮演角色”提示统一使用 `UnifiedConfirmDialog`,不再调用浏览器原生 `window.confirm` / `window.alert`。
|
|
||||||
6. RPG 结果页整页重新生成确认由页面层使用 `UnifiedConfirmDialog` 承接,`useRpgCreationResultActions` 只保留执行命令和忙碌态保护,不再在 hook 内调用浏览器原生确认框。
|
|
||||||
7. RPG 详情页删除确认由平台壳的共享作品删除弹窗承接;`useRpgEntryLibraryDetail` 只保留已确认后的删除命令、刷新和阶段回退,不再直接调用浏览器原生确认框。
|
|
||||||
8. RPG 角色素材工作室的形象 / 动作泥点消耗确认使用 `UnifiedConfirmDialog portal={false}` 内嵌在工作室弹窗栈内;点击生成只打开确认,确认后再执行生成工作流。
|
|
||||||
9. RPG 场景编辑器中的多幕数量、连接关系、主角色、幕预览和角色槽位阻断提示统一使用基于 `UnifiedConfirmDialog` 的编辑器提示弹窗,不再调用浏览器原生 `window.alert`。
|
|
||||||
10. RPG 可扮演角色 / 场景角色的背景章节删除阻断提示由角色编辑器壳层承接,背景章节编辑控件只上报 `onNotice`,不直接调用原生弹窗。
|
|
||||||
11. RPG 编辑器关闭未保存草稿时使用 `UnifiedConfirmDialog` 统一承接“确认关闭 / 继续编辑”,不再维护单独的关闭确认按钮样式。
|
|
||||||
12. RPG 场景背景和作品封面生成结果未保存时,退出确认也使用 `UnifiedConfirmDialog`;像素风场景生成弹窗通过 `variant="pixel"` 适配视觉。
|
|
||||||
13. 公开作品详情或运行态深链失效时,由平台入口壳展示 `UnifiedConfirmDialog` 的“作品不可用”提示;用户确认后再回到首页,错误处理分支不再调用浏览器原生 `window.alert`。
|
|
||||||
14. 带复杂内容的专用 Module 可以保留自己的布局,但复制反馈仍应复用 `useCopyFeedback`;如果有可点击复制按钮,优先复用 `CopyFeedbackButton`;如果只展示复制结果提示,优先复用 `CopyFeedbackMessage`。
|
|
||||||
15. 白底平台弹窗、详情页、结果页、目录页、个人页、认证入口、统一创作工作台和通用创作输入区的基础错误 / 成功 / 信息 / 警告 / 中性状态提示逐步迁移到 `PlatformStatusMessage`;RPG 结果页、拼图结果页、抓大鹅结果页、跳一跳结果页、敲木鱼结果页、拼消消结果页、宝贝识物结果页、方洞结果页、汪汪声浪结果页、视觉小说结果页、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creative-agent 工作台、creation-agent operation banner、自定义世界实体目录、拼消消 runtime 白底错误条和平台作品详情分享复制反馈已使用 `surface="platform"` 承接发布检查、错误提示、进度提示、素材生成提示、资源未就绪提示、主线目标提示和复制反馈;个人中心、认证入口、统一创作工作台和创作输入区需要 profile token 外观时使用 `surface="profile"`;RPG 暗色编辑 / 运行面板和拼图首访 onboarding 里的普通错误 / 成功 / 信息 / 警告 / 中性提示使用 `surface="editorDark"`,背包故事档案 QA 提示、NPC 交易 / 赠礼 / 招募叙事提示和角色聊天错误提示已先迁移。运行态里的短错误 / 成功 / 命中反馈 chip 使用 `PlatformRuntimeStatusToast`,位置和玩法强品牌 HUD 仍留在 runtime 壳层;深色半透明游戏内提示和强品牌样式可以暂保留专用布局,避免状态条组件过早承接游戏视觉。
|
|
||||||
16. 正方形图片裁剪的初始居中、边界 clamp 和裁剪矩形类型统一从 `squareImageCropModel` 导入,避免头像裁剪、拼图参考图裁剪等业务页面依赖弹窗组件文件里的 helper。
|
|
||||||
17. 个人中心的账户充值、泥点账单、每日任务、兑换码、扫码、存档、玩过作品、邀请 / 社区、昵称修改、头像裁剪,以及平台筛选、创作图片预览、认证入口、邀请码弹窗、公开编号搜索结果弹窗、方洞结果页图片素材弹窗、视觉小说结果页资产 / 音频 / 编辑器弹窗、视觉小说 runtime 普通面板、creative-agent 模板确认弹窗、像素风 UnifiedModal 和自定义选择弹窗等圆形关闭按钮迁移到 `PlatformModalCloseButton`;后续新增弹窗关闭按钮先判断是否属于 `profile`、`profileCompact`、`floating`、`floatingPlain`、`platformIcon`、`pixel` 或 `editorDark` 七类,确有品牌化或运行态 HUD 语义时才保留专用按钮。
|
|
||||||
17.1. 平台弹窗 header 和普通工具栏里的 `platform-icon-button` 迁移到 `PlatformIconButton`;历史图片选择弹窗、RPG 发布检查弹窗、creative-agent 侧边栏关闭 / 外观 / 设置入口、通用输入 Composer 上传 / 发送 / 移除参考图、creation-agent composer 上传文档 / 上传参考图、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、敲木鱼创作工作台功德词条删除入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口、RPG 首页搜索结果清空入口、方洞结果页形状 / 洞口选项删除入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移。结果页内的普通平台弹窗关闭入口使用 `PlatformModalCloseButton variant="platformIcon"`;图标上传控件使用 `PlatformIconButton asChild="label"` 保留 label + file input 语义,不改成普通按钮;`PlatformIconButton` 的 label 模式会自动写入隐藏文本,保证内嵌 file input 仍能继承可访问名称。通用创作图片面板中覆盖在图片上的更换主图、移除主图、历史入口短标签按钮和提示词参考图上传入口,抓大鹅封面编辑中覆盖在封面图上的移除入口,以及敲木鱼创作工作台功德词条删除入口使用 `PlatformIconButton variant="surfaceFloating"`,不再手写白底圆形 / 短标签浮动按钮 chrome。运行态 HUD、带复制状态或需要专用交互禁用语义的图标按钮,先保留专用布局,等对应场景验证时再迁移。
|
|
||||||
17.2. 非交互图标徽章迁移到 `PlatformIconBadge`;视觉小说 runtime 面板标题、存档列表项,creative-agent 模板卡 / 模板确认 / 顶部 hero / 目标就绪 / 过程条目图标圆槽,创作类型弹层锁定卡小圆锁图标,大鱼吃小鱼发布失败弹窗图标槽,通用创作图片面板空主图上传占位图标槽,拼图结果页智能修订条图标槽,以及 GameCanvas 宝箱遭遇图标槽已先迁移。后续同类图标槽只表达 icon、尺寸、形状和 neutral / soft / softBright / hero / heroMuted / darkAmber / success / danger 调性,不再重复中性、白底柔和、hero 叠层、暗色琥珀、成功或危险底色、文字色、居中和 shrink class。
|
|
||||||
17.3. RPG 大编辑器主壳层和紧凑对话壳层的右上角关闭入口迁移到 `PlatformModalCloseButton variant="platformIcon"`;暗色编辑器仍保留原 `platform-icon-button` 视觉 token,但业务 JSX 不再手写 `button`、`aria-label` 和默认关闭图标。
|
|
||||||
18. RPG 首页、公开广场、排行、作品架、个人中心充值 / 任务弹窗、视觉小说 runtime 普通白底面板、历史素材选择弹窗、视觉小说上传资产弹窗本地上传占位、自定义世界实体目录搜索无结果、大鱼吃小鱼结果页缺草稿提示、RPG 大编辑器纯展示暗色列表、背景故事空档案和 RPG 运行态设置保存禁用提示中的无操作空态 / 轻量读取态迁移到 `PlatformEmptyState`;后续空态如果包含 CTA、插画、复杂列表恢复动作或玩法 HUD,再保留专用布局。
|
|
||||||
18.1. 历史图片 / 历史素材 / 可引用素材选择迁移到 `PlatformAssetPickerCard` 与 `PlatformAssetPickerGrid`;拼图历史图片弹窗、方洞历史生成、视觉小说历史素材选择器、RPG 大编辑器历史素材弹窗和抓大鹅封面编辑可引用素材网格已先迁移。后续素材选择只传素材数组、`imageSrc`、主副文案、可访问名称、surface、选中判断和选择回调,不再在业务页重复缩略图、边框、选中 ring、禁用态、`ResolvedAssetImage` 壳层、虚线读取 / 空态和网格 JSX。
|
|
||||||
18.2. 平台白底圆角输入框和文本域迁移到 `PlatformTextField surface="platform"`;RPG 暗色弹窗 / 运行面板里的普通输入框、文本域和下拉框迁移到 `PlatformTextField surface="editorDark"` / `PlatformSelectField surface="editorDark"`;抓大鹅结果页作品名称 / 描述、封面描述、素材名称、批量新增 / 批量重生成物品名称,方洞结果页游戏名称、标签、简介、题材主题、反差规则、背景提示、形状数量、形状 / 洞口名称、形状目标洞口和图片提示词,拼图结果页作品名称 / 描述、关卡名称和智能修订输入,敲木鱼结果页作品标题 / 简介,视觉小说结果页的音乐生成、作品信息、开场、运行配置、角色、场景、阶段和世界观普通文本 / 下拉字段,以及视觉小说 / 抓大鹅 / 汪汪声浪 / 宝贝识物 / 拼消消 / 跳一跳创作工作台普通输入字段、敲木鱼创作工作台功德词条输入、creative-agent 模板确认调整弹层关卡数输入已先迁移。通用输入 Composer、通用创作图片输入面板的提示词文本域、自定义世界实体目录搜索框、认证验证码答案输入、短信 / 密码登录、重置密码、绑定手机号、邀请码、账号安全表单字段、个人中心兑换码 / 邀请码输入、自定义选择弹窗角色名字 / 背景补充 / 生成模式 / 世界描述、角色聊天草稿、拼图首访 onboarding 提示词文本域和平台反馈页问题描述 / 联系电话也使用 `PlatformTextField` / `PlatformSelectField`;浮动胶囊 Composer 可继续由 `.creative-agent-composer--floating textarea` 覆盖尺寸和背景,图片输入面板可通过局部 class 保留高度与浮动上传按钮避让,实体目录搜索框可通过局部 class 保留紧凑圆角和底色,验证码答案输入和认证表单字段可通过局部 class 保留表单高度、横向验证码按钮布局和原生 `label` 关联,个人中心兑换码 / 邀请码输入通过局部 class 保留大写和居中,暗色聊天草稿和首访提示词文本域可通过局部 class 保留沉浸式底色 / 高度,反馈页字段可通过局部 class 保留透明嵌入式视觉,不在业务 JSX 中手写 textarea / input / select chrome。默认密度用于结果页主表单,`density="compact"` 用于选项卡片、工具条、认证提示内或反馈页联系电话的紧凑字段,`density="roomy"` 用于宽内边距文本域、关卡详情字段、首访提示词文本域或反馈页问题描述;默认 `tone="warm"`,玩法需要保留调性焦点色时使用 `tone="rose"`、`tone="emerald"` 或 `tone="sky"`,不要在业务 JSX 中重复写 `focus:border-* focus:ring-*`。后续结果页、工作台、目录工具条、认证提示、认证表单、个人中心轻量表单、反馈表单、首访页或 RPG 暗色弹窗内的普通文本输入 / 下拉框只传受控值、事件、可访问名称、占位符、选项和局部布局 class,不再重复基础边框、背景、内边距、字号、禁用态和焦点色。
|
|
||||||
18.2.1. 个人中心昵称弹窗输入框迁移到 `PlatformTextField surface="editorDark"`;昵称状态机、校验、保存和弹窗壳层不随输入框 chrome 收口改动。
|
|
||||||
18.3. 平台字段标签迁移到 `PlatformFieldLabel`;视觉小说结果页、抓大鹅结果页作品 / 封面 / 素材字段标题、方洞结果页主信息 / 形状 / 洞口 / 历史图片字段标题、拼图结果页关卡详情 / 发布弹窗字段标题、拼消消创作工作台作品标题 / 简介 / 主题词、跳一跳创作工作台主题、大鱼素材弹窗 prompt、RPG 发布弹窗发布检查 / 封面设置、汪汪声浪轻配置编辑器、宝贝识物工作台、通用创作图片输入面板主图 / 提示词标题,以及认证登录 / 绑定 / 邀请码 / 账号安全表单标题、平台反馈页问题描述 / 联系电话标题已先迁移。后续结果页、编辑弹窗、工作台、通用创作输入面板、反馈表单或认证表单中只表达字段名称的小标题,优先选择 `field` / `section` / `form` / `pill` / `accentPill`,不要在业务 JSX 中重复拼字段标题 class;认证表单、反馈表单和提示词字段保留外层原生 `label`,带品牌化插画、运行态 HUD 或复杂步骤标题时可暂保留专用标题。
|
|
||||||
18.3.1. 个人中心存档 / 玩过弹窗里的简单空态、分区标题和已玩作品白底按钮卡分别迁移到 `PlatformEmptyState`、`PlatformFieldLabel` 与 `PlatformSubpanel`;`SaveArchiveCard` 带图片遮罩和加载视觉,仍保留专用实现,后续需要单独视觉验收后再决定是否收口。
|
|
||||||
18.3.2. 平台入口壳中的纯 Suspense fallback、作品详情读取 / 错误提示、Agent 工作区恢复提示、RPG runtime 主阶段懒加载提示和 `CreationResultRecoveryPanel` 外壳迁移到 `PlatformSubpanel`;加载 / 错误提示使用 `radius="sm" padding="none"`,带恢复动作的结果恢复面板使用 `radius="xl" padding="none"`,玩法 runtime overlay 后续单独评估。
|
|
||||||
18.3.3. 个人中心钱包账单弹窗里的空态和账单行分别迁移到 `PlatformEmptyState` 与 `PlatformSubpanel`;账单展示只保留收支内容、余额和时间,不在业务 JSX 重复白底列表行 chrome。
|
|
||||||
18.3.4. 个人中心邀请弹窗内部的二维码卡、邀请码卡、成功邀请列表、邀请用户行、小标题和简单空态分别迁移到 `PlatformSubpanel`、`PlatformFieldLabel` 与 `PlatformEmptyState`;外层弹窗、query 自动打开、复制邀请、提交邀请码和社区面板信息架构不随本轮改变。
|
|
||||||
18.3.5. 个人中心任务中心任务条目迁移到 `PlatformSubpanel`;任务选择、领取、奖励和完成态仍由任务 ViewModel / 业务流程控制。
|
|
||||||
18.3.6. 个人中心充值弹窗 Native 支付二维码确认面板迁移到 `PlatformSubpanel`;支付渠道选择、二维码生成和确认支付流程不随 UI chrome 收口改动。
|
|
||||||
18.3.7. 个人中心充值弹窗商品整卡按钮迁移到 `PlatformSubpanel as="button" interactive`;支付渠道选择、商品展示、提交中态和购买回调不随按钮卡 chrome 收口改动。
|
|
||||||
18.4. 平台白底分段 Tab / 二选一 / 四选一配置项迁移到 `PlatformSegmentedTabs`;拼图结果页、抓大鹅结果页、抓大鹅素材配置、抓大鹅创作 / 结果页难度选择、视觉小说结果页和 creative-agent 模板确认弹窗已先迁移。后续同类控件只传选项、当前 id、变更回调、列数、尺寸、调性和外壳形态,不再在业务 JSX 中重复容器边框、`bg-white/62`、选中态和 `aria-pressed`。
|
|
||||||
18.4.1. `PlatformSegmentedTabs` 支持 `semantics="tabs"`、`tone="underline"`、`size="tab"` 和 `columns="one"`,用于承接认证入口短信 / 密码登录切换这类真实 Tab 语义;业务页不再维护本地 `LoginTabButton`、`role="tab"`、`aria-selected` 和下划线选中态。
|
|
||||||
18.5. 平台只读信息块迁移到 `PlatformInfoBlock`;错误弹窗和生成完成弹窗的来源、错误和状态展示、分享弹窗正文,以及汪汪声浪预览卡场景 / 形象 / 难度 / 声浪信息行已先迁移。后续弹窗、详情页或预览卡里只是展示短标签 + 只读正文,或无标签纯只读正文时,优先使用该 Module;横向信息行通过 `labelClassName` / `valueClassName` 保留标签和值排版,不在业务 JSX 中重复白底信息块 chrome。
|
|
||||||
18.5.1. 平台来源 / 状态 / 错误这类可复制报告弹窗迁移到 `PlatformReportDialog`;`PlatformErrorDialog` 和 `PlatformTaskCompletionDialog` 已先迁移,业务弹窗只保留标题、字段语义和黑名单过滤,不再重复维护 `UnifiedModal`、`CopyFeedbackButton`、`useCopyFeedback`、报告拼装和 `PlatformInfoBlock` footer 组合。后续同类“字段展示 + 复制整段报告”弹窗优先复用该 Module。
|
|
||||||
18.6. 平台统计小卡和轻量状态 chip 迁移到 `PlatformStatGrid`;拼消消结果页素材摘要、方洞结果页封面状态 chip、抓大鹅结果页难度摘要、creative-agent 模板确认摘要和自定义世界实体目录世界页档案规模已先迁移。后续结果页里只表达数值 / 标签摘要时,优先传 `items`、列数、密度、surface 和 label/value 顺序,不再在业务 JSX 中重复手写统计卡 chrome。
|
|
||||||
18.6.1. 平台普通进度条迁移到 `PlatformProgressBar`;creation-agent 主进度 / operation banner、RPG 结果页生成提示、RPG 实体目录生成中提示、开场 CG 生成占位、拼图关卡画面生成进度、生成页当前步骤线性进度、抓大鹅批量物品素材生成进度和自定义世界生成选择弹窗进度提示已先迁移。creation-agent operation banner 的状态外壳也迁移到 `PlatformStatusMessage surface="platform" remapSurface`,避免业务 JSX 继续组合 `platform-remap-surface platform-banner` 和 `platform-banner--*`。后续生成进度、素材进度或实体目录进度只保留进度值、显示文案、主题色、必要覆盖层和业务状态,不再重复写 `role="progressbar"`、`platform-progress-track`、fill 宽度和最小可见宽度计算;未知进度用 `indeterminate`。生成页环形总进度继续保留 `GenerationProgressHero` 专用 SVG。
|
|
||||||
18.6.2. 平台单个胶囊状态 / 标签 chip 迁移到 `PlatformPillBadge`;宝贝识物结果页发布状态、主题标签与占位资源 overlay,宝贝识物 / 拼图 / 抓大鹅 / 视觉小说工作台 BETA chip、汪汪声浪轻配置 chip、汪汪声浪结果页草稿 chip、汪汪声浪预览 VS chip、敲木鱼结果页飘字 chip、creative-agent 顶部阶段 / 过程计数 / 条目 meta chip、通用音频输入面板限制标签、自定义世界实体目录批量选择 / 生成中 / 开局 CG / 可扮演角色元信息 badge、RPG 首页公开作品卡 / 搜索结果 / 充值商品 / 移动端创建入口 / 桌面发现区 chip、RPG 世界详情静态元信息 chip、平台作品详情主题标签、RPG 角色身份 / 等级 / 技能出手方式 / 技能详情与状态标签 / 背景故事解锁状态 / 好感等级 / 角色资产工作室动作状态 / 角色编辑技能动作状态 / 角色资源应用状态 / 场景角色选择状态 / 地标当前连接状态 / 地图节点当前状态 / 地图节点方向标签 / 地图场景切换方向标签 / 作品封面来源状态 / 开局物品标签、NPC 交易数量 / 赠礼好感和背包工坊材料需求等暗色展示 chip、抓大鹅批量新增 / 批量重生成物品名称预览 chip、抓大鹅 / RPG / 拼图 / 方洞结果页自动保存状态、抓大鹅结果页当前难度 badge、拼图结果页关卡生成中 overlay / 列表 badge、大鱼吃小鱼结果页终局 / 关卡元信息 / 发布校验成功 badge、RPG 开发资产诊断数量 / 加载状态 badge、RPG 发布弹窗封面来源 badge、账号弹窗主题状态 / 会话数量 / 设备状态 badge、汪汪声浪生成页和通用生成页右上状态 badge、创作类型弹层锁定 badge、通用创作图片面板提交按钮内泥点消耗标签,以及个人中心泥点账单余额、玩过总时长和玩过作品类型 chip 已先迁移。后续只表达一个状态、标签、分类 chip 或按钮内消耗小胶囊时使用该 Module,不在业务 JSX 中重复拼 `rounded-full border bg-* text-* px-* py-*`;个人中心玫瑰色 chip 使用 `tone="profile"` / `tone="profileAccent"`,RPG 暗色展示 chip 使用 `dark*` tone,密集目录元信息用 `size="xxs"`,平台白底柔和状态使用 `tone="muted"`,实心中性详情标签使用 `tone="neutralSolid"`,按钮内浅色叠层使用 `tone="lightOverlay"`,多项统计摘要继续使用 `PlatformStatGrid`。可点击复制 / 分享胶囊 chip 继续由 `CopyCodeButton` / `CopyFeedbackButton` 管复制状态,并通过 `actionAppearance="pill"` 复用 `PlatformPillBadge` chrome。
|
|
||||||
18.6.2.1. 抓大鹅创作工作台提交按钮内的泥点消耗标签使用 `PlatformPillBadge tone="lightOverlay" size="xs"`;工作台只保留泥点数值和提交状态,不再手写 `rounded-full bg-white/24 px-2 py-0.5`。
|
|
||||||
18.6.3. 平台媒体悬浮短标签迁移到 `PlatformOverlayBadge`,复合控件内部的紧凑槽位编号迁移到 `PlatformSlotBadge`;RPG 场景幕预览左上幕标签和每幕角色槽位的“主 / 2 / 3”标记已先迁移。后续覆盖在图片、素材预览或舞台画面上的非交互短标签只传文案、位置和局部 class,绝对定位、白底半透明、边框、阴影与字距由 `PlatformOverlayBadge` 承接;角色槽、步骤槽等复合按钮里的小圆形序号只传文案和 active / inactive 语义,由 `PlatformSlotBadge` 承接。普通状态 / 分类仍使用 `PlatformPillBadge`,外层按钮、人物舞台布局和运行态 HUD 不迁入这两个小 Module。
|
|
||||||
18.6.3.1. `PlatformOverlayBadge` 支持 `tone="muted"`、`size="compact"` 和 `offset="tight"`,用于素材缩略图右上角“占位图”等更紧凑的非交互浮层;宝贝识物结果页素材卡占位图标记已迁移到该组合。后续这类贴在媒体框上的短标签优先使用 overlay badge,不再把 `PlatformPillBadge` 绝对定位到图片内。
|
|
||||||
18.6.3.2. `PlatformSlotBadge` 支持 `tone="soft"` 和 `size="md"`,用于 creative-agent 阶段时间线这类白底柔和步骤圆点;时间线外层阶段卡、进行中 / 已完成 / 未开始语义配色仍保留在业务 Module,公共徽标只承接圆点尺寸、白底、边框和图标居中。
|
|
||||||
18.6.4. 物品格、奖励格等缩略图右下角的数量角标迁移到 `PlatformQuantityBadge`;背包物品格和 RPG 冒险面板 / 覆盖层的奖励物品数量已先迁移。后续同类右下角数量只传数量值,绝对定位、黑底半透明、圆角、边框和字号由该 Module 承接;可交互物品按钮、选中态、稀有度边框、图标来源和详情弹窗仍留在业务 Module。
|
|
||||||
18.6.5. RPG 冒险面板和覆盖层里的任务目标状态、任务日志状态、当前幕、剩余交谈等纯展示暗色 chip 复用 `PlatformPillBadge` 的 `dark*` tone;任务 presentation / 日志状态只返回语义 tone,不再携带完整 `border / bg / text` class。运行态行动按钮、任务面板打开按钮和需要 hover / click 语义的胶囊仍保留专用按钮布局。
|
|
||||||
18.6.6. RPG 角色面板里的标签数、适配倍数、性别和装备稀有度等纯展示暗色 chip 复用 `PlatformPillBadge darkNeutral / darkEmerald / darkAmber`;构筑适配倍数只保留 multiplier 计算,不再手写 emerald 胶囊 chrome。后续带复杂数值拆解的统计 / 加成类展示能力再单独收口。
|
|
||||||
18.6.7. RPG 首页作品卡里的发布状态、元信息、主标签,以及存档卡右上恢复 / 最近游玩时间等暗色静态 chip 复用 `PlatformPillBadge dark*`;作品卡 / 存档卡只保留可点击卡片、删除动作、进入 / 继续创作箭头和业务文案。
|
|
||||||
18.6.8. 自定义世界实体目录里的基础设定词条标签复用 `PlatformPillBadge darkSoft`;目录页只保留词条解析和空值展示逻辑,不再手写白字暗底 tag chrome。
|
|
||||||
18.7. 平台白底子面板迁移到 `PlatformSubpanel`;拼图结果页作品信息 / 智能修订条 / 关卡卡片、敲木鱼结果页主预览面板 / 元信息、敲木鱼工作台功德词条、拼图图片生成模式选择器菜单外壳、跳一跳结果页预览 / 操作面板 / 排行榜 / 轻量媒体壳、拼消消创作工作台左侧表单面板、拼消消结果页预览 / 统计 / 操作面板 / 轻量媒体壳、方洞结果页封面 / 主信息 / 形状 / 洞口标准面板、方洞形状 / 洞口选项卡与缩略图按钮、RPG 结果页开发资产诊断摘要 / 条目 / 空态、RPG 发布弹窗封面预览壳、通用音频输入面板、视觉小说创作工作台画风选择面板、视觉小说结果页素材 / 音频小面板、视觉小说结果页作品 / 开场 / 运行配置 / 世界观标准编辑面板、视觉小说结果页角色 / 场景 / 阶段列表项与空态、视觉小说 runtime 历史条目 / 存档列表、抓大鹅创作工作台难度小面板、抓大鹅结果页作品 / 难度 / 统计 / UI素材预览标准面板 / 物品图集分组卡 / 批量素材生成进度卡、汪汪声浪结果页草稿摘要 / 素材槽 / 预览卡、平台反馈页问题描述 / 上传凭证 / 联系方式区块、自定义世界实体目录世界基调 / 角色维度 / 基本设定条目 / 场景幕级缩略图 / 目录卡片媒体壳 / 目录卡片整卡壳,以及 creative-agent 工作台标准白底面板 / 关卡计划小卡和通用输入 Composer 普通 panel 外壳已先迁移。后续仅表达“白底子面板 + 标题 / 右侧动作 + 内容”“不需要 fallback / overlay 的白底轻量媒体壳”或“白底整卡点击列表项”的片段优先使用该 Module;标准面板使用 `surface="platform"`,选中 / 删除预备等危险整卡态使用 `surface="danger"`,大圆角标准面板使用 `radius="xl" padding="lg"`,小型白底卡片或小型浮层菜单使用 `surface="flat"`,不要在业务 JSX 里继续覆盖 flat 的圆角和底色,轻量媒体壳使用 `surface="flat" padding="none"`,整卡或缩略图点击使用 `as="button" interactive`;暗色运行态 HUD、通用输入 Composer 浮动胶囊或强玩法品牌面板可继续保留专用布局。
|
|
||||||
18.7.1. 账号设置入口卡、主题选择卡、当前主题状态、账号绑定卡、密码 / 安全 / 设备 / 操作记录区块,以及设备 / 操作记录内的白底列表行已迁移到 `PlatformSubpanel`;账号弹窗只保留绑定、换绑、撤销会话和日志展示语义,不再直接拼 `platform-subpanel rounded-2xl` 或内层白底列表边框。
|
|
||||||
18.7.2. RPG 世界详情页的世界信息统计卡、关键角色 / 关键场景预览卡和操作区标题已迁移到 `PlatformSubpanel` 与 `PlatformFieldLabel variant="section"`;详情页只保留作品展示、启动、编辑、发布、下架和删除动作语义,不再直接拼小型 `platform-subpanel` 卡片或本地 section 标题 class。
|
|
||||||
18.7.3. 大鱼吃小鱼结果页的关卡卡片、场地背景卡、发布校验卡、空草稿提示和素材工坊 PROMPT 信息块已迁移到 `PlatformSubpanel`;结果页只保留大鱼玩法的青色主题按钮、预览背景、素材生成动作和发布校验语义,不再直接拼 `rounded-[1.45rem] border ... bg-[var(--platform-subpanel-fill)] p-4` 或 `rounded-[1.25rem] border ... bg-white/72 p-4`。
|
|
||||||
18.7.4. 平台媒体预览框迁移到 `PlatformMediaFrame`;自定义世界实体目录场景图片框、RPG 实体编辑器 `ImagePreview`、拼图结果页关卡列表正式图框、拼图发布弹窗封面关卡预览、拼消消结果页场地底图 / 素材图集 / 卡片预览网格、方洞结果页图片查看弹窗预览、方洞结果页封面 / 背景点击预览、方洞结果页形状 / 洞口贴图缩略图、宝贝识物结果页素材卡图片框、视觉小说结果页封面 / 资产字段图片预览、敲木鱼结果页主 9:16 背景 + 敲击物叠层预览、跳一跳结果页地块图集整图预览、大鱼吃小鱼关卡主图缩略图、大鱼吃小鱼素材工坊候选预览、大鱼吃小鱼场地背景竖版预览、creative-agent 模板确认预览、拼图图库详情页封面轮播媒体框、认证验证码图片,以及抓大鹅结果页物品素材列表缩略图、详情大图、视角缩略图、UI 素材背景 / UI spritesheet / 物品 spritesheet 主图预览已先迁移。拼图发布弹窗封面关卡预览、creative-agent 模板确认预览和认证验证码图片使用 `surface="soft"` 承接白底柔和边框,业务 JSX 只保留局部圆角、高度或 fallback 渐变差异;拼图关卡列表正式图、拼消消场地底图 / 素材图集、宝贝识物素材卡顶部媒体槽、视觉小说资产字段、creative-agent 模板目录卡、跳一跳地块图集整图、大鱼关卡主图 / 工坊候选 / 场地背景主题槽、拼图图库详情页封面轮播媒体框、方洞封面 / 背景点击预览、方洞形状 / 洞口贴图缩略图和抓大鹅 UI 素材页白底预览壳 / 详情视角缩略图嵌在保留媒体壳或交互态的外层壳内,使用 `surface="none"` 只承接图片 / fallback;抓大鹅素材列表缩略图和详情大图使用 `surface="bright"` 承接亮白素材槽,并通过容器属性透传保留测试 id / aria。后续需要图片源、fallback 图、fallback 文案 / 自定义占位内容、fallback 外壳局部着色、固定比例或 overlay 的预览框只传素材地址、可访问名称、比例、surface、刷新 key 和覆盖层,不再在业务 JSX 中重复拼图片框渐变、无图占位、`aspect-*`、基础边框 / 底色和绝对定位 overlay。
|
|
||||||
18.7.5. 汪汪声浪结果页草稿编译小卡迁移到 `PlatformSubpanel surface="flat"`,跳一跳结果页排行榜行卡迁移到 `PlatformSubpanel surface="flat"`,排行榜无成绩空态迁移到 `PlatformEmptyState surface="subpanel"`;结果页只保留玩法文案、排行字段和错误 / 空态文案,不再手写白底小卡圆角、边框、底色和 padding。
|
|
||||||
18.7.6. creative-agent 模板目录卡迁移到 `PlatformSubpanel as="button" interactive surface="flat"`,卡内 16:9 预览迁移到 `PlatformMediaFrame aspect="landscape" surface="none"`;工作台只保留模板选择、标题、摘要、预览渐变局部样式和泥点范围,不再手写白底按钮卡、16:9 图片框或图标 fallback 容器。
|
|
||||||
18.7.7. `PlatformSubpanel` 支持 `surface="dark"`、`radius="xs"` 和 `padding="xs"`,用于承接暗色编辑 / 运行面板中的小型信息卡;RPG 冒险面板 / 覆盖层任务目标、区域、进度、任务摘要卡、奖励条、描述卡、任务更新提示、任务日志条目、冒险统计总览和统计卡、任务完成领奖提示、奖励缓存、战斗结束、战利品面板和奖励物品详情描述 / 效果 / 标签,自定义世界实体目录角色维度小卡,自定义选择弹窗当前角色信息块,RPG 大编辑器场景幕背景信息、预设背景和场景连接关系面板,角色面板个人线阶段 / 背景 / 性格块 / 装备行,好感状态卡的等级摘要 / 进度面板,背景故事公开印象 / 已解锁章节 / 锁定章节面板,角色详情装备 / 背包 / 旅程 / 背景 / 性格小卡,通用角色技能卡,实体详情主分区壳和最近回响里的后果 / 编年 / 载体 / 场景残留卡,背包文书 / 故事档案 / 工坊分区与非交互条目卡,NPC 交易数量 / 库存 / 详情 / 总价 / 交易详情装备与使用属性,以及角色聊天状态 / 总结等静态信息卡已先迁移。RPG 大编辑器本地 `EditorInfoPanel` / `SectionPanel` 和实体详情本地 `Section` 只保留标题、右侧动作、subtitle 或内容插槽,暗色面板 chrome 继续由 `PlatformSubpanel surface="dark"` 承接;可扮演角色背景故事、关系、技能、物品和世界基础设定等编辑分区不再手写外层暗色面板。后续暗色小信息卡只保留标题、图标、值和必要动作,不再重复手写 `rounded-xl border border-white/10 bg-black/* px-* py-*`。
|
|
||||||
18.7.7.1. `PlatformSubpanel` 支持 `surface="darkSky" | "darkEmerald" | "darkAmber" | "darkRose"`,用于承接暗色编辑 / 运行面板中的强调态结构化信息面板;实体详情私聊提示、队友收束、玩家等级进度、角色面板等级 / 收束状态、任务奖励好感度 / 货币 / 经验数值卡、RPG 大编辑器上传封面中提示、地图场景切换目标场景面板和共享构筑状态标签外壳已先迁移。后续同类只读或半结构化提示只传 surface、radius、padding 和业务内容,不再手写 `border-*-400/18 bg-*-500/8` 暗色 tint 面板。
|
|
||||||
18.7.8. 拼图图库详情页封面轮播壳迁移到 `PlatformSubpanel radius="xl" padding="none"`,内层图片 / fallback / 轮播 overlay 迁移到 `PlatformMediaFrame surface="none"`;详情页只保留图片 slide 数据、轮播按钮和 fallback 文案,不再手写 `rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]` 静态封面面板或直接依赖底层 `ResolvedAssetImage`。
|
|
||||||
18.7.9. 抓大鹅结果页物品详情五视角面板迁移到 `PlatformSubpanel radius="xl" padding="sm"` 并通过局部 `sm:p-5` 保留桌面间距;详情页只保留视角预览、缩略图切换和素材名称字段,不再手写 `platform-subpanel min-h-0 rounded-[1.5rem] p-3 sm:p-5`。
|
|
||||||
18.7.10. RPG 暗色弹窗里的可选项按钮卡迁移到 `PlatformDarkOptionCard`;NPC 交易模式、交易物品行、赠礼候选、招募替换候选、角色素材工作室动作预览格、营地编组替换位按钮和角色聊天建议按钮已先迁移。业务页只传 selected、tone、点击回调和内容布局,不再重复写选中 / 未选中暗色卡片边框、底色、hover 和 disabled chrome;像素风 footer 按钮、强品牌动作按钮和含复杂禁用语义的动作按钮继续保留专用布局。
|
|
||||||
18.7.11. 发布分享弹窗渠道 tile 按钮迁移到 `PlatformSubpanel as="button" interactive surface="flat"`;复制反馈状态、渠道枚举和品牌图标继续留在分享弹窗内。
|
|
||||||
18.7.12. 平台入口创作类型弹层玩法卡片迁移到 `PlatformSubpanel as="button" surface="platform" radius="xl" padding="none"`;玩法图片蒙版、锁定 badge、标题副标题和分流回调继续由弹层组件持有。
|
|
||||||
18.7.13. creation-agent 工作台聊天区外壳迁移到 `PlatformSubpanel radius="xl" padding="none"`;消息列表、上传预览、错误提示和输入区继续由工作台组件持有。
|
|
||||||
18.7.14. 绑定手机号页左侧的“当前登录身份”提示块迁移到 `PlatformSubpanel as="div" radius="sm" padding="md"`;认证页只保留品牌说明、当前用户显示名和绑定流程,不再手写 `platform-subpanel` 信息块外壳。
|
|
||||||
18.8. 平台标签编辑器迁移到 `PlatformTagEditor`;拼图、敲木鱼和抓大鹅结果页标签编辑已先迁移。后续标签编辑只把 parse / normalize 和保存语义留在业务页,新增输入状态、删除 chip、空态、AI 生成按钮和错误提示统一由 Module 承接。
|
|
||||||
19. 个人中心充值、任务、兑换、邀请、支付结果等弹窗里的普通主动作按钮迁移到 `PlatformActionButton surface="profile"`;RPG 首页作品卡删除小动作、RPG 作品详情、RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪 / 视觉小说 / 大鱼吃小鱼结果页、自定义世界实体目录小动作、生成结果恢复面板、通用生成页重试 / 中断动作、法律信息弹窗 footer、公共确认弹窗 footer、统一创作工作台、统一创作页壳层、拼图创作工作台、拼消消创作工作台、宝贝识物创作工作台、视觉小说创作工作台、汪汪声浪创作工作台、creation-agent 推荐回复、creative-agent 工作台、creative-agent 模板确认弹窗、创作中心错误重试、创作中心作品卡积分激励领取按钮、反馈页 header 返回、通用创作输入面板、认证表单、敲木鱼 fallback 返回、跳一跳结算、拼消消 runtime header / 结算弹窗和视觉小说 runtime 普通白底面板里的普通主动作 / 次动作 / 危险动作迁移到 `PlatformActionButton surface="platform"`;RPG 暗色弹窗 / 运行面板中的角色自定义 footer、生成 footer、地图切换确认、营地编组普通动作、角色聊天刷新动作、角色素材工作室本地 `ActionButton`,以及 RPG 大编辑器暗色面板内的保存 / 角色槽动作都迁移到 `PlatformActionButton surface="editorDark"`。若业务侧仍需要 `stopPropagation`、局部 tone 映射或内容排版差异,可以保留局部 `ActionButton` 包装层,但包装层本体应委托共享按钮,而不是继续直接渲染原生 `<button>`。统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,提交 / 生成 / 发布 / 保存按钮使用默认主动作,素材槽小按钮、作品卡角落小动作、拼图图片生成模式选择器触发器和白底面板行内动作使用 `size="xs"` 与 `shape="pill"`,积分激励领取这类密集卡片小动作使用 `size="xxs"` 并由局部卡片 class 保留响应式布局,暗色微型刷新动作使用 `size="xxs" shape="pill"`,左对齐回复 / 列表动作使用 `align="start"`,认证表单提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 保持 48px 高度,文件上传 label 使用 `asChild="label"` 保持上传语义;复制邀请、错误复制、完成复制和分享复制继续使用 `CopyFeedbackButton` 管状态,并通过 `actionSurface` 复用动作按钮外观。大鱼吃小鱼结果页资产工坊 footer、关卡主图 / 动作入口和场地背景生成这类白底平台动作也使用 `shape="pill" size="xs"`,深色 hero 返回 / 测试 / 发布按钮保留玩法品牌布局。后续带复制三态的按钮不改用普通 ActionButton,避免复制状态分支回流业务页;暗色可选项卡继续使用 `PlatformDarkOptionCard`,像素风发送按钮和强品牌动作继续保留专用布局。
|
|
||||||
19.1. `CopyFeedbackButton` 支持 `actionShape`,用于在复用共享复制状态机时直接对齐 `PlatformActionButton` 的圆角外观;拼图广场详情 hero 的“分享作品”已使用 `actionSurface="editorDark" actionShape="pill"`,不再手写复制按钮 rounded / border / bg class。
|
|
||||||
19.2. 拼图广场详情 hero 的返回、上一张 / 下一张关卡图入口迁移到 `PlatformIconButton variant="darkMini"`,修改作品和进入第 1 关迁移到 `PlatformActionButton`,分享动作继续使用 `CopyFeedbackButton` 但复用共享动作按钮 chrome;详情页只保留轮播、复制和跳转语义,不再手写 hero 区按钮壳。
|
|
||||||
19.3. 个人中心充值商品卡里的“购买 / 处理中”胶囊暂保留局部 `span`,不直接套用 `PlatformActionButton`,避免在 `PlatformSubpanel as="button"` 内再嵌套交互按钮;待出现第二个同形态的非交互 action chip 后,再决定是否沉淀独立的共享展示基元。
|
|
||||||
19.3.1. RPG 首页创作 / 草稿顶栏的钱包快捷入口迁移到同文件适配器 `TopbarWalletShortcutButton`,内部复用 `PlatformActionButton tone="accentSoft" shape="pill" size="xs"` 与 `PlatformIconBadge`;移动端和桌面端继续保留 `.platform-mobile-create-wallet-chip`、`.platform-desktop-create-wallet-chip` 和 `.platform-desktop-search` 兼容 class,承接移动端余额截断、桌面顶栏胶囊底色以及既有测试锚点。入口点击仍统一走 `openRechargeOrRewardCodeModal`,不把充值 / 兑换码平台分流逻辑改散到两个顶栏分支里。
|
|
||||||
19.3.2. 个人中心昵称修改、账户充值、每日任务和兑换码四类标准头部弹窗迁移到 `UnifiedModal`;`UnifiedModal` 新增 `closeVariant`、`closeOnEscape`、`titleClassName` 和 `descriptionClassName`,用于在收口弹窗壳层时保留个人中心 `profile / profileCompact` 关闭按钮、居中浮层布局和标题层级。上述弹窗统一通过 `closeOnBackdrop={false}`、`closeOnEscape={false}` 保持原有交互语义,不把 backdrop / Escape 关闭行为悄悄带进个人中心;邀请、玩过作品等结构更复杂的二级弹层继续按同一壳层策略逐步迁移。
|
|
||||||
19.3.3. 个人中心支付结果提示和支付确认遮罩迁移到 `UnifiedModal` 的 headerless 模式;`UnifiedModal` 新增 `showHeader`,用于在保留 `role="dialog"`、可访问名称、遮罩和 z-index 语义的同时,允许业务页自己排版 icon badge、轻量标题和正文。支付结果提示与确认遮罩统一使用 `showHeader={false}`、`showCloseButton={false}`、`closeOnBackdrop={false}`、`closeOnEscape={false}`,继续保持阻断式确认语义;业务页只保留图标、文案和按钮,不再手写 backdrop、dialog aria 和面板壳层。
|
|
||||||
19.3.4. 个人中心移动端顶栏的“扫码”“打开设置”入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-header__icon-button` 局部 class 控制位置、尺寸和主题色,交互语义与可访问名称统一由共享按钮承接,不再在 `RpgEntryHomeView` 里手写图标按钮的 `type`、`aria-label` 和基础 chrome。
|
|
||||||
19.3.5. 发现页分类筛选弹窗与个人中心扫码面板迁移到 `UnifiedModal`;分类筛选继续复用本地选项栅格和底部动作区样式,但 backdrop、dialog 语义、头部关闭入口和 `closeOnEscape={false}` 统一收口到共享壳层。扫码面板复用 `showHeader={false}` 模式保留深色自定义头部、摄像头 viewport 和状态提示,同时显式保持 `closeOnBackdrop={false}`、`closeOnEscape={false}`,确保不会把扫码中的资源清理语义改散到页面外层。
|
|
||||||
19.3.6. 个人中心泥点账单弹窗迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级和关闭策略,账单弹窗继续保留自定义渐变面板、浮动关闭按钮、余额 badge、列表 / 空态 / 错误态布局以及 `closeOnBackdrop={false}`、`closeOnEscape={false}` 的原有交互,不再手写 `fixed inset-0` 遮罩壳层。
|
|
||||||
19.3.7. 个人中心“玩过作品”面板迁移到 `UnifiedModal` 的 headerless 模式;共享壳层承接 `dialog` 语义、层级与关闭策略,面板继续保留 `PLAYED` kicker、总时长 badge、浮动关闭按钮、`可继续 / 玩过` 双分区、作品卡与空态布局,以及 `closeOnBackdrop={false}`、`closeOnEscape={false}` 的原有交互。存档入口仍留在同一个“玩过”面板内,不再回退成独立的 `SAVE ARCHIVE` / `ARCHIVE` 壳层。
|
|
||||||
19.3.8. 个人中心邀请相关弹层中的 live 分支迁移到 `UnifiedModal` 的 headerless 模式;玩家社区与填邀请码继续保留浮动关闭按钮、居中标题、二维码卡片、邀请码表单 / 已填写空态和成功 / 失败提示,但 `dialog` 语义、层级与关闭策略统一由共享壳层承接。`community / redeem` 两条真实入口继续显式保持 `closeOnBackdrop={false}`、`closeOnEscape={false}`;历史 `invite` 分支暂不扩张能力面,只随同一壳层复用现状内容。
|
|
||||||
19.3.9. 个人中心昵称旁的铅笔入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-edit-button` 局部 class 控制 1.45rem 紧凑尺寸、边框与浅色底,但按钮语义、默认 `type="button"` 和共享 icon chrome 统一由公共组件承接,不再在 `RpgEntryHomeView` 里手写原生图标按钮。
|
|
||||||
19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`。
|
|
||||||
19.3.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`。
|
|
||||||
19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;`RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`。
|
|
||||||
19.3.13. RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;`RpgEntryHomeView` 仅保留个人中心展示、昵称头像编辑、扫码入口和页面级编排 / 交互,不再直接承接账户动作分流、商业状态派生和面板控制。该收口默认保持现有弹层与充值链路语义不变,避免在职责迁移时顺带扩张行为面。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
|
||||||
19.3.14. RPG 首页个人中心的“玩过 / 可继续”历史弹层抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和旧的 `ProfileSaveArchivesModal`。当前真实产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此未连通的 `saveArchives` profile popup 分支一并删除,避免继续维护没有入口的独立壳层。组件级验证新增 `src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。
|
|
||||||
19.3.15. 个人中心标准头部弹窗与白底副弹层壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;`PlatformProfileModalShell` 负责标准账户弹窗的 overlay、header、title、description、close variant 和 `closeOnBackdrop={false} / closeOnEscape={false}` 约束,`PlatformProfileSecondaryModalShell` 负责白底副弹层的 overlay、floating close、`bodyClassName="!p-0"` 和内容外壳。`RpgEntryHomeView` 内的昵称修改、账户充值、每日任务、兑换码、泥点账单与“玩过”弹层已接到共享壳层,页面不再重复手写个人中心弹窗的基础 chrome 与关闭策略。
|
|
||||||
19.3.15.1. `PlatformProfileModalShell` 继续补齐标准 footer 插槽:壳层现已直接透传 `UnifiedModal.footer` 与 `footerClassName`,`RpgEntryHomeView.tsx` 的昵称修改弹窗不再把双按钮动作区塞在 body 末尾,而是改成标准 profile modal footer。后续个人中心里同类“表单 body + 底部双按钮动作区”弹窗,优先走 `PlatformProfileModalShell + footer`,不要把共享按钮再手写回内容区。
|
|
||||||
19.3.15.2. `PlatformProfileModalShell` 的 footer 接法继续扩展到单 CTA 表单收尾:`PlatformProfileRewardCodeRedeemModal.tsx` 的兑换动作已迁到标准 profile footer,body 仅保留输入与反馈消息;后续个人中心里这种“输入表单 + 底部唯一主动作”弹窗,也优先复用壳层 footer,而不是把按钮继续塞在内容区。验证命令:`npx vitest run src/components/platform-entry/PlatformProfileModalShell.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.16. RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;组件统一复用 `PlatformProfileSecondaryModalShell` 承接居中白底浮层、floatingPlain 关闭按钮和成功 / 失败提示区,`RpgEntryHomeView` 不再内联邀请码规范化、社区二维码卡片和邀请用户头像行。组件级验证新增 `src/components/platform-entry/PlatformProfileReferralModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的邀请链路断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders invite panel with shared profile content|submits redeem panel with the shared form shell|renders community QR panels|profile community shortcut shows reward subtitle and invited users|invite query opens redeem modal directly for logged in users|profile redeem invite query modal submits code after login"`、`npm run typecheck`。
|
|
||||||
19.3.17. RPG 首页个人中心的账户充值弹层抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;组件承接 Native 二维码生成、点数 / 会员 tab、套餐卡片、空态和错误重试,继续复用 `PlatformProfileModalShell` 与平台白底卡片 token,`RpgEntryHomeView` 不再内联 `useWechatNativeQrCode`、`RechargeProductCard` 和 `ProfileRechargeModal`。组件级验证新增 `src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的充值入口与 Native 二维码断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileRechargeModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders point products and forwards buy action|shows empty state when the selected tab has no products|profile recharge modal shows native qr code on desktop web by default|create tab wallet chip opens recharge when recharge entry is enabled"`、`npm run typecheck`。
|
|
||||||
19.3.18. RPG 首页个人中心的泥点账单、每日任务和兑换码三类标准 profile 弹层分别抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;账单继续复用 `PlatformProfileSecondaryModalShell`,任务和兑换码继续复用 `PlatformProfileModalShell`,页面不再内联账单余额 badge、任务领取列表和兑换码输入提交实现。三者均新增组件级测试,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的真实入口断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "renders ledger entries with shared balance presentation|retries from the shared error state|renders claimable tasks and forwards claim action|keeps incomplete tasks disabled|submits on button click and enter key|disables submit when the code is blank|opens wallet ledger modal from narrative coin card|profile daily task shortcut reflects task progress and claim updates|wallet ledger modal shows empty and error states|opens reward code modal from profile action on mobile|create tab wallet chip opens reward code when recharge entry is hidden"`、`npm run typecheck`。
|
|
||||||
19.3.19. RPG 首页个人中心的支付结果提示、支付确认遮罩与扫码面板继续向共享组件收口:支付结果 / 确认中弹层统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 仅保留支付状态映射、扫码打开关闭和结果写回,不再内联 `RechargePaymentResultModal`、`RechargePaymentConfirmationMask`、`ProfileQrScannerModal`、`BarcodeDetector` 启动逻辑和 profile 弹层壳层参数。组件级验证新增 `src/components/common/PlatformStatusDialog.test.tsx` 与 `src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`,首页继续复用 `RpgEntryHomeView.recharge.test.tsx` 的支付 / 扫码入口断言。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx`、`npm run test -- src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal jumps to h5 payment on mobile web by default|profile recharge modal posts mini program payment request and reacts to success hash result|profile recharge modal releases submitting state and shows virtual payment failure detail|profile recharge modal eventually shows error text even when hashchange is not dispatched|profile recharge modal resumes virtual payment confirmation when pageshow returns with paid order|profile recharge modal blocks tab navigation while virtual payment confirmation is pending|profile scan action opens camera scanner instead of recharge panel"`、`npm run typecheck`。
|
|
||||||
19.3.20. `PlatformStatusDialog` 继续扩展到 notice 场景:组件新增 header notice 布局、body content、close button、backdrop / Escape 关闭路径以及动作按钮样式透传;`PlatformEntryFlowShellImpl` 里的 `draftGenerationPointNotice` / `workNotFoundRecoveryDialog` 和 `RpgCreationEntityEditorShared.tsx` 里的 `EditorNoticeDialog` 已接入。创作入口泥点不足、作品不可用恢复和 RPG 大编辑器规则阻断提示不再各自维护 `UnifiedConfirmDialog` 壳层,只保留标题、正文、辅助提示和关闭回调。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle form checks mud points before creating a draft|puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|direct missing public work detail shows unified dialog before returning home"`、`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色至少保留一个背景章节时使用统一提示弹窗|场景连接缺少可连接目标时使用统一提示弹窗|场景保存缺少主角色时使用统一提示弹窗"`、`npm run typecheck`。
|
|
||||||
19.3.21. `PlatformStatusDialog` 继续收口规则阻断和搜索未命中提示:`CustomWorldEntityCatalog.tsx` 的 `minimum-playable` 规则阻断从删除确认分支中拆出,改由独立 `PlatformStatusDialog` 承接;`PlatformEntryFlowShellImpl` 的公开编号搜索弹层拆成“命中用户继续走 `UnifiedModal + PlatformSubpanel`”与“未找到结果改走 `PlatformStatusDialog`”两条分支。业务页不再让规则阻断提示和危险删除确认共用同一套 confirm config,也不再在搜索结果 modal 内同时维护用户信息和错误态两套内容布局。验证命令:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "最后一个可扮演角色不可删除时使用平台状态弹窗"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "searching unmatched public work code shows not-found search result dialog|public code search shows public user summary in shared search result modal and clears it on close"`、`npm run typecheck`。
|
|
||||||
19.3.22. 标准泥点消耗确认弹窗收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;组件固定承接“确认消耗泥点 + 消耗 N 泥点”的同形态标题、正文骨架和确认 / 取消动作,业务页只保留点数、补充说明和确认回调。`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx`、`Match3DResultView.tsx` 以及 `RpgCreationRoleAssetStudioModalImpl.tsx` 已迁移;其中角色形象生成 / 动作草稿生成继续通过自定义 title 和补充说明承接工坊语义,但不再各自拼接 `UnifiedConfirmDialog` 的相同文案和内容结构。后续同类泥点确认优先复用该 Module;像 runtime 道具确认、预计消耗区间确认这类节奏不同的弹层再单独评估是否扩展变体。
|
|
||||||
19.3.23. 平台危险确认弹窗收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;组件固定承接“确认 / 取消 + 危险主动作”的标准骨架,并透传忙碌态、遮罩关闭策略、按钮文案和局部面板样式。`PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认,以及 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认已迁移;业务页继续保留标题、说明文案和确认回调,不再各自拼接 `UnifiedConfirmDialog` 的危险按钮配置。后续删除、覆盖、清空等危险动作优先复用该 Module,再按需要补充更窄的语义 wrapper。
|
|
||||||
19.3.24. 平台未保存离开确认弹窗收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;组件固定承接“继续编辑 + 确认离开”的标准骨架,并按 `platform / pixel` 两类确认风格兜底默认遮罩和面板样式。`RpgCreationEntityEditorShared.tsx` 中的关闭未保存修改确认、生成结果未保存退出确认和普通结果未保存退出确认已迁移;业务页只保留标题、确认按钮文案和未保存提示内容,不再各自拼接 `UnifiedConfirmDialog` 的 cancel/confirm 组合和重复壳层 class。
|
|
||||||
19.3.25. 平台单按钮已读状态弹窗收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;组件固定承接“状态提示 + 知道了”这一类单按钮确认已读语义,并透传 action surface / size / fullWidth / class、header、关闭路径和局部 panel 覆写。`BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示已迁移;业务页继续保留 status、标题、说明和关闭回调,不再各自手写 `PlatformStatusDialog` 的 `action={{ label: '知道了', onClick: onClose }}` 结构。
|
|
||||||
19.3.26. profile 侧重复的 `error / loading / empty / content` 分支统一收口到 `src/components/common/PlatformAsyncStatePanel.tsx`;该 Module 只承接互斥状态切换,不承接需要和内容并存的 success / error banner。`PlatformProfileReferralModal.tsx`、`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileTaskCenterModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入。后续 profile 或白底 panel 侧若只是同形态互斥异步状态,优先传 slot 复用该骨架,不再把 `loading skeleton` / `empty state` / `retry error` 直接写回业务页。
|
|
||||||
19.3.27. `PlatformSegmentedTabs` 支持 `layout="scroll"` 承接横向可滚动 tab rail;`CustomWorldCreationStartCard.tsx`、`CustomWorldWorkTabs.tsx` 和 `RpgEntryHomeView.tsx` 的排行 tab、分类筛选项已接入。共享组件先统一 `tablist/tab` 语义、滚动容器和基础交互;当同一类皮肤在首页、作品架、分类筛选或个人中心内重复出现时,再沉淀到 `src/components/common/PlatformSegmentedTabPresets.tsx` 的薄 preset,避免业务页继续复制 `itemClassName`,也避免把一次性玩法配置项抽成过胖公共组件。
|
|
||||||
19.3.28. `PixelCloseButton.tsx` 保持为 RPG 语义薄封装,底层改为复用 `src/components/common/PlatformModalCloseButton.tsx` 的 `variant="pixel"`;共享 close button 统一承接像素风基础 chrome、`absolute / inline` placement、默认 `title=label` 和可选 `stopPropagation` 冒泡拦截,`CharacterChatModal.tsx` 与 `MapModal.tsx` 的 inline / absolute 真实 importer 已补测试。后续需要像素风关闭按钮时优先使用 `PlatformModalCloseButton variant="pixel"` 或继续复用 `PixelCloseButton` 语义壳,不再手写本地 close button。
|
|
||||||
19.3.29. 平台入口创作前置泥点阻断提示抽到 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;`PlatformEntryFlowShellImpl.tsx` 不再直接拼 `PlatformAcknowledgeStatusDialog` 的标题、说明和 amber icon 条件分支。后续若只是平台入口里的泥点前置检查提示,优先继续扩展这个局部语义 wrapper;不要急着在 `common/` 抽泛化 `BlockingNoticeDialog`,避免把底层状态弹窗的样式透传再次包装一层。
|
|
||||||
19.3.30. `PlatformSegmentedTabs` 继续承接首页 / 结果页里剩余的横向 rail 与二选一切换:`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。`CustomWorldEntityCatalog` 通过 `ReactNode label` 保留“标题 + count”两行内容;`RpgEntryHomeView`、创作入口、作品架和个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤沉淀到 `PlatformSegmentedTabPresets`,业务页只保留 items、activeId 和回调。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,不再把这些切换项当普通 button;一次性玩法配置项继续直接组合 `PlatformSegmentedTabs`。
|
|
||||||
19.3.31. 简单泥点确认流的开关状态机收口到 `src/components/common/useMudPointConfirmController.ts`;该 hook 只承接 `open / requestOpen / close / confirm` 四个动作,`confirm` 固定先关弹窗再执行回调,不持有 `points / title / description / confirmDisabled` 之类业务字段。`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 和 `Match3DResultView.tsx` 的两个批量素材面板已接入;`PuzzleCreationWorkspace` 仍在业务页判断“只有 `aiRedraw` 才弹确认”。`PuzzleResultView.tsx` 与 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类要么节奏不同、要么携带 pending payload 的场景先保留本地状态机,不把 hook 扩成泛型动作路由器。
|
|
||||||
19.3.32. 标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页里重复的白底 portal 弹窗壳层进一步收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 以支持“可见标题随业务对象变化、可访问名称保持固定”的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态专属 overlay 继续保留本地 close 语义,不把 `PlatformToolModalShell` 硬塞进非平台白底工具弹窗场景。
|
|
||||||
19.3.33. `PlatformAsyncStatePanel` 从 profile modal 扩展到作品架:`CustomWorldCreationHub.tsx` 的作品架主体现在也统一通过 `loadingState / emptyState / children` 三个 slot 切换,保留外层 error + 重试提示不并入共享状态骨架。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用 `PlatformAsyncStatePanel`,不要再在业务 JSX 中重复拼 skeleton 和“当前筛选下没有内容”的分支。验证命令:`npx vitest run src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run check:encoding`。
|
|
||||||
19.3.34. `CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续向共享按钮收口:带平台动作外观的复制按钮现在直接组合 `PlatformActionButton`,仅保留 `pill` 分支继续复用 `PlatformPillBadge` 风格。复制反馈按钮不再手动调用 `getPlatformActionButtonClassName` 拼平台按钮基础 chrome;后续同类“复制状态机 + 平台动作按钮”组合也优先走 `CopyFeedbackButton + PlatformActionButton`,不要在业务页或按钮组件里重新混写图标、文案、aria 和 class。验证命令:`npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/common/PlatformActionButton.test.tsx`。
|
|
||||||
19.3.35. 详情页头部动作组合收口到 `src/components/common/PlatformDetailTopbar.tsx` 与 `src/components/common/PlatformDetailShareActions.tsx`;前者只承接“返回 / 标题 / 右侧动作槽位”的 topbar 骨架,并允许 `pill` / `icon` 两种返回按钮语义,后者只承接“前置 badge 区块 + 作品号复制 + 分享复制”这一组稳定动作,不吸收详情页自己的标题、摘要、作者、封面轮播或业务 CTA。`RpgEntryWorldDetailView.tsx` 已接入完整的 overlay 版动作组合,统一世界主题 badge、作者、发布时间、作品号和分享入口;`PlatformWorkDetailView.tsx` 已接入 icon topbar 与 solid 版作品号复制动作,并继续保留公开详情页自己的顶部 icon 分享入口和分享反馈提示。后续同类详情页若只是复用返回按钮骨架、标题居中布局或作品号 / 分享动作排列,优先直接组合这两个 Module,不要把整页 detail header 抽成巨型配置对象。验证命令:`npx vitest run src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/platform-entry/PlatformWorkDetailView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check -- src/components/common/PlatformDetailTopbar.tsx src/components/common/PlatformDetailTopbar.test.tsx src/components/common/PlatformDetailShareActions.tsx src/components/common/PlatformDetailShareActions.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.tsx src/components/platform-entry/PlatformWorkDetailView.tsx`。
|
|
||||||
19.3.36. `PlatformToolModalShell` 继续承接 RPG 结果页发布检查弹窗;`RpgCreationResultActionBar.tsx` 只保留发布检查、封面预览、封面设置和发布动作语义,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close、body/footer spacing 和遮罩关闭逻辑。后续结果页 / 工具页里同形态的白底 portal 弹窗优先迁移到 `PlatformToolModalShell`;编辑器大壳、暗色 runtime overlay 和需要专属布局的面板继续保留局部 shell。
|
|
||||||
19.3.37. `PlatformToolModalShell` 继续承接方洞结果页图片槽弹窗;`SquareHoleResultView.tsx` 的封面 / 背景 / 形状 / 洞口图片查看与历史选择弹窗只保留当前图、上传、AI 生成和历史素材选择语义,不再直接维护 `createPortal`、主题 overlay、白底 remap panel、header close 和滚动 body。该弹窗使用 `ariaLabel` 保持“封面图查看 / 背景图查看”等固定可访问名称,历史生成区继续由 `PlatformAssetPickerGrid` 承接读取、错误和空态。
|
|
||||||
19.3.38. `PlatformToolModalShell` 继续承接视觉小说结果页素材选择弹窗;`VisualNovelAssetPickerDialog` 只保留本地上传、AI 图片生成、历史素材读取、错误提示和素材选择回调,不再直接维护 `createPortal`、平台主题 overlay、白底 remap panel、header close 和滚动 body。视觉小说音频生成弹窗需要保留生成中禁止关闭,实体编辑器弹窗需要保留编辑 footer,本轮先不混入同一提交,后续逐个迁移并补对应交互测试。
|
|
||||||
19.3.35. 白底 / 暗色面板里的轻量空态和普通 CTA 继续按共享组件收口:`PuzzleResultView.tsx` 的“还没有可编辑的拼图草稿”、`RpgCreationAssetDebugPanel.tsx` 的“没有可诊断项”、`VisualNovelEntityGrid` 的空实体列表、`AccountModal.tsx` 里账号安全分区的“无安全限制 / 无登录设备 / 无操作记录”以及 `LoginScreen.tsx` 的“当前登录入口暂不可用”都改为 `PlatformEmptyState`;`Match3DResultView.tsx` 的引用素材列表直接交给 `PlatformAssetPickerGrid` 自己处理空态。`AdventureEntityModal.tsx` 的私聊按钮、`InventoryPanel.tsx` 的锻造 / 合成按钮、`RpgAdventurePanel.tsx` 底部 `队伍 / 背包 / 换一换 / 退出聊天` 按钮,以及 `RpgAdventurePanelOverlays.tsx` 里的“查看任务 / 保存并退出”都改为 `PlatformActionButton surface="editorDark"`,业务页只贴回局部 sky / emerald / runtime 皮肤。后续白底子面板里的只读空态优先使用 `PlatformEmptyState surface="subpanel"`;暗色编辑 / 运行面板里的普通动作优先使用 `PlatformActionButton surface="editorDark"`,若还需要 stopPropagation、局部字号或图标排版,可保留薄包装层,但不要再回退到原生 `<button>` 基础 chrome。验证命令:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx src/components/AdventureEntityModal.test.tsx src/components/InventoryPanel.test.tsx src/components/auth/AccountModal.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformActionButton.test.tsx`、`npm run check:encoding`。
|
|
||||||
19.3.36. `VisualNovelEntityGrid` 的空态也继续收口到 `PlatformEmptyState surface="subpanel" size="inline"`;角色 / 场景 / 剧情阶段共用这一网格组件后,白底实体列表里的“暂无角色 / 暂无场景 / 暂无剧情阶段”等同构空态不再回退成 `PlatformSubpanel`。同时,`RpgCreationRoleAssetStudioModalImpl.tsx` 与 `RpgCreationEntityEditorShared.tsx` 保留局部 `ActionButton` 语义壳,但按钮本体已统一委托给 `PlatformActionButton surface="editorDark"`,只在包装层补最小的 `stopPropagation`、tone 映射和局部 class 适配。后续类似“暗色编辑器局部包装按钮”优先沿用这种薄包装模式,不再直接手写原生 `<button>` 基础 chrome。验证命令:`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformActionButton.test.tsx`、`npm run check:encoding`。
|
|
||||||
19.3.37. 暗色编辑器里的局部动作按钮继续往共享 `editorDark` button 收口:`CustomWorldNpcVisualEditor.tsx` 的本地 `ActionButton` 和 `SkillEffectPreview.tsx` 的“重新预览”按钮都改为委托 `PlatformActionButton surface="editorDark"`。这类局部包装仍可保留 `stopPropagation`、图标布局、`tone` 映射和少量局部视觉覆写,但按钮本体不再直接使用原生 `<button>` 承接边框 / 底色 / hover / disabled chrome。验证命令:`npm run test -- src/components/common/PlatformActionButton.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.38. `PlatformAsyncStatePanel` 继续从 profile / 作品架扩展到首页公开分区:`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区都统一改成 `loadingState / emptyState / children` 三个 slot 切换。页面继续把 `platformError` 保留在状态壳外层,让错误提示可以和内容并存;`recommend runtime`、分类筛选和其它含二级筛选 / 运行态语义的分支暂不并入这次收口。后续首页、作品架或白底列表若只是纯 `loading / empty / content` 互斥状态,优先直接复用 `PlatformAsyncStatePanel`,不要再把空态与读取态分支手写回业务 JSX。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.39. 桌面首页里的轻量可点击扁平行统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;该 Module 只承接 `button + 左侧主内容 + 右侧 affordance` 的结构、默认 `type="button"` 和 `leading / trailing` 插槽,不承接卡片封面、复杂摘要或 runtime 专属交互。`RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行已接入。教培 promo card、分类卡片、世界卡和 runtime 列表项继续保留各自语义,等出现更多同构 desktop flat row 再逐步扩覆盖面。验证命令:`npx vitest run src/components/common/PlatformNavigableListItem.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.40. `PlatformNavigableListItem` 继续从桌面首页扩展到 profile 设置行:`src/components/platform-entry/PlatformProfilePrimitives.tsx` 里的 `ProfileSettingsRow` 现已统一委托共享 `button + leading + trailing` 骨架,保留本地 `platform-profile-settings-row` class 承接行间分隔、icon 胶囊和字号微调。后续 profile / 账户中心里同类“左图标标题 + 右箭头”的轻量导航行,优先直接复用 `PlatformNavigableListItem`,不要再回退成原生 `<button>` 手写布局。验证命令:`npx vitest run src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.41. `PlatformAsyncStatePanel` 继续补齐首页分类分支:`RpgEntryHomeView.tsx` 的移动端“发现 -> 分类”、桌面发现页“分类”以及桌面首页“作品分类”模块都改成共享状态壳承接外层 `loading / empty / content` 切换,分类控制条与排序按钮继续保留在内容 slot 中;筛选后无结果的“当前筛选下没有作品。”也统一改由内层 `PlatformAsyncStatePanel` 切换,不再在三处 JSX 中各自手写空态分支。后续同类“外层数据可用性 + 内层筛选空态”面板优先沿用这套双层状态壳,不要回退成嵌套 ternary。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.42. `PlatformAsyncStatePanel` 继续从首页扩展到公共素材网格、runtime 面板和账号子区块:`PlatformAssetPickerGrid` 现已统一用共享状态壳承接 `loading / empty / content`,但继续把 `error` banner 留在外层,以保持“错误提示可与内容或加载态并存”的原语义;`VisualNovelSavePanel.tsx` 的存档列表,以及 `AccountModal.tsx` 里的“安全状态 / 当前登录设备 / 账号操作记录”三个子区块也都改成各自使用 `PlatformAsyncStatePanel`。后续白底列表、素材选择器或账号子面板若只是标准互斥异步状态,优先按这三种接法复用共享状态壳,不再把读取态和空态分支手写回组件内部。验证命令:`npx vitest run src/components/common/PlatformAssetPickerCard.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/auth/AccountModal.test.tsx src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.43. 轻量按钮漏网继续向共享按钮收口:`PlatformTagEditor.tsx` 的标签 chip 删除入口已改成紧凑 `PlatformIconButton`,保留 `label="删除标签 ${tag}"`、透明背景和原 chip 高度,不再手写裸 `<button>`;`RpgEntryCharacterSelectView.tsx` 两处重复的“返回”按钮已统一沉到文件内 `CharacterSelectBackButton`,底层委托 `PlatformActionButton surface="editorDark"`,保留原有暗色视觉与文案。后续同类“局部 chip 删除按钮”优先先用 `PlatformIconButton` 压缩尺寸和视觉;暗色轻量返回 / 返回上一级 CTA 则优先用 `PlatformActionButton surface="editorDark"` 包一层局部 helper,不再复制原生 `<button>` class。验证命令:`npx vitest run src/components/common/PlatformTagEditor.test.tsx src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.44. 暖色生成页顶部返回入口开始沉淀共享壳:`GenerationProgressHero.tsx` 新增 `GenerationHeaderBackButton`,统一承接 `ArrowLeft + 文案 + 透明背景` 的暖色生成页返回按钮骨架,并底层复用 `PlatformIconButton variant="darkMini"`;`CustomWorldGenerationView.tsx` 与 `BarkBattleGeneratingView.tsx` 已接入,继续保留各自 `backLabel`、禁用态和局部暖色文字样式。后续同类生成页、等待页或暖色 hero 顶栏若只是“左箭头 + 返回文案”的轻量返回入口,优先复用这个小组件,不再各自手写 `ArrowLeft`、透明按钮背景和字号间距。验证命令:`npx vitest run src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/common/PlatformIconButton.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.45. 白底 / 浅色结果页与工作台顶部的轻量返回入口继续收口到 `src/components/common/PlatformBackActionButton.tsx`;该 Module 固定承接 `PlatformActionButton tone="ghost" size="xs"` 上的 `ArrowLeft + 返回文案` 骨架,并只暴露 `compact / regular` 两档尺寸,分别覆盖紧凑结果页 header 与标准白底结果页顶栏。当前 `PuzzleResultView.tsx`、`SquareHoleResultView.tsx`、`Match3DResultView.tsx`、`VisualNovelResultView.tsx` 四个结果页已接入 `variant="compact"`,`PuzzleClearResultView.tsx`、`JumpHopResultView.tsx`、`WoodenFishResultView.tsx` 三个结果页已接入 `variant="regular"`,`BabyObjectMatchResultView.tsx` 继续使用紧凑款并保留 `className="px-3"` 贴合原横向留白。暖色生成页顶部返回入口继续走 `GenerationHeaderBackButton`,`BigFishResultView.tsx` 这类 dark hero / 强品牌 special case 继续保留 `PlatformIconButton variant="darkMini"` 路线,不强行并入同一个白底返回按钮基元。后续白底结果页、浅色工作台或普通 platform 顶栏里若只是“左箭头 + 返回”轻量返回入口,优先直接复用 `PlatformBackActionButton`,只在局部补尺寸和少量外边距,不再各页重复手写 ghost button class。
|
|
||||||
19.3.46. `PlatformNavigableListItem` 继续从桌面 flat row 扩展到首页公开列表里的排行行与分类行;`RpgEntryHomeView.tsx` 的 `PlatformRankingItem` 和 `PlatformCategoryGameItem` 现在都统一委托共享 `button + leading + body + trailing` 骨架,同时保留原有 `platform-ranking-item__*`、`platform-category-game-item__*` 局部 class 承接封面尺寸、标题摘要、公开 badge、metric 和右侧 `试玩 / 进入` affordance。后续首页、发现页或 profile 侧同类“封面 + 文本主体 + 右侧动作提示”的浅色导航行,优先先尝试复用 `PlatformNavigableListItem` 并把局部视觉挂在业务 class 上,不要为了这类 row 再回退成原生 `<button>` 手写布局;但教培 promo card、runtime 列表项和带复杂手势的卡片仍保留本地语义,不把共享行骨架扩成万能作品卡。验证命令:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.47. `PlatformDarkModalFooter` 继续从标准双按钮 footer 扩展到 detail / confirm 收尾:`NpcModals.tsx` 的交易详情单按钮 footer 与 `MapModal.tsx` 的场景切换确认 footer 已接入共享 dark footer frame,分别保留“关闭”单 CTA 和“取消 / 确认前往”双 CTA 的业务语义、按钮 tone 与禁用态。后续 dark / pixel modal 里若只是标准底部分隔线 + 常规动作区排布,优先直接复用 `PlatformDarkModalFooter`,即使只有单个按钮也不再手写 `flex justify-end`;但像 `SquareImageCropModal.tsx` 这类白底弹窗 footer、sticky 工作台 footer 和运行态 HUD 工具条继续留在各自语义壳层,不强行混到 dark footer 抽象里。验证命令:`npx vitest run src/components/NpcModals.test.tsx src/components/MapModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.48. `RpgEntryHomeView.tsx` 里的分类筛选工具条继续从页面内重复 JSX 收口到 `src/components/common/PlatformFilterToolbar.tsx`;该 Module 只承接“筛选按钮 + 横向 tabs + 排序按钮”的结构排布,暴露 `mobile / desktop` 两种 layout 以覆盖移动端 divider + 独立排序行和桌面端同排布局差异,但不持有分类列表、筛选状态、空态或排序逻辑。当前 RPG 首页分类区已接入,后续若其它白底列表页也出现同构的筛选壳层,可直接复用这套薄结构组件;若场景只是在单页内局部重复、接口会为了兼容业务差异不断膨胀,则优先退回文件内 helper,不把 `common` 扩成假的“万能筛选条”。验证命令:`npx vitest run src/components/common/PlatformFilterToolbar.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.49. `SquareImageCropModal.tsx` 的白底 modal 壳层与 footer 已收口到 `src/components/common/UnifiedModal.tsx`;`UnifiedModal` 为此只薄补了 `titleId` 与 `closeIcon` 透传,继续由调用方决定 `closeOnBackdrop`、`closeOnEscape`、`portal`、header/footer 样式和按钮内容,不额外掺入 profile 业务语义,也不让 `common/` 反向依赖 `platform-entry/`。`SquareImageCropModal.tsx` 继续保留裁剪拖拽、pointer capture、保存禁用态与两列等宽 footer 行为,只把 header / body / footer 外壳交给共享 modal 承接。后续 `common` 级白底工具弹窗若只是标准标题栏 + 内容区 + footer 按钮排布,优先先看 `UnifiedModal` 是否够用,再决定是否需要新的薄壳;不要为了一个弹窗把 `PlatformProfileModalShell` 之类带页面语义的壳层倒灌回 `common`。验证命令:`npx vitest run src/components/common/SquareImageCropModal.test.tsx src/components/common/UnifiedModal.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.50. `CreativeImageInputPanel.tsx` 里内嵌的图片查看器改为 `src/components/common/PlatformImagePreviewModal.tsx`:参考图预览与主图预览都使用黑底全屏查看器,底层继续委托 `UnifiedModal size="fullscreen"` 承接 dialog / portal / Escape 语义,但 overlay、panel 和 body 必须强制全屏黑底,避免透出原页面或白底工具面板。查看器固定提供缩小、重置、放大和关闭图标按钮,缩放范围夹在 `1x-4x`;图片先按视口完整 contain,放大后拖拽位移按缩放后的图片边界夹取,不能把图片拖到露出背景。移除图片确认继续复用 `src/components/common/UnifiedConfirmDialog.tsx`,不和全屏查看器混同。后续 `common` 级图片大图预览优先复用 `PlatformImagePreviewModal`,若只是裁剪、选择或编辑工具弹窗,再回到 `UnifiedModal` / `PlatformToolModalShell` 的白底工具语义。验证命令:`npm run test -- src/components/common/PlatformImagePreviewModal.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.51. `PlatformReportDialog.tsx` 与 `PublishShareModal.tsx` 共同的工具信息弹窗壳层继续收口到 `src/components/common/PlatformUtilityInfoModal.tsx`;该 Module 只承接平台主题 overlay、白底 panel,以及 body / footer 的基础间距与标准 footer frame,底层继续委托 `UnifiedModal.tsx`,不吸收报告字段列表、分享正文、复制逻辑、渠道按钮或品牌图标这些业务内容。`PlatformReportDialog.tsx` 继续保留 `PlatformInfoBlock` 字段列表与 joined report copy 行为,`PublishShareModal.tsx` 继续保留分享文案、主复制动作和渠道按钮网格;后续 `common` 级白底工具信息弹窗若只是重复这套“共享 modal 外壳 + 业务正文 / footer 内容”的骨架,优先复用 `PlatformUtilityInfoModal`,只有当正文编排或 footer 交互明显偏离时才回退到直接组合 `UnifiedModal`。验证命令:`npx vitest run src/components/common/PlatformUtilityInfoModal.test.tsx src/components/common/PlatformReportDialog.test.tsx src/components/common/PublishShareModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.52. profile 白底 modal 里的摘要头、列表骨架和内容行继续沉到 `src/components/common/PlatformProfileSummaryHeader.tsx`、`src/components/common/PlatformProfileSkeletonList.tsx` 与 `src/components/common/PlatformProfileContentRow.tsx`;这三个 Module 只承接 `kicker + title + badge` 的摘要层次、重复 skeleton 列表行,以及 `PlatformSubpanel` 上的 `div / button` 内容行语义,不持有账单金额、任务进度、邀请用户信息、充值商品结构或 modal 状态切换逻辑。`PlatformProfileWalletLedgerModal.tsx`、`PlatformProfileTaskCenterModal.tsx`、`PlatformProfilePlayedWorksModal.tsx`、`PlatformProfileReferralModal.tsx` 与 `PlatformProfileRechargeModal.tsx` 已接入;后续 profile 副弹层若只是重复这三类白底内容骨架,优先继续复用这组薄组件,不再把 skeleton、摘要头和 row chrome 写回各自 modal。验证命令:`npx vitest run src/components/common/PlatformProfileModalContent.shared.test.tsx src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/platform-entry/PlatformProfileReferralModal.test.tsx src/components/platform-entry/PlatformProfileRechargeModal.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/`;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
|
|
||||||
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
|
|
||||||
19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。
|
|
||||||
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`。
|
|
||||||
21. 图片编辑面板中的白底胶囊开关迁移到 `PlatformPillSwitch`;通用创作图片面板和抓大鹅封面编辑的 `AI重绘` 已先迁移。后续同类开关只保留受控布尔值和状态变更回调,switch 输入语义、轨道、圆点、白底浮层和禁用态统一由 Module 承接。
|
|
||||||
22. 设置面板、结果页运行配置和工作台白底配置项中的整行开关迁移到 `PlatformToggleRow`;视觉小说结果页、runtime 设置面板和拼消消创作工作台 AI 生成底图开关已先迁移。后续整行配置项只保留字段写回和可选点击动作,不再重复开关行 chrome、checkbox class 或状态 pill。
|
|
||||||
22.1. RPG 创作侧标准 dark header / footer 动作继续向共享按钮收口:`RpgCreationRoleAssetStudioModalImpl.tsx` 的 header“关闭”、`RpgCreationEntityEditorShared.tsx` 的 footer“取消”、`RpgCreationRoleAssetStudioFooter.tsx` 的“保存到当前角色”都改为委托 `PlatformActionButton surface="editorDark"`。局部壳层只继续保留 `stopPropagation`、tone 映射、布局和极少量字号/宽度贴合;标准暗色编辑器里的 close / cancel / save CTA 不再各自手写原生 `<button>` 基础 chrome。
|
|
||||||
22.2. RPG runtime overlay 里的标准 dark CTA 和可点击 dark row 继续向共享原子收口:`RpgAdventurePanelOverlays.tsx` 的 goal panel“知道了”、任务详情里的“领取任务 / 返回交付”、任务完成提示里的“打开任务日志”都改为委托 `PlatformActionButton surface="editorDark"`;设置面板里的“运行统计”入口改为 `PlatformSubpanel as="button" surface="dark"`。像素风 choice button、HUD launcher、奖励物品格和输入 composer 这类 runtime 专属控件继续保留独立语义,不并回普通平台按钮。
|
|
||||||
22.3. NPC dark modal footer 与暗色明细空态继续向共享原子收口:`NpcModals.tsx` 里的交易 / 赠礼 / 招募弹窗 footer 按钮和物品详情“关闭”按钮都改为委托 `PlatformActionButton surface="editorDark"`,交易右侧“请选择一件物品”提示改为 `PlatformEmptyState surface="editorDark"`;`CharacterInfoShared.tsx` 里的 `BuildContributionDetailPanel` 空明细也改为 `PlatformEmptyState surface="editorDark"`。数量 stepper、赠礼 / 招募 option card、标签强度按钮这类带更强业务语义的控件继续保留局部实现。
|
|
||||||
22.4. 暗色 / 像素 modal 的标准 footer 布局统一收口到 `src/components/common/PlatformDarkModalFooter.tsx`;该 Module 只承接 dark footer 的顶部分隔线、padding 和常见动作区排布,不承接“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer、`InventoryItemViews.tsx` 的详情 footer wrapper,以及 `CompanionCampModal.tsx` 的“营地气氛”内容 footer 已接入。sticky 工作台 footer、正文里的单独 CTA 收尾和 runtime HUD 工具条继续保留局部布局;后续 dark / pixel modal 若只是同构 footer chrome,优先直接复用这个 Module,不再重复手写 `border-t border-white/10 + px/py + justify-end gap-*` 组合。验证命令:`npx vitest run src/components/common/PlatformDarkModalFooter.test.tsx src/components/CompanionCampModal.test.tsx src/components/NpcModals.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformReportDialog.test.tsx src/components/platform-entry/PlatformTaskCompletionDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/auth/BindPhoneScreen.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx -t "renders generated formal previews with accurate status copy"`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/UnifiedConfirmDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/useCopyFeedback.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CopyFeedbackButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CopyCodeButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PublishShareModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformEntryCreationTypeModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyFeedbackButton.test.tsx src/components/common/CopyCodeButton.test.tsx src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/AdventureEntityModal.test.tsx -t "BuildContributionDetailPanel|技能详情静态标签"`
|
|
||||||
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "CharacterSkillsList|supports dark compact subpanel cards"`
|
|
||||||
- `npm run test -- src/components/CharacterInfoShared.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/AdventureEntityModal.test.tsx -t "物品空态|技能详情静态标签"`
|
|
||||||
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "主分区|最近回响|supports dark compact subpanel cards"`
|
|
||||||
- `npm run test -- src/components/AdventureEntityModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "私聊和队友收束|tinted dark information panels"`
|
|
||||||
- `npm run test -- src/components/InventoryPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx -t "背包文书|背包工坊|supports dark compact subpanel cards|supports editor dark surface"`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "可扮演角色技能动作状态|supports dark compact subpanel cards"`
|
|
||||||
- `npm run test -- src/components/CharacterDetailModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/CharacterPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "角色面板详情静态信息|tinted dark information panels"`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/BackstoryArchive.test.tsx src/components/AffinityStatusCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/MapModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformOverlayBadge.test.tsx src/components/common/PlatformSlotBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformQuantityBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain"`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformPillBadge.test.tsx -t "quest offer accept button|supports dark RPG badge tones"`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "adventure statistics panel|supports dark compact subpanel cards"`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformQuantityBadge.test.tsx -t "quest offer accept button|quest reward strip|quest completion notice|battle reward modal|supports dark compact subpanel cards|supports dark RPG badge tones|renders a dark bottom-right quantity badge"`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal trusts per-product first bonus display after points recharge"`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut shows reward subtitle and invited users"`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "confirms virtual payment after returning without hash result|releases submitting state after cancelled wechat pay result"`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CopyFeedbackMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformRuntimeStatusToast.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/square-hole-runtime/SquareHoleRuntimeShell.test.tsx src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.test.tsx`
|
|
||||||
- `npm run test -- src/components/CharacterChatModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/CompanionCampModal.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
|
||||||
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformProgressBar.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformStatusMessage.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "场景图片保存后会同步更新编辑页和场景列表"`
|
|
||||||
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx -t "可扮演角色空态复用暗色平台空态"`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "settings save-disabled hint|dark editor dashed empty state"`
|
|
||||||
- `npm run test -- src/components/NpcModals.test.tsx -t "NPC 弹窗空态复用暗色平台空态"`
|
|
||||||
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "NPC 交易静态信息卡|supports dark compact subpanel cards"`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx -t "quest offer accept button reuses the shared accepted-quest follow-up chain|quest log empty state reuses dark PlatformEmptyState chrome"`
|
|
||||||
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformActionButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
|
||||||
- `npm run test -- src/components/common/platformActionButtonModel.test.ts src/components/common/PlatformActionButton.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CompanionCampModal.test.tsx src/components/MapModal.test.tsx src/components/CharacterChatModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/shared/PuzzleImageModelPicker.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformPillBadge.test.tsx src/components/common/CopyCodeButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx -t "shows publish failures in a dismissible modal"`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/game-canvas/GameCanvasEntityLayer.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconBadge.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformIconButton.test.tsx src/components/common/PlatformUploadPreviewCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformToggleRow.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformFieldLabel.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx src/components/CustomWorldResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformToggleRow.test.tsx src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentHome.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/CreativeImageInputPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile nickname modal"`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile community shortcut|profile redeem invite"`
|
|
||||||
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformFieldLabel.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile daily task"`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "profile recharge modal shows native qr code"`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile played modal|profile page keeps save archives inside played stats panel"`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
|
|
||||||
- `npm run test -- src/components/rpg-runtime-shell/RpgRuntimeStageRouter.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger"`
|
|
||||||
- `npm run test -- src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformTextField.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/SelectionCustomizationModals.test.tsx src/components/CharacterChatModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/SelectionCustomizationModals.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/auth/AccountModal.test.tsx src/components/auth/BindPhoneScreen.test.tsx src/components/auth/CaptchaChallengeField.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/common/PlatformTextField.test.tsx src/components/common/PlatformEmptyState.test.tsx -t "reward code|invite query|profile redeem invite|daily task"`
|
|
||||||
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSegmentedTabs.test.tsx src/components/auth/AuthGate.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/platform-entry/PlatformErrorDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformInfoBlock.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformStatGrid.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillBadge.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile played modal summary"`
|
|
||||||
- `npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformProgressBar.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldGenerationView.test.tsx src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/creation-agent/CreationAgentWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/NpcModals.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx src/components/common/PlatformDarkOptionCard.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
|
|
||||||
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimePanels.emptyState.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformStatGrid.test.tsx`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryWorldDetailView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformFieldLabel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformSubpanel.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx`
|
|
||||||
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx -t "作品封面上传|tinted dark information panels"`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformPillBadge.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaFrame.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/common/PlatformSubpanel.test.tsx src/components/common/PlatformMediaFrame.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformMediaTileGrid.test.tsx src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTagEditor.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformTextField.test.tsx src/components/common/PlatformTagEditor.test.tsx`
|
|
||||||
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformEmptyState.test.tsx src/components/common/PlatformTextField.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformPillSwitch.test.tsx src/components/common/CreativeImageInputPanel.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/UnifiedModal.test.tsx src/components/common/PlatformModalCloseButton.test.tsx src/components/common/UnifiedConfirmDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformModalCloseButton.test.tsx src/components/SelectionCustomizationModals.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/squareImageCropModel.test.ts`
|
|
||||||
- `npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx`
|
|
||||||
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "direct missing public work detail"`
|
|
||||||
- `npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformEmptyState.test.tsx`
|
|
||||||
- `npm run test -- src/components/big-fish-result/BigFishResultView.test.tsx src/components/common/PlatformStatusMessage.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/AuthGate.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/AuthGate.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/AccountModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/auth/AccountModal.test.tsx src/components/common/PlatformSubpanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/CreativeImageInputPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/JumpHopCreationWorkspace.test.tsx src/components/unified-creation/workspaces/WoodenFishCreationWorkspace.test.tsx src/components/unified-creation/workspaces/Match3DCreationWorkspace.interaction.test.tsx`
|
|
||||||
- `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx src/components/wooden-fish-result/WoodenFishResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx`
|
|
||||||
- `npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/components/bark-battle-creation/BarkBattleResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx`
|
|
||||||
- `npm run test -- src/components/unified-creation/workspaces/PuzzleCreationWorkspace.interaction.test.tsx`
|
|
||||||
- `npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/common/PlatformModalCloseButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/PlatformStatusMessage.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/unified-creation/shared/PuzzleHistoryAssetPickerDialog.test.tsx src/components/square-hole-result/SquareHoleResultView.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformAssetPickerCard.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentInputComposer.test.tsx src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/common/PlatformIconButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "保存修改|保存角色"`
|
|
||||||
- `npm run test -- src/components/wooden-fish-runtime/WoodenFishRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`
|
|
||||||
- `npm run test -- src/components/creation-agent/CreationAgentWorkspace.test.tsx src/components/common/PlatformActionButton.test.tsx src/components/common/platformActionButtonModel.test.ts`
|
|
||||||
- `npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldGenerationView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx src/components/common/PlatformActionButton.test.tsx`
|
|
||||||
- `npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/common/PlatformActionButton.test.tsx src/index.test.ts`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
|
|
||||||
- `npm run test -- src/components/common/PlatformUploadPreviewCard.test.tsx src/components/common/PlatformUploadTile.test.tsx src/components/platform-entry/PlatformFeedbackView.test.tsx`
|
|
||||||
- `npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "场景编辑器会在场景内展示槽位化多幕配置并保存"`
|
|
||||||
- `rg -n "window\\.confirm|window\\.alert" src/components src/services src/hooks -g '*.tsx' -g '*.ts'`
|
|
||||||
- 涉及 JSX 迁移时同步运行对应页面交互测试。
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# 外部生成 Worker 化方案
|
|
||||||
|
|
||||||
更新时间:`2026-06-12`
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前 VectorEngine `gpt-image-2`、音频、LLM 等外部生成链路多数由 `api-server` 的 HTTP handler 直接等待上游、OSS 持久化和 SpacetimeDB 回写完成。前端虽然有生成页和会话轮询,但 HTTP 进程仍承担长耗时副作用,导致接入更多玩法或大图生成时只能放大 API 进程,而不能单独扩展外部生成吞吐。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
|
||||||
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
|
||||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
|
||||||
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
|
||||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
|
||||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
|
||||||
- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
|
|
||||||
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。
|
|
||||||
|
|
||||||
## Module 与 Interface
|
|
||||||
|
|
||||||
新增深一点的 **外部生成任务 Module**,Interface 收敛为:
|
|
||||||
|
|
||||||
- `enqueue_external_generation_job_and_return`:按 `dedupe_key` 幂等创建或返回现有任务。
|
|
||||||
- `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。
|
|
||||||
- `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
|
|
||||||
- `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。
|
|
||||||
- `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。
|
|
||||||
- `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
|
|
||||||
- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
|
|
||||||
|
|
||||||
这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。
|
|
||||||
|
|
||||||
## BFF 状态接口
|
|
||||||
|
|
||||||
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
|
|
||||||
|
|
||||||
- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于 `我的` 页签、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。
|
|
||||||
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
|
|
||||||
|
|
||||||
BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。
|
|
||||||
|
|
||||||
## 任务表
|
|
||||||
|
|
||||||
新增私有表 `external_generation_job`:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `job_id` | 主键,`extgen-` 前缀 UUID |
|
|
||||||
| `dedupe_key` | 唯一键,建议为 `play/action/session/scope` |
|
|
||||||
| `job_kind` | 执行类型,当前拼图为 `puzzle_compile_draft`、`puzzle_generate_images`、`puzzle_generate_ui_background` |
|
|
||||||
| `owner_user_id` | 触发用户 |
|
|
||||||
| `source_module` | 玩法或能力名,例如 `puzzle` |
|
|
||||||
| `source_entity_id` | session/profile/work 等作用域 |
|
|
||||||
| `request_label` | 排障标签 |
|
|
||||||
| `request_payload_json` | worker 执行入参 JSON |
|
|
||||||
| `status` | `pending/running/completed/failed/cancelled` |
|
|
||||||
| `attempt` / `max_attempts` | 当前尝试次数与最大尝试次数 |
|
|
||||||
| `last_error_message` | 最近失败原因 |
|
|
||||||
| `worker_id` | 当前 lease owner |
|
|
||||||
| `lease_expires_at` | lease 到期时间 |
|
|
||||||
| `lease_token` | 本次 claim 的 fencing token,用于阻止过期 worker 回写 |
|
|
||||||
| `available_at` | 下次可领取时间 |
|
|
||||||
| `result_payload_json` | 完成摘要 |
|
|
||||||
| `created_at/started_at/completed_at/updated_at` | 审计时间 |
|
|
||||||
|
|
||||||
索引:
|
|
||||||
|
|
||||||
- `by_external_generation_job_status_available(status, available_at)`
|
|
||||||
- `by_external_generation_job_worker_id(worker_id)`
|
|
||||||
- `by_external_generation_job_source(source_module, source_entity_id)`
|
|
||||||
- `by_external_generation_job_owner_user_id(owner_user_id)`
|
|
||||||
|
|
||||||
## 状态机
|
|
||||||
|
|
||||||
```text
|
|
||||||
pending -> running -> completed
|
|
||||||
pending -> running -> pending (可重试失败)
|
|
||||||
pending -> running -> failed (达到最大重试次数)
|
|
||||||
pending/running -> cancelled (预留)
|
|
||||||
```
|
|
||||||
|
|
||||||
`claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
|
|
||||||
|
|
||||||
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
|
|
||||||
|
|
||||||
## 执行模式与进程角色
|
|
||||||
|
|
||||||
外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制:
|
|
||||||
|
|
||||||
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
|
||||||
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
|
||||||
|
|
||||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
|
||||||
|
|
||||||
- `api`:只启动 HTTP server。
|
|
||||||
- `external-generation-worker`:只启动外部生成 worker,不监听 HTTP。
|
|
||||||
- `external-generation-controller`:只启动 worker controller,不监听 HTTP,也不直接执行外部生成任务。
|
|
||||||
- `all`:本地开发可同时启动 HTTP 与 worker。
|
|
||||||
|
|
||||||
worker 配置:
|
|
||||||
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`:实例 ID;未配置时用 hostname/pid 派生。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY`:单进程并发领取/执行数量。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS`:空队列轮询间隔。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS`:任务 lease 时长;worker 会按约三分之一 lease、最长 30 秒的间隔续租。该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。
|
|
||||||
|
|
||||||
controller 配置:
|
|
||||||
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS`:保底 worker 实例数,生产默认 `1`,controller 不会主动停止 `@1`。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS`:自动扩容上限,生产模板默认 `8`。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER`:每个 worker 实例承担的目标未完成任务数,默认 `2`;目标实例数按 `claimable_pending + running_active + expired_running` 计算后夹在 min/max 之间,避免把已包含过期 running 的 `claimable_count` 重复计入。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS`:controller 轮询队列统计的间隔,默认 `10000`。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS`:连续多少轮无可领取、无运行中、无过期 running 后才允许缩容,默认 `6`;缩容每轮只停止最高编号的一个实例。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE`:systemd worker 模板,默认 `genarrative-external-generation-worker@{}.service`。
|
|
||||||
- `GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN`:只记录决策不执行 systemctl,默认 `false`。
|
|
||||||
|
|
||||||
动态缩扩容方式:生产默认由 `deploy/systemd/genarrative-external-generation-controller.service` 启动 `GENARRATIVE_PROCESS_ROLE=external-generation-controller`,controller 读取 `get_external_generation_queue_stats_and_return` 后对 `genarrative-external-generation-worker@N.service` 执行精确 `systemctl start/stop`;无需改变 HTTP 进程数。controller 只操作 `@1..@MAX` 中的缺口或最高编号多余实例,保留 `@1` 作为保底 worker。缩容或发布重启 worker 时,进程收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务会在 lease 过期后被其它 worker 重新领取。容器链路已有独立 `external-generation-worker` compose service;扩 worker 必须扩这个 worker service,不能只扩 `api-server` HTTP service。
|
|
||||||
|
|
||||||
## 已接入的拼图纵切
|
|
||||||
|
|
||||||
### 拼图
|
|
||||||
|
|
||||||
`compile_puzzle_draft`:
|
|
||||||
|
|
||||||
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
|
|
||||||
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
|
|
||||||
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
|
|
||||||
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
|
|
||||||
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
|
|
||||||
6. 失败后调用 `mark_puzzle_draft_generation_failed`,拼图首期业务失败直接进入 failed;只有失败态写回成功才把队列 job 标为 failed,失败态写回失败则保留租约等待重领。队列仍保留 lease 过期后的崩溃重领,避免 worker 退款后再次成功导致钱包账本漂移。前端通过现有失败草稿/弹窗机制展示来源错误。
|
|
||||||
|
|
||||||
`generate_puzzle_images`:
|
|
||||||
|
|
||||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
|
||||||
2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
|
|
||||||
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
|
|
||||||
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
|
|
||||||
|
|
||||||
`generate_puzzle_ui_background`:
|
|
||||||
|
|
||||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
|
||||||
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
|
|
||||||
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
|
|
||||||
|
|
||||||
### 跳一跳、拼消消和敲木鱼扩展范围
|
|
||||||
|
|
||||||
以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列:
|
|
||||||
|
|
||||||
- 跳一跳 `jump-hop`
|
|
||||||
- `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。
|
|
||||||
- `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。
|
|
||||||
- 拼消消 `puzzle-clear`
|
|
||||||
- `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。
|
|
||||||
- `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。
|
|
||||||
- 敲木鱼 `wooden-fish`
|
|
||||||
- `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。
|
|
||||||
- `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。
|
|
||||||
|
|
||||||
这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
|
|
||||||
|
|
||||||
每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。
|
|
||||||
|
|
||||||
## 验收
|
|
||||||
|
|
||||||
基础检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run spacetime:generate
|
|
||||||
npm run check:spacetime-schema
|
|
||||||
npm run check:server-rs-ddd
|
|
||||||
cargo check -p api-server --manifest-path server-rs/Cargo.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
定向测试:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test -p spacetime-module external_generation --manifest-path server-rs/Cargo.toml
|
|
||||||
cargo test -p spacetime-module level_generation_failure --manifest-path server-rs/Cargo.toml
|
|
||||||
cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml
|
|
||||||
npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps generation progress visible"
|
|
||||||
npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "compile_puzzle_draft"
|
|
||||||
```
|
|
||||||
|
|
||||||
本地 smoke:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GENARRATIVE_PROCESS_ROLE=all npm run dev
|
|
||||||
curl -f http://127.0.0.1:<api-port>/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。
|
|
||||||
|
|
||||||
生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。
|
|
||||||
|
|
||||||
systemd 生产 controller 与手动兜底示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl enable --now genarrative-external-generation-worker@1.service
|
|
||||||
systemctl enable --now genarrative-external-generation-controller.service
|
|
||||||
systemctl start genarrative-external-generation-worker@2.service
|
|
||||||
systemctl stop genarrative-external-generation-worker@2.service
|
|
||||||
systemctl status genarrative-external-generation-controller.service 'genarrative-external-generation-worker@*.service'
|
|
||||||
```
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# 本地 SSH 服务器管理面板技术方案
|
|
||||||
|
|
||||||
日期:`2026-06-11`
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
release / dev 等服务器的日常巡检已经有 `genarrative-health-patrol.timer`、`/readyz`、`/healthz`、SpacetimeDB `/v1/ping` 和 systemd 状态文件,但开发者本地仍需要在多个 SSH alias 之间切换命令。服务器管理面板用于把这些只读巡检和少量 systemd 服务操作收敛到一个本地桌面入口。
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
- 使用 Rust `egui` / `eframe` 实现本地桌面面板,不接入线上 Web 后台,不暴露公网端口。
|
|
||||||
- 从本机 `~/.ssh/config` 的 `Host` alias 发现服务器;只展示不含通配符的 alias。
|
|
||||||
- 支持多个服务器,左侧服务器侧边栏可收起。
|
|
||||||
- 主面板展示硬件状态、服务状态、HTTP 健康探测和生产健康巡检状态。
|
|
||||||
- 支持对允许的 systemd unit 执行启动、关闭、重启。
|
|
||||||
|
|
||||||
## 命令入口
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run server-manager:panel
|
|
||||||
```
|
|
||||||
|
|
||||||
等价于:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
面板启动时会自动查找本机中文字体并注入 egui 字体集,优先使用 `Noto Sans CJK SC`,其次使用文泉驿 / Droid fallback。若某台开发机字体路径特殊,可用 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指定;普通 `.ttf` 可省略 `|index`。
|
|
||||||
|
|
||||||
## SSH 约定
|
|
||||||
|
|
||||||
本地 `~/.ssh/config` 中需要存在类似:
|
|
||||||
|
|
||||||
```sshconfig
|
|
||||||
Host dev
|
|
||||||
HostName 10.2.0.10
|
|
||||||
User genarrative
|
|
||||||
|
|
||||||
Host release
|
|
||||||
HostName genarrative.world
|
|
||||||
User genarrative
|
|
||||||
```
|
|
||||||
|
|
||||||
面板通过 `ssh <alias> sh -s` 执行远端只读巡检脚本。服务操作使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -n systemctl <start|stop|restart> <unit>
|
|
||||||
```
|
|
||||||
|
|
||||||
若 SSH 用户是 root,则直接执行 `systemctl`。非 root 用户需要提前配置只允许目标 unit 的无密码 sudo;否则面板会显示 sudo 权限错误,不会弹出交互密码输入。
|
|
||||||
|
|
||||||
## 健康检查内容
|
|
||||||
|
|
||||||
只读巡检覆盖:
|
|
||||||
|
|
||||||
- 主机名、内核、运行时长、CPU 核数 / 型号、load average。
|
|
||||||
- 内存 / swap 使用情况。
|
|
||||||
- `/`、`/var`、`/opt`、`/stdb`、`/data` 中存在路径的磁盘使用率。
|
|
||||||
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service`、`genarrative-health-patrol.timer`、`genarrative-database-backup.timer` 的 systemd 状态。
|
|
||||||
- `http://127.0.0.1:8082/healthz`、`/readyz`、`http://127.0.0.1:3101/v1/ping` 和代表性公开接口。
|
|
||||||
- `/var/lib/genarrative/health-patrol/status.json` 的最近巡检状态。
|
|
||||||
- 若服务器安装了 `sensors`,附带温度 / 风扇等硬件传感器摘要。
|
|
||||||
|
|
||||||
## 服务操作安全边界
|
|
||||||
|
|
||||||
面板只允许 `start`、`stop`、`restart` 三种动作,并且 unit 名必须匹配安全字符集:
|
|
||||||
|
|
||||||
```text
|
|
||||||
A-Z a-z 0-9 . _ - @ :
|
|
||||||
```
|
|
||||||
|
|
||||||
服务操作会先出现确认弹窗,避免误点。第一版默认列出 Genarrative 生产相关 unit,并提供“其他 unit”输入框;该输入框仍只会执行 `systemctl` 的三种受控动作,不提供任意命令执行入口。
|
|
||||||
|
|
||||||
## 状态判定
|
|
||||||
|
|
||||||
- service / HTTP 探测失败:`CRITICAL`。
|
|
||||||
- 磁盘使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
|
||||||
- 内存使用率 `>= 95%`:`CRITICAL`,`>= 85%`:`WARNING`。
|
|
||||||
- 生产健康巡检状态沿用 `OK / WARNING / CRITICAL`。
|
|
||||||
|
|
||||||
面板状态只是本地巡检视图,最终运维事实仍以服务器上的 systemd、journal、Nginx 日志、`production-health-patrol.mjs` 输出和现有部署文档为准。
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# SpacetimeDB 连接池租约 Drop 兜底与取消安全
|
|
||||||
|
|
||||||
- 日期:2026-06-11
|
|
||||||
- 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。
|
|
||||||
- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs`
|
|
||||||
|
|
||||||
## 故障根因
|
|
||||||
|
|
||||||
修复前的连接池存在两个叠加缺陷:
|
|
||||||
|
|
||||||
1. **租约没有 Drop 兜底**。`PooledConnectionLease` 只能通过显式 `release_connection` 归还。当 HTTP 请求方在等待 StDB 回包期间断开(前端超时、用户刷新、Nginx 截断),axum/hyper 会直接丢弃 handler future,租约被 Drop:permit 因 `OwnedSemaphorePermit` 自动归还,但槽位的 `in_use` 标记永远不会复位。
|
|
||||||
2. **acquire 在槽位泄漏后永久空转**。后续请求拿到 permit 后进入 `loop { 扫描槽位; yield_now }`,找不到空闲槽位就无限自旋,且这段自旋不受 `procedure_timeout` 约束,自旋期间 permit 不归还。
|
|
||||||
|
|
||||||
叠加效果:StDB 一旦变慢(请求占用连接接近 45 秒),客户端取消请求的概率大增,每次取消泄漏一个槽位并连带吞掉一个 permit;泄漏数量达到 `pool_size`(release 为 8)后,所有业务请求与健康检查全部在 `pool_acquire` 阶段 45 秒超时,服务表现为"连不上 StDB",只有重启能恢复。
|
|
||||||
|
|
||||||
## 本地复现
|
|
||||||
|
|
||||||
不需要真实 SpacetimeDB,单元测试即可复现机制(位于 `spacetime-client` tests 模块):
|
|
||||||
|
|
||||||
- 修复前:将一个槽位置为 `in_use=true` 后调用 `acquire_connection_with_timeout(200ms)`,acquire 在 5 秒守护窗口内不返回(永久自旋),测试红。
|
|
||||||
- `dropped_lease_releases_slot_and_permit`:模拟"请求被取消、租约未经 release 直接 Drop",断言槽位与 permit 都被复位归还。
|
|
||||||
- `acquire_times_out_at_pool_acquire_when_pool_is_busy`:池内 permit 全部被占用时,acquire 必须在超时窗口内返回 `PoolAcquire + Timeout`,不允许无限等待。
|
|
||||||
|
|
||||||
## 修复方案
|
|
||||||
|
|
||||||
1. `PooledConnectionSlot` 改为 `in_use: AtomicBool + connection: Mutex<Option<PooledConnection>>`,槽位占用标记不再依赖异步锁。
|
|
||||||
2. `PooledConnectionLease` 持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop`:无论显式归还还是 future 被取消,统一在 Drop 中复位槽位、按 broken 状态决定连接是否回池,permit 随后自动归还。Drop 体先复位 `in_use` 再释放 permit(字段在 Drop 体之后析构),保证新请求拿到 permit 时必有空闲槽位。
|
|
||||||
3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。
|
|
||||||
4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。
|
|
||||||
|
|
||||||
## 验收
|
|
||||||
|
|
||||||
- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(35 通过,含上述新测试)
|
|
||||||
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz`(2 通过)
|
|
||||||
- `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
|
||||||
|
|
||||||
## 运维提示
|
|
||||||
|
|
||||||
- 此修复解决的是"取消导致的永久泄漏"。StDB 真慢时仍会出现成批 45 秒超时(连接被在途请求合法占用),那是容量/上游问题,应结合 `GENARRATIVE_SPACETIME_POOL_SIZE` 与 StDB 负载排查,不要再怀疑池泄漏。
|
|
||||||
- 健康检查 `/readyz` 在池被在途请求占满时仍可能短暂 503(stage=pool_acquire),恢复后自动转好,无需重启。
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# server-rs 与 SpacetimeDB 数据契约
|
# server-rs 与 SpacetimeDB 数据契约
|
||||||
|
|
||||||
更新时间:`2026-06-12`
|
更新时间:`2026-06-10`
|
||||||
|
|
||||||
## 后端主线
|
## 后端主线
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ server-rs + Axum + SpacetimeDB
|
|||||||
|
|
||||||
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
`server-rs/Cargo.toml` 是 workspace 事实源。默认构建成员为 `crates/api-server`;第三方依赖版本和 workspace 内 crate path 统一放在 `[workspace.dependencies]`。
|
||||||
|
|
||||||
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.5.0`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `server-rs/Cargo.toml` 锁定版本对齐,避免 BSATN / procedure result 反序列化错配。遇到版本不匹配时,不继续沿着业务超时排查,先把 CLI / standalone 直接升级到锁定版本并重启后再重试。
|
SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`、`spacetimedb-lib` 统一锁定 `2.4.1`;本地 `spacetime` CLI / standalone、生成的 `spacetime-client` bindings 和容器压测镜像也必须与 `2.4.1` 对齐,避免 BSATN / procedure result 反序列化错配。
|
||||||
|
|
||||||
当前主要 crate:
|
当前主要 crate:
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ npm run check:server-rs-ddd
|
|||||||
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
||||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt 和初始资产就绪校验。
|
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||||
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||||
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||||
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||||
@@ -125,14 +125,11 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
|
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求,应由前端在登录态下继续携带账号 access token;匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。
|
||||||
|
|
||||||
公开正式 runtime 的启动与局内同步动作统一接受 `RuntimePrincipal`,包括拼图、拼消消、跳一跳、敲木鱼、抓大鹅 Match3D、方洞挑战、视觉小说、大鱼吃小鱼和汪汪声浪。登录用户仍使用账号 Bearer;未登录推荐页或公开运行态使用 Runtime Guest Token,后端以 `principal.subject()` 作为本局 owner / player subject,并用 `WorkPlayTrackingDraft::runtime_principal(...)` 记录游玩。创作、个人作品、删除、发布、Remix、点赞等账号或所有权动作不得改成 runtime guest 鉴权。
|
|
||||||
|
|
||||||
抓大鹅 Match3D `api-server` 内部拆分:
|
抓大鹅 Match3D `api-server` 内部拆分:
|
||||||
|
|
||||||
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
||||||
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
||||||
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
||||||
- `/api/runtime/match3d/works/{profile_id}/runs`、`/api/runtime/match3d/runs/{run_id}`、`/click`、`/stop`、`/restart` 与 `/time-up` 属于正式运行态局部请求,必须接受 `RuntimePrincipal`;登录用户使用账号 Bearer,推荐页匿名游客使用 runtime guest token,后端以 principal subject 作为本局 owner,不得退回只认普通 Bearer 的路由。
|
|
||||||
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
||||||
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
||||||
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
|
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。
|
||||||
@@ -190,10 +187,6 @@ npm run check:server-rs-ddd
|
|||||||
1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。
|
1. `creation_entry_type_config.unified_creation_spec_json` 内的 `mudPointCost` 是玩法新建草稿初始生成的泥点成本真相源,同时供入口卡展示和前端余额前置校验使用;旧契约缺失时允许按代码默认成本兜底。
|
||||||
2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。
|
2. `api-server` 执行拼图首图生成、抓大鹅完整草稿生成和汪汪声浪初始三图生成时,必须通过 `GET /api/creation-entry/config` 同源配置解析对应玩法成本后再调用钱包扣费 procedure,不得继续使用前端或后端硬编码常量作为实际扣费真相。
|
||||||
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
|
3. 结果页单图重生成、发布、道具使用和其它独立资产操作仍按各自业务操作成本执行;不要把初始草稿成本误套到这些单次操作上。
|
||||||
4. 资产操作的预扣费必须 fail-closed:钱包或 SpacetimeDB 预扣费不可达、超时或返回业务错误时,`api-server` 直接返回错误,不允许继续调用图片、音频、GLB 等外部生成 provider。
|
|
||||||
5. 需要支持 HTTP retry 的计费 ledger id 必须包含当前请求的 `request_id`;前端 `fetchWithApiAuth` 同一次业务请求的静默刷新重试复用同一个 `x-request-id`,后端不得再使用 prompt 指纹或随机 asset id 作为扣费幂等键。
|
|
||||||
6. 外部生成已预扣费但后续失败时必须先同步调用钱包退款;若 SpacetimeDB 暂不可用,退款请求写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。默认启用,配置项为 `GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED`、`GENARRATIVE_WALLET_REFUND_OUTBOX_DIR`、`GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE`、`GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS` 和 `GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES`。outbox 文件按 refund ledger id 幂等落盘;成功重放后删除,坏文件隔离为 `corrupt-*`。
|
|
||||||
7. 拼图首图后台生成的跨实例互斥锁必须落在 SpacetimeDB `puzzle_background_compile_task` 表,claim id 由 `task_id + request_id` 构成,释放时必须校验 claim id,避免旧后台任务释放新请求抢到的租约。
|
|
||||||
|
|
||||||
## 外部服务与资产
|
## 外部服务与资产
|
||||||
|
|
||||||
@@ -233,12 +226,6 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`AiTaskStage`
|
- Rust 结构体:`AiTaskStage`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/ai/stages.rs`
|
||||||
|
|
||||||
### `external_generation_job`
|
|
||||||
|
|
||||||
- Rust 结构体:`ExternalGenerationJob`
|
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
|
||||||
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
|
||||||
|
|
||||||
### `ai_text_chunk`
|
### `ai_text_chunk`
|
||||||
|
|
||||||
- Rust 结构体:`AiTextChunk`
|
- Rust 结构体:`AiTextChunk`
|
||||||
@@ -650,12 +637,6 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`PuzzleAgentSessionRow`
|
- Rust 结构体:`PuzzleAgentSessionRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
|
||||||
### `puzzle_background_compile_task`
|
|
||||||
|
|
||||||
- Rust 结构体:`PuzzleBackgroundCompileTaskRow`
|
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
|
||||||
- 说明:拼图首图后台生成的跨 api-server 实例互斥 claim 表,只保存活动任务租约,不表达最终生成结果;`task_id` 为主键,`claim_id` 用于释放时防止误删新租约,租约超时时间为 30 分钟。
|
|
||||||
|
|
||||||
### `puzzle_event`
|
### `puzzle_event`
|
||||||
|
|
||||||
- Rust 结构体:`PuzzleEvent`
|
- Rust 结构体:`PuzzleEvent`
|
||||||
@@ -675,8 +656,6 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`PuzzleWorkProfileRow`
|
- Rust 结构体:`PuzzleWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
- 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。
|
|
||||||
- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime;默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。
|
|
||||||
|
|
||||||
### `puzzle_clear_agent_session`
|
### `puzzle_clear_agent_session`
|
||||||
|
|
||||||
@@ -722,14 +701,14 @@ npm run check:server-rs-ddd
|
|||||||
- Rust view:`puzzle_gallery_view`
|
- Rust view:`puzzle_gallery_view`
|
||||||
- 返回类型:`Vec<PuzzleWorkProfile>`
|
- 返回类型:`Vec<PuzzleWorkProfile>`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 且 `visible = true` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
|
- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
|
||||||
|
|
||||||
### SpacetimeDB view:`puzzle_gallery_card_view`
|
### SpacetimeDB view:`puzzle_gallery_card_view`
|
||||||
|
|
||||||
- Rust view:`puzzle_gallery_card_view`
|
- Rust view:`puzzle_gallery_card_view`
|
||||||
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
|
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
- 说明:拼图公开列表 source 投影,只暴露 `publication_status = Published` 且 `visible = true` 的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。
|
- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。
|
||||||
|
|
||||||
### 拼图公开列表 HTTP 窗口缓存
|
### 拼图公开列表 HTTP 窗口缓存
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# 本地开发验证与生产运维
|
# 本地开发验证与生产运维
|
||||||
|
|
||||||
更新时间:`2026-06-12`
|
更新时间:`2026-06-09`
|
||||||
|
|
||||||
## 标准开发流程
|
## 标准开发流程
|
||||||
|
|
||||||
```text
|
```text
|
||||||
同步代码 -> 读 AGENTS.md / docs/project-memory 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / project-memory -> 提交
|
同步代码 -> 读 AGENTS.md / .hermes 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / .hermes -> 提交
|
||||||
```
|
```
|
||||||
|
|
||||||
如果当前文档不足以指导编码,先补文档再落地工程修改。
|
如果当前文档不足以指导编码,先补文档再落地工程修改。
|
||||||
@@ -51,14 +51,6 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
|
|||||||
|
|
||||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
||||||
|
|
||||||
本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。
|
|
||||||
|
|
||||||
本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部图片生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`。非外部图片生成动作继续 inline,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
|
|
||||||
|
|
||||||
`我的` 页签或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。生成页 / 进度页不承接队列概览,只展示当前玩法业务进度;队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。
|
|
||||||
|
|
||||||
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。
|
|
||||||
|
|
||||||
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
|
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
|
||||||
|
|
||||||
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||||
@@ -77,7 +69,7 @@ spacetime sql <database> "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
|
|||||||
|
|
||||||
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
|
本地 `npm run dev:spacetime` 发布模块时必须显式忽略仓库根目录的 `spacetime.json`,由脚本固定追加 `--no-config` 并使用命令参数里传入的数据库名和 `--server http://127.0.0.1:3101`。否则 CLI 可能把发布目标改写到配置文件里的其他数据库,导致 `dev:spacetime` 启动后又因发布失败自动退出,浏览器随后会在 `ws://127.0.0.1:3101/v1/database/.../subscribe` 看到连接拒绝。
|
||||||
|
|
||||||
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.5.0`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;遇到版本不匹配时不要继续深挖业务超时,直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.4.1`。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;需要切版本时执行 `spacetime version install <version> && spacetime version use <version>`,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。
|
||||||
|
|
||||||
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。
|
||||||
|
|
||||||
@@ -141,8 +133,6 @@ npm run spacetime:generate
|
|||||||
|
|
||||||
项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。
|
项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。
|
||||||
|
|
||||||
项目文档 RAG 索引使用 `scripts/rag/` 下的脚本和本地 `.rag/` 运行时目录,主要供 Agent 检索项目上下文,不作为人工阅读入口。默认不安装 RAG 相关依赖,不把 LanceDB、Transformers.js 或本地 embedding 模型写入根 `package.json`;需要启用时,Agent 必须先询问用户是否安装,并在用户确认后只安装到 gitignored 的 `.rag/runtime/`。索引范围默认包含 `AGENTS.md`、`CONTEXT.md`、`docs/project-memory/` 和 `docs/`,不把 `.hermes/` 工具目录作为项目知识库索引源。
|
|
||||||
|
|
||||||
首次拉取或需要重建索引时:
|
首次拉取或需要重建索引时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -212,7 +202,7 @@ UI 相关修改要重点验证:
|
|||||||
|
|
||||||
## SpacetimeDB 操作规则
|
## SpacetimeDB 操作规则
|
||||||
|
|
||||||
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`;CI/CD 脚本内部为隔离运行用户登录态的受控用法例外,但不得写成手工排障命令。
|
1. 不在人工命令、本地联调或文档示例中使用 `spacetime --root-dir`。
|
||||||
2. 本地开发使用项目脚本维护数据目录;需要清空本地数据时先确认可丢弃,再停止服务并处理本地数据目录。
|
2. 本地开发使用项目脚本维护数据目录;需要清空本地数据时先确认可丢弃,再停止服务并处理本地数据目录。
|
||||||
3. 发布目标必须显式 `--server` / `--server-url`。
|
3. 发布目标必须显式 `--server` / `--server-url`。
|
||||||
4. 身份问题先查 `spacetime login show`、`spacetime server list` 和目标库权限,不通过切回旧 Node / PostgreSQL 绕过。
|
4. 身份问题先查 `spacetime login show`、`spacetime server list` 和目标库权限,不通过切回旧 Node / PostgreSQL 绕过。
|
||||||
@@ -222,7 +212,7 @@ UI 相关修改要重点验证:
|
|||||||
|
|
||||||
### SpacetimeDB 数据目录 OSS 备份
|
### SpacetimeDB 数据目录 OSS 备份
|
||||||
|
|
||||||
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为 `scripts/database-backup-to-oss.mjs`(npm 命令 `npm run database:backup:oss`);生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service
|
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service --restart-service-after genarrative-api.service
|
||||||
@@ -243,7 +233,7 @@ GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
|||||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||||
```
|
```
|
||||||
|
|
||||||
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。如果 timer 显示 `enabled` 但 `inactive/dead` 且 `NEXT` / `Trigger` 为空,先写入当前 stamp 避免 `Persistent=true` 在白天立刻补跑冷备份:`touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer && systemctl daemon-reload && systemctl start genarrative-database-backup.timer`,随后确认下一次触发时间约为次日 `03:20`。
|
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。
|
||||||
|
|
||||||
冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出:
|
冷备份后必须做一次只读验收,不要只看 `genarrative-database-backup.service` 是否成功退出:
|
||||||
|
|
||||||
@@ -270,12 +260,11 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
|||||||
|
|
||||||
`Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查:
|
`Genarrative-Server-Provision` 会安装并启用 `genarrative-health-patrol.timer`,默认每 5 分钟运行一次 `genarrative-health-patrol.service`。巡检脚本随 API release 归档到 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs`,只读检查:
|
||||||
|
|
||||||
- `genarrative-api.service`、`genarrative-external-generation-controller.service`、`spacetimedb.service`、`nginx.service` 是否 active。
|
- `genarrative-api.service`、`spacetimedb.service`、`nginx.service` 是否 active。
|
||||||
- 至少一个 `genarrative-external-generation-worker@*.service` 实例是否 active;如果 controller 存活但 worker 全部退出,巡检直接返回 `CRITICAL`,避免外部生成队列长期无人消费。
|
|
||||||
- API 直连 `/healthz`、`/readyz`。
|
- API 直连 `/healthz`、`/readyz`。
|
||||||
- SpacetimeDB 直连 `/v1/ping`。
|
- SpacetimeDB 直连 `/v1/ping`。
|
||||||
- 默认直连 API 端口检查 `/api/creation-entry/config`、`/api/runtime/puzzle/gallery`、`/api/runtime/custom-world-gallery`;如需走 Nginx / 公网域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。
|
- 默认直连 API 端口检查 `/api/creation-entry/config`、`/api/runtime/puzzle/gallery`、`/api/runtime/custom-world-gallery`;如需走 Nginx / 公网域名,在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_BASE_URL=https://<域名>`。
|
||||||
- 最近 15 分钟 `genarrative-api.service`、`genarrative-external-generation-controller.service`、`genarrative-external-generation-worker@*.service`、`spacetimedb.service`、`nginx.service` 的 `err..alert` 日志。
|
- 最近 15 分钟 `genarrative-api.service`、`spacetimedb.service`、`nginx.service` 的 `err..alert` 日志。
|
||||||
|
|
||||||
巡检输出总状态 `OK / WARNING / CRITICAL`;只有 `CRITICAL` 默认让 systemd service 失败,`WARNING` 只写日志和状态文件,避免历史日志噪声把 timer 长期打成失败。最近一次结果写入 `/var/lib/genarrative/health-patrol/status.json`。手动执行:
|
巡检输出总状态 `OK / WARNING / CRITICAL`;只有 `CRITICAL` 默认让 systemd service 失败,`WARNING` 只写日志和状态文件,避免历史日志噪声把 timer 长期打成失败。最近一次结果写入 `/var/lib/genarrative/health-patrol/status.json`。手动执行:
|
||||||
|
|
||||||
@@ -313,9 +302,7 @@ dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/
|
|||||||
|
|
||||||
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
||||||
|
|
||||||
`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job`。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部图片生成动作覆盖拼图、跳一跳、拼消消和敲木鱼,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。
|
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
|
||||||
|
|
||||||
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential`、`ca-certificates`、`curl`、`perl`、`tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
|
|
||||||
|
|
||||||
50 HTTP req/s 首版压测优化口径:
|
50 HTTP req/s 首版压测优化口径:
|
||||||
|
|
||||||
@@ -324,15 +311,15 @@ dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/
|
|||||||
- `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
|
- `api-server` 正常运行时 `/healthz` 只返回进程存活状态,`/readyz` 会同时检查进程是否仍接收新流量和 SpacetimeDB 连接租约是否健康;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。
|
||||||
- SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。
|
- SpacetimeDB 健康检查默认使用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS=2` 的短等待窗口,和业务 procedure 的 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 分开。`/readyz` 失败时 `details.spacetime.stage` 会标出当前卡住阶段:`pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result` 或 `read_cache`;`elapsedMs` / `timeoutMs` 用于确认是否命中健康检查窗口。业务请求日志也会写入 `operation_kind`、`operation_name`、`spacetime_stage` 和 `elapsed_ms`,后续 45 秒超时不再只靠 Nginx `request_time=45s` 推断。
|
||||||
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
||||||
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.5.0` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内先检查 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current`:版本已满足时直接复用目标机现有文件生成 `provision-tools/`,只有缺失或版本不匹配时才使用 `PROVISION_DOWNLOADS_DIR` 里的本地包或从配置的下载源准备 SpacetimeDB `2.4.1` / `otelcol-contrib 0.151.0`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。
|
||||||
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。
|
- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。
|
||||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。该服务必须存在系统用户 / 组 `otelcol`,并且 `/etc/otelcol/genarrative-debug.yaml` 已安装到目标机;若看到 `status=217/USER` 或 `Failed to determine user credentials`,优先检查 `getent passwd otelcol`,再补齐 `/etc/otelcol` 配置目录并重启服务。
|
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
||||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||||
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
||||||
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
||||||
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
||||||
|
|
||||||
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额;容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,可用 `npm run container:up -- --scale external-generation-worker=N external-generation-worker` 验证外部生成 worker 动态扩缩容,`inline` 模式不参与该验证:
|
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run container:init
|
npm run container:init
|
||||||
@@ -345,7 +332,6 @@ npm run container:down
|
|||||||
|
|
||||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧现在由目标 dev / release agent 自己准备 `provision-tools/otelcol-contrib`,并安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||||
隔离验证 worker 队列和 API-only 更新时使用 `npm run container:worker-smoke -- smoke`。该命令不复用 `deploy/container/api-server.env`,会在 `deploy/container/worker-smoke/` 生成本机专用 env 与端口 state,并使用 unsupported job 验证 worker claim / fail 回写,不需要真实外部生成密钥;本机 crates.io 网络不稳时使用 `--local-binary`,由容器内 Cargo 复用本机 Cargo 缓存构建,并把产物放进 Debian bookworm smoke runtime。
|
|
||||||
|
|
||||||
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
||||||
|
|
||||||
@@ -424,7 +410,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
|
|||||||
- `profile_task_reward_claim`
|
- `profile_task_reward_claim`
|
||||||
- `profile_wallet_ledger`
|
- `profile_wallet_ledger`
|
||||||
|
|
||||||
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。认证成功后的 `daily_login` 必须通过 `SpacetimeClient::record_daily_login_tracking_event(...)` 调用 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return` procedure,由数据库事务时间生成当日幂等事件并推进任务进度;不要改回普通 `record_tracking_event_after_success`、tracking outbox 或旧 `profile.login.daily` 事件键。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
||||||
|
|
||||||
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
|
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts`
|
|||||||
|
|
||||||
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。
|
平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。
|
||||||
|
|
||||||
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。外部生成队列的用户可见概览统一放在移动端一级 `我的` 页签,生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作;用户离开生成页后仍可在 `我的` 页查看当前账号可见的排队与生成数量。队列概览只作为等待状态补充,草稿 ready / failed 与作品结果仍以后端玩法 session/detail 回读为准。
|
生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。
|
||||||
|
|
||||||
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。
|
||||||
|
|
||||||
@@ -146,14 +146,12 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
|
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
|
||||||
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
||||||
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
|
||||||
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,正式 runtime 启动与后续局内动作继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。平台壳统一通过 `buildRecommendRuntimeRequestOptions(...)` 为各玩法的 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作生成局部 request options,不允许每个玩法各写一套匿名分支。后端 `/api/runtime/*` 正式运行态写请求统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
|
||||||
- 推荐页作品队列只能通过 `buildPlatformRecommendFeedEntries(...)` 生成,首页卡片窗口、桌面推荐格、嵌入 runtime 自动启动和上一条 / 下一条切换都必须消费同一队列。不得在首页和 `PlatformEntryFlowShellImpl` 内分别按“最新列表顺序”和“评分推荐顺序”各算一套相邻作品,否则连续切换会出现视觉上跳过作品或回跳。
|
|
||||||
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的 H5 分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。微信小程序 WebView 内复制动作必须改为小程序 `pages/web-view/index` 路径并补齐 `targetPath=/works/detail` 与 `work` 参数。推荐页当前 active 作品必须通过 `wx.miniProgram.postMessage` 同步给原生 `web-view` 页,让右上角系统“转发给朋友”和“分享到朋友圈”也使用当前作品参数生成小程序短链背后的 path。微信小程序 WebView 内的推荐页运行态需要启用分享快照安全区,把游戏画面等比缩放并保持在页面中部,避免用户直接点击小程序自带“分享到聊天”时只截到游戏画面局部。
|
|
||||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||||
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
||||||
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo-runtime-hud.webp` 卡通形象小图;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
|
||||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||||
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
|
||||||
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
|
||||||
@@ -306,7 +304,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。
|
- 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。
|
||||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。
|
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。
|
||||||
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
|
- 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。
|
||||||
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo-runtime-hud.webp` 产品 logo 小图;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。
|
||||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||||
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||||
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ server-rs + Axum + SpacetimeDB
|
|||||||
|
|
||||||
- Issue tracker 是自托管 Gitea。可用 Gitea UI/API 或 `tea` CLI;不要用 GitHub `gh` 或 GitLab `glab`。
|
- Issue tracker 是自托管 Gitea。可用 Gitea UI/API 或 `tea` CLI;不要用 GitHub `gh` 或 GitLab `glab`。
|
||||||
- 默认 triage labels:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
- 默认 triage labels:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||||
- 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。
|
- 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `.hermes/shared-memory/decision-log.md` 的最新稳定摘要为准。
|
||||||
- `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。
|
- `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。
|
||||||
- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`。
|
- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `.hermes/shared-memory/`。
|
||||||
|
|
||||||
## 当前文档策略
|
## 当前文档策略
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pipeline {
|
|||||||
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
|
string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录')
|
||||||
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
|
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录')
|
||||||
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理')
|
||||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
|
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址;默认固定到项目锁定版本')
|
||||||
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
|
string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值')
|
||||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||||
@@ -162,7 +162,7 @@ BASH
|
|||||||
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
||||||
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
||||||
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
|
PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \
|
||||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}" \
|
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1}" \
|
||||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \
|
||||||
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
|
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" \
|
||||||
scripts/prepare-server-provision-tools.sh
|
scripts/prepare-server-provision-tools.sh
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/web-view/index",
|
"pages/web-view/index",
|
||||||
"pages/share-grid/index",
|
|
||||||
"pages/wechat-pay/index",
|
"pages/wechat-pay/index",
|
||||||
"pages/subscribe-message/index"
|
"pages/subscribe-message/index"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
/* global Page, wx */
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
|
|
||||||
const {
|
|
||||||
buildShareGridTileFileName,
|
|
||||||
buildShareGridTilePlan,
|
|
||||||
normalizeShareGridQuery,
|
|
||||||
} = require('./index.shared');
|
|
||||||
|
|
||||||
function downloadImage(imageUrl) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.downloadFile({
|
|
||||||
url: imageUrl,
|
|
||||||
success(response) {
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
||||||
resolve(response.tempFilePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(new Error(`封面下载失败:${response.statusCode}`));
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
reject(new Error(error.errMsg || '封面下载失败'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageInfo(src) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.getImageInfo({
|
|
||||||
src,
|
|
||||||
success: resolve,
|
|
||||||
fail(error) {
|
|
||||||
reject(new Error(error.errMsg || '读取封面失败'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvasNode(page) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.createSelectorQuery()
|
|
||||||
.in(page)
|
|
||||||
.select('#share-grid-canvas')
|
|
||||||
.fields({ node: true, size: true })
|
|
||||||
.exec((results) => {
|
|
||||||
const canvas = results && results[0] && results[0].node;
|
|
||||||
if (canvas) {
|
|
||||||
resolve(canvas);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(new Error('切图画布初始化失败'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasToTempFilePath(canvas, width, height) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.canvasToTempFilePath({
|
|
||||||
canvas,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
destWidth: width,
|
|
||||||
destHeight: height,
|
|
||||||
fileType: 'png',
|
|
||||||
success(response) {
|
|
||||||
resolve(response.tempFilePath);
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
reject(new Error(error.errMsg || '导出切图失败'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveImageToAlbum(filePath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.saveImageToPhotosAlbum({
|
|
||||||
filePath,
|
|
||||||
success() {
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
fail(error) {
|
|
||||||
reject(new Error(error.errMsg || '保存到相册失败'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyTempFileWithName(tempFilePath, fileName) {
|
|
||||||
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
|
|
||||||
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
|
|
||||||
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
|
|
||||||
return Promise.resolve(tempFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = `${userDataPath}/${fileName}`;
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
fileSystem.copyFile({
|
|
||||||
srcPath: tempFilePath,
|
|
||||||
destPath: targetPath,
|
|
||||||
success() {
|
|
||||||
resolve(targetPath);
|
|
||||||
},
|
|
||||||
fail() {
|
|
||||||
resolve(tempFilePath);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGridTiles(page, params, localImagePath, imageInfo) {
|
|
||||||
const canvas = await getCanvasNode(page);
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
const image = canvas.createImage();
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = () => reject(new Error('封面绘制失败'));
|
|
||||||
image.src = localImagePath;
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
|
|
||||||
for (const tile of plan) {
|
|
||||||
canvas.width = tile.sourceWidth;
|
|
||||||
canvas.height = tile.sourceHeight;
|
|
||||||
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
|
|
||||||
context.drawImage(
|
|
||||||
image,
|
|
||||||
tile.sourceX,
|
|
||||||
tile.sourceY,
|
|
||||||
tile.sourceWidth,
|
|
||||||
tile.sourceHeight,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
tile.sourceWidth,
|
|
||||||
tile.sourceHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tempFilePath = await canvasToTempFilePath(
|
|
||||||
canvas,
|
|
||||||
tile.sourceWidth,
|
|
||||||
tile.sourceHeight,
|
|
||||||
);
|
|
||||||
const namedFilePath = await copyTempFileWithName(
|
|
||||||
tempFilePath,
|
|
||||||
buildShareGridTileFileName(params, tile.index),
|
|
||||||
);
|
|
||||||
await saveImageToAlbum(namedFilePath);
|
|
||||||
page.setData({
|
|
||||||
savedCount: tile.index + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
errorMessage: '',
|
|
||||||
loading: true,
|
|
||||||
savedCount: 0,
|
|
||||||
title: '九宫切图',
|
|
||||||
},
|
|
||||||
|
|
||||||
async onLoad(query = {}) {
|
|
||||||
const params = normalizeShareGridQuery(query);
|
|
||||||
this._shareGridParams = params;
|
|
||||||
this.setData({
|
|
||||||
errorMessage: '',
|
|
||||||
loading: true,
|
|
||||||
savedCount: 0,
|
|
||||||
title: params.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!params.imageUrl) {
|
|
||||||
this.setData({
|
|
||||||
errorMessage: '缺少封面图。',
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localImagePath = await downloadImage(params.imageUrl);
|
|
||||||
const imageInfo = await getImageInfo(localImagePath);
|
|
||||||
await saveGridTiles(this, params, localImagePath, imageInfo);
|
|
||||||
this.setData({
|
|
||||||
loading: false,
|
|
||||||
savedCount: 9,
|
|
||||||
});
|
|
||||||
wx.showToast({
|
|
||||||
title: '已保存',
|
|
||||||
icon: 'success',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[share-grid] save failed', error);
|
|
||||||
this.setData({
|
|
||||||
errorMessage:
|
|
||||||
error && error.message ? error.message : '九宫切图保存失败。',
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
handleBack() {
|
|
||||||
wx.navigateBack();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "九宫切图"
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
const GRID_SIZE = 3;
|
|
||||||
const TILE_COUNT = GRID_SIZE * GRID_SIZE;
|
|
||||||
|
|
||||||
function normalizeQueryValue(value) {
|
|
||||||
return String(value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFileNamePart(value) {
|
|
||||||
const normalized = normalizeQueryValue(value)
|
|
||||||
.replace(/[\\/:*?"<>|]/g, '')
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.slice(0, 32);
|
|
||||||
return normalized || 'taonier';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShareGridTileFileName(params, tileIndex) {
|
|
||||||
const safeTitle = sanitizeFileNamePart(params.title || params.publicWorkCode);
|
|
||||||
const safeCode = sanitizeFileNamePart(params.publicWorkCode || 'share');
|
|
||||||
const order = String(tileIndex + 1).padStart(2, '0');
|
|
||||||
return `${safeTitle}-${safeCode}-${order}.png`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeShareGridQuery(query) {
|
|
||||||
return {
|
|
||||||
imageUrl: normalizeQueryValue(query && query.imageUrl),
|
|
||||||
title: normalizeQueryValue(query && query.title) || '我的作品',
|
|
||||||
publicWorkCode: normalizeQueryValue(query && query.publicWorkCode),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShareGridTilePlan(imageWidth, imageHeight) {
|
|
||||||
const tileWidth = Math.floor(imageWidth / GRID_SIZE);
|
|
||||||
const tileHeight = Math.floor(imageHeight / GRID_SIZE);
|
|
||||||
const plan = [];
|
|
||||||
|
|
||||||
for (let row = 0; row < GRID_SIZE; row += 1) {
|
|
||||||
for (let col = 0; col < GRID_SIZE; col += 1) {
|
|
||||||
const index = row * GRID_SIZE + col;
|
|
||||||
const sourceX = col * tileWidth;
|
|
||||||
const sourceY = row * tileHeight;
|
|
||||||
plan.push({
|
|
||||||
index,
|
|
||||||
row,
|
|
||||||
col,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
sourceWidth: col === GRID_SIZE - 1 ? imageWidth - sourceX : tileWidth,
|
|
||||||
sourceHeight: row === GRID_SIZE - 1 ? imageHeight - sourceY : tileHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
GRID_SIZE,
|
|
||||||
TILE_COUNT,
|
|
||||||
buildShareGridTileFileName,
|
|
||||||
buildShareGridTilePlan,
|
|
||||||
normalizeShareGridQuery,
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import shareGridBridge from './index.shared.js';
|
|
||||||
|
|
||||||
const {
|
|
||||||
buildShareGridTileFileName,
|
|
||||||
buildShareGridTilePlan,
|
|
||||||
normalizeShareGridQuery,
|
|
||||||
} = shareGridBridge;
|
|
||||||
|
|
||||||
describe('share-grid mini program bridge', () => {
|
|
||||||
test('normalizes query values and keeps a fallback title', () => {
|
|
||||||
expect(
|
|
||||||
normalizeShareGridQuery({
|
|
||||||
imageUrl: ' https://web.test/cover.png ',
|
|
||||||
publicWorkCode: ' PZ-0001 ',
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
imageUrl: 'https://web.test/cover.png',
|
|
||||||
title: '我的作品',
|
|
||||||
publicWorkCode: 'PZ-0001',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('names tiles by title, public code and left-to-right order', () => {
|
|
||||||
const params = {
|
|
||||||
title: '星港:拼图',
|
|
||||||
publicWorkCode: 'PZ-0001',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(buildShareGridTileFileName(params, 0)).toBe(
|
|
||||||
'星港拼图-PZ-0001-01.png',
|
|
||||||
);
|
|
||||||
expect(buildShareGridTileFileName(params, 8)).toBe(
|
|
||||||
'星港拼图-PZ-0001-09.png',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builds a 3x3 crop plan in reading order', () => {
|
|
||||||
const plan = buildShareGridTilePlan(900, 600);
|
|
||||||
|
|
||||||
expect(plan).toHaveLength(9);
|
|
||||||
expect(plan[0]).toMatchObject({
|
|
||||||
index: 0,
|
|
||||||
row: 0,
|
|
||||||
col: 0,
|
|
||||||
sourceX: 0,
|
|
||||||
sourceY: 0,
|
|
||||||
sourceWidth: 300,
|
|
||||||
sourceHeight: 200,
|
|
||||||
});
|
|
||||||
expect(plan[4]).toMatchObject({
|
|
||||||
index: 4,
|
|
||||||
row: 1,
|
|
||||||
col: 1,
|
|
||||||
sourceX: 300,
|
|
||||||
sourceY: 200,
|
|
||||||
});
|
|
||||||
expect(plan[8]).toMatchObject({
|
|
||||||
index: 8,
|
|
||||||
row: 2,
|
|
||||||
col: 2,
|
|
||||||
sourceX: 600,
|
|
||||||
sourceY: 400,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<view class="share-grid-page">
|
|
||||||
<view class="share-grid-card">
|
|
||||||
<view class="share-grid-title">{{title}}</view>
|
|
||||||
<view wx:if="{{loading}}" class="share-grid-text">
|
|
||||||
正在保存 {{savedCount}}/9
|
|
||||||
</view>
|
|
||||||
<view wx:elif="{{errorMessage}}" class="share-grid-text share-grid-text--danger">
|
|
||||||
{{errorMessage}}
|
|
||||||
</view>
|
|
||||||
<view wx:else class="share-grid-text">已保存 9/9</view>
|
|
||||||
<button class="share-grid-button" bindtap="handleBack">
|
|
||||||
返回
|
|
||||||
</button>
|
|
||||||
</view>
|
|
||||||
<canvas
|
|
||||||
id="share-grid-canvas"
|
|
||||||
type="2d"
|
|
||||||
class="share-grid-canvas"
|
|
||||||
></canvas>
|
|
||||||
</view>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
page {
|
|
||||||
background: #fffdf9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48rpx;
|
|
||||||
background: #fffdf9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1rpx solid rgba(127, 85, 57, 0.18);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
padding: 36rpx;
|
|
||||||
box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-title {
|
|
||||||
color: #332820;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-text {
|
|
||||||
margin-top: 18rpx;
|
|
||||||
color: rgba(51, 40, 32, 0.68);
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-text--danger {
|
|
||||||
color: #b84a3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-button {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
background: #7f5539;
|
|
||||||
color: #fffdf9;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 2.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-grid-canvas {
|
|
||||||
position: fixed;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,6 @@ const {
|
|||||||
WEB_VIEW_ENTRY_URL,
|
WEB_VIEW_ENTRY_URL,
|
||||||
WEB_VIEW_SOURCE_QUERY,
|
WEB_VIEW_SOURCE_QUERY,
|
||||||
} = require('../../config');
|
} = require('../../config');
|
||||||
const {
|
|
||||||
appendHashParams,
|
|
||||||
buildWebViewSharePath,
|
|
||||||
buildWebViewShareTimelineQuery,
|
|
||||||
resolveShareTargetFromWebViewMessage,
|
|
||||||
resolveWebViewUrlFromRuntimeConfig,
|
|
||||||
} = require('./index.shared');
|
|
||||||
|
|
||||||
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||||
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||||
@@ -26,6 +19,7 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result';
|
|||||||
const AUTH_ACTION_LOGIN = 'login';
|
const AUTH_ACTION_LOGIN = 'login';
|
||||||
const PAY_RESULT_RECHECK_DELAY_MS = 120;
|
const PAY_RESULT_RECHECK_DELAY_MS = 120;
|
||||||
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
|
const WEB_VIEW_SHARE_TITLE = '陶泥儿';
|
||||||
|
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
||||||
|
|
||||||
function showWebViewShareMenu() {
|
function showWebViewShareMenu() {
|
||||||
if (typeof wx.showShareMenu !== 'function') {
|
if (typeof wx.showShareMenu !== 'function') {
|
||||||
@@ -38,25 +32,17 @@ function showWebViewShareMenu() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNativeShareQuery(page) {
|
function buildWebViewShareAppMessage() {
|
||||||
return (
|
|
||||||
(page && page._currentShareTarget) ||
|
|
||||||
(page && page._lastLaunchQuery) ||
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWebViewShareAppMessage(query = {}) {
|
|
||||||
return {
|
return {
|
||||||
title: WEB_VIEW_SHARE_TITLE,
|
title: WEB_VIEW_SHARE_TITLE,
|
||||||
path: buildWebViewSharePath(query),
|
path: WEB_VIEW_SHARE_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWebViewShareTimeline(query = {}) {
|
function buildWebViewShareTimeline() {
|
||||||
return {
|
return {
|
||||||
title: WEB_VIEW_SHARE_TITLE,
|
title: WEB_VIEW_SHARE_TITLE,
|
||||||
query: buildWebViewShareTimelineQuery(query),
|
query: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +59,50 @@ function isConfiguredApiBaseUrl(value) {
|
|||||||
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendQuery(url, query) {
|
||||||
|
const pairs = Object.keys(query)
|
||||||
|
.filter((key) => query[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendHashParams(url, params) {
|
||||||
|
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
||||||
|
const pairs = Object.keys(params)
|
||||||
|
.filter((key) => params[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||||
|
);
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||||
|
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||||
|
const keptHashParts = rawHash.split('&').filter((part) => {
|
||||||
|
if (!part) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [rawKey = ''] = part.split('=');
|
||||||
|
try {
|
||||||
|
return !nextKeys.has(decodeURIComponent(rawKey));
|
||||||
|
} catch (_error) {
|
||||||
|
return !nextKeys.has(rawKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseBooleanQueryFlag(value) {
|
function parseBooleanQueryFlag(value) {
|
||||||
return value === true || value === '1' || value === 'true' || value === 'yes';
|
return value === true || value === '1' || value === 'true' || value === 'yes';
|
||||||
}
|
}
|
||||||
@@ -203,16 +233,22 @@ function shouldReturnToPreviousPage(query) {
|
|||||||
return String((query && query.returnTo) || '').trim() === 'previous';
|
return String((query && query.returnTo) || '').trim() === 'previous';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWebViewUrl(authResult, launchQuery = {}) {
|
function resolveWebViewUrl(authResult) {
|
||||||
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
const runtimeConfig = resolveMiniProgramRuntimeConfig();
|
||||||
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim();
|
||||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, {
|
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery);
|
||||||
...runtimeConfig,
|
if (!authResult || !authResult.token) {
|
||||||
webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(),
|
return sourcedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendHashParams(sourcedUrl, {
|
||||||
|
auth_provider: 'wechat',
|
||||||
|
auth_token: authResult.token,
|
||||||
|
auth_binding_status: authResult.bindingStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +467,7 @@ Page({
|
|||||||
loading: false,
|
loading: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage: false,
|
returnToPreviousPage: false,
|
||||||
webViewUrl: resolveWebViewUrl(null, query),
|
webViewUrl: resolveWebViewUrl(null),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -536,7 +572,7 @@ Page({
|
|||||||
nicknameRequired: false,
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
returnToPreviousPage,
|
returnToPreviousPage,
|
||||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
webViewUrl: resolveWebViewUrl(authResult),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setData({
|
this.setData({
|
||||||
@@ -564,7 +600,7 @@ Page({
|
|||||||
loading: false,
|
loading: false,
|
||||||
nicknameRequired: false,
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}),
|
webViewUrl: resolveWebViewUrl(authResult),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,10 +674,7 @@ Page({
|
|||||||
loading: false,
|
loading: false,
|
||||||
nicknameRequired: false,
|
nicknameRequired: false,
|
||||||
phoneBindingRequired: false,
|
phoneBindingRequired: false,
|
||||||
webViewUrl: resolveWebViewUrl(
|
webViewUrl: resolveWebViewUrl(nextAuthResult),
|
||||||
nextAuthResult,
|
|
||||||
this._lastLaunchQuery || {},
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setData({
|
this.setData({
|
||||||
@@ -679,19 +712,15 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleWebViewMessage(event) {
|
handleWebViewMessage(event) {
|
||||||
const shareTarget = resolveShareTargetFromWebViewMessage(event.detail);
|
|
||||||
if (shareTarget) {
|
|
||||||
this._currentShareTarget = shareTarget;
|
|
||||||
}
|
|
||||||
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
|
// 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。
|
||||||
console.info('[web-view] message', event.detail);
|
console.info('[web-view] message', event.detail);
|
||||||
},
|
},
|
||||||
|
|
||||||
onShareAppMessage() {
|
onShareAppMessage() {
|
||||||
return buildWebViewShareAppMessage(resolveNativeShareQuery(this));
|
return buildWebViewShareAppMessage();
|
||||||
},
|
},
|
||||||
|
|
||||||
onShareTimeline() {
|
onShareTimeline() {
|
||||||
return buildWebViewShareTimeline(resolveNativeShareQuery(this));
|
return buildWebViewShareTimeline();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
const ALLOWED_TARGET_PATHS = new Set(['/works/detail']);
|
|
||||||
const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
|
|
||||||
const WEB_VIEW_SHARE_PATH = '/pages/web-view/index';
|
|
||||||
|
|
||||||
function trimTrailingSlash(value) {
|
|
||||||
return String(value || '').trim().replace(/\/+$/u, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendQuery(url, query) {
|
|
||||||
const rawUrl = String(url || '');
|
|
||||||
const pairs = Object.keys(query)
|
|
||||||
.filter((key) => query[key])
|
|
||||||
.map(
|
|
||||||
(key) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
return rawUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashIndex = rawUrl.indexOf('#');
|
|
||||||
const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl;
|
|
||||||
const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : '';
|
|
||||||
return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendHashParams(url, params) {
|
|
||||||
const nextKeys = new Set(Object.keys(params).filter((key) => params[key]));
|
|
||||||
const pairs = Object.keys(params)
|
|
||||||
.filter((key) => params[key])
|
|
||||||
.map(
|
|
||||||
(key) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
|
||||||
);
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashIndex = url.indexOf('#');
|
|
||||||
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
||||||
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
|
||||||
const keptHashParts = rawHash.split('&').filter((part) => {
|
|
||||||
if (!part) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const [rawKey = ''] = part.split('=');
|
|
||||||
try {
|
|
||||||
return !nextKeys.has(decodeURIComponent(rawKey));
|
|
||||||
} catch (_error) {
|
|
||||||
return !nextKeys.has(rawKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTargetPath(value) {
|
|
||||||
const trimmed = String(value || '').trim();
|
|
||||||
if (!trimmed.startsWith('/')) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = trimmed.replace(/\/+$/u, '') || '/';
|
|
||||||
return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLaunchTargetQuery(query) {
|
|
||||||
const targetPath = normalizeTargetPath(query && query.targetPath);
|
|
||||||
const work = String((query && query.work) || '').trim();
|
|
||||||
if (!targetPath || !work) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
targetPath,
|
|
||||||
work,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) {
|
|
||||||
const launchTarget = resolveLaunchTargetQuery(query);
|
|
||||||
if (!launchTarget.targetPath) {
|
|
||||||
return basePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return appendQuery(basePath, {
|
|
||||||
targetPath: launchTarget.targetPath,
|
|
||||||
work: launchTarget.work,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWebViewShareTimelineQuery(query = {}) {
|
|
||||||
const launchTarget = resolveLaunchTargetQuery(query);
|
|
||||||
if (!launchTarget.targetPath) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return new URLSearchParams({
|
|
||||||
targetPath: launchTarget.targetPath,
|
|
||||||
work: launchTarget.work,
|
|
||||||
}).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeShareTargetMessageData(value) {
|
|
||||||
const message = value && value.data ? value.data : value;
|
|
||||||
if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = message.payload || {};
|
|
||||||
const launchTarget = resolveLaunchTargetQuery(payload);
|
|
||||||
if (!launchTarget.targetPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...launchTarget,
|
|
||||||
title: String(payload.title || '').trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveShareTargetFromWebViewMessage(detail) {
|
|
||||||
const dataList = detail && Array.isArray(detail.data) ? detail.data : [];
|
|
||||||
for (let index = dataList.length - 1; index >= 0; index -= 1) {
|
|
||||||
const target = normalizeShareTargetMessageData(dataList[index]);
|
|
||||||
if (target) {
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeShareTargetMessageData(detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLaunchTargetToEntryUrl(entryUrl, query) {
|
|
||||||
const launchTarget = resolveLaunchTargetQuery(query);
|
|
||||||
if (!launchTarget.targetPath) {
|
|
||||||
return entryUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawEntryUrl = String(entryUrl || '').trim();
|
|
||||||
const hashIndex = rawEntryUrl.indexOf('#');
|
|
||||||
const entryWithoutHash =
|
|
||||||
hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl;
|
|
||||||
const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : '';
|
|
||||||
const queryIndex = entryWithoutHash.indexOf('?');
|
|
||||||
const entryBase =
|
|
||||||
queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash;
|
|
||||||
const entrySearch =
|
|
||||||
queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : '';
|
|
||||||
const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`;
|
|
||||||
|
|
||||||
return appendQuery(targetUrl, {
|
|
||||||
work: launchTarget.work,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWebViewUrlFromRuntimeConfig(
|
|
||||||
authResult,
|
|
||||||
launchQuery = {},
|
|
||||||
runtimeConfig = {},
|
|
||||||
) {
|
|
||||||
const entryUrl = appendLaunchTargetToEntryUrl(
|
|
||||||
String(runtimeConfig.webViewEntryUrl || '').trim(),
|
|
||||||
launchQuery,
|
|
||||||
);
|
|
||||||
const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {});
|
|
||||||
if (!authResult || !authResult.token) {
|
|
||||||
return sourcedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return appendHashParams(sourcedUrl, {
|
|
||||||
auth_provider: 'wechat',
|
|
||||||
auth_token: authResult.token,
|
|
||||||
auth_binding_status: authResult.bindingStatus,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
appendHashParams,
|
|
||||||
appendLaunchTargetToEntryUrl,
|
|
||||||
appendQuery,
|
|
||||||
buildWebViewSharePath,
|
|
||||||
buildWebViewShareTimelineQuery,
|
|
||||||
normalizeTargetPath,
|
|
||||||
resolveShareTargetFromWebViewMessage,
|
|
||||||
resolveLaunchTargetQuery,
|
|
||||||
resolveWebViewUrlFromRuntimeConfig,
|
|
||||||
};
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import webViewBridge from './index.shared.js';
|
|
||||||
|
|
||||||
const {
|
|
||||||
appendLaunchTargetToEntryUrl,
|
|
||||||
buildWebViewSharePath,
|
|
||||||
buildWebViewShareTimelineQuery,
|
|
||||||
resolveShareTargetFromWebViewMessage,
|
|
||||||
resolveWebViewUrlFromRuntimeConfig,
|
|
||||||
} = webViewBridge;
|
|
||||||
|
|
||||||
const runtimeConfig = {
|
|
||||||
sourceQuery: {
|
|
||||||
clientType: 'mini_program',
|
|
||||||
clientRuntime: 'wechat_mini_program',
|
|
||||||
},
|
|
||||||
webViewEntryUrl: 'https://www.genarrative.world',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('mini program web-view launch target', () => {
|
|
||||||
test('opens the H5 public work detail when launch query carries work params', () => {
|
|
||||||
expect(
|
|
||||||
appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', {
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
}),
|
|
||||||
).toBe(
|
|
||||||
'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678',
|
|
||||||
);
|
|
||||||
|
|
||||||
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
},
|
|
||||||
runtimeConfig,
|
|
||||||
);
|
|
||||||
const url = new URL(webViewUrl);
|
|
||||||
expect(url.pathname).toBe('/works/detail');
|
|
||||||
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
|
||||||
expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores unsupported launch target paths', () => {
|
|
||||||
const webViewUrl = resolveWebViewUrlFromRuntimeConfig(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
targetPath: '/admin',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
},
|
|
||||||
runtimeConfig,
|
|
||||||
);
|
|
||||||
const url = new URL(webViewUrl);
|
|
||||||
expect(url.pathname).toBe('/');
|
|
||||||
expect(url.searchParams.get('work')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps public work params in native mini program share paths', () => {
|
|
||||||
const sharePath = buildWebViewSharePath({
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
});
|
|
||||||
const url = new URL(sharePath, 'https://mini.test');
|
|
||||||
|
|
||||||
expect(url.pathname).toBe('/pages/web-view/index');
|
|
||||||
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
|
|
||||||
expect(url.searchParams.get('work')).toBe('BB-12345678');
|
|
||||||
expect(
|
|
||||||
buildWebViewShareTimelineQuery({
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
}),
|
|
||||||
).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reads the latest H5 recommended work share target from web-view messages', () => {
|
|
||||||
expect(
|
|
||||||
resolveShareTargetFromWebViewMessage({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
type: 'genarrative:share-target',
|
|
||||||
payload: {
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'PZ-0001',
|
|
||||||
title: '旧作品',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
type: 'genarrative:share-target',
|
|
||||||
payload: {
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
title: '汪汪声浪',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
targetPath: '/works/detail',
|
|
||||||
work: 'BB-12345678',
|
|
||||||
title: '汪汪声浪',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
"dev:api-server": "node scripts/dev.mjs api-server",
|
"dev:api-server": "node scripts/dev.mjs api-server",
|
||||||
"dev:web": "node scripts/dev.mjs web",
|
"dev:web": "node scripts/dev.mjs web",
|
||||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||||
"server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml",
|
|
||||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||||
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||||
@@ -55,7 +54,6 @@
|
|||||||
"container:ps": "node scripts/container-compose.mjs ps",
|
"container:ps": "node scripts/container-compose.mjs ps",
|
||||||
"container:config": "node scripts/container-compose.mjs config",
|
"container:config": "node scripts/container-compose.mjs config",
|
||||||
"container:k6": "node scripts/container-compose.mjs k6",
|
"container:k6": "node scripts/container-compose.mjs k6",
|
||||||
"container:worker-smoke": "node scripts/container-worker-smoke.mjs",
|
|
||||||
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
||||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||||
@@ -65,8 +63,6 @@
|
|||||||
"codegraph:index": "codegraph index .",
|
"codegraph:index": "codegraph index .",
|
||||||
"codegraph:sync": "codegraph sync .",
|
"codegraph:sync": "codegraph sync .",
|
||||||
"codegraph:status": "codegraph status .",
|
"codegraph:status": "codegraph status .",
|
||||||
"rag:index": "node scripts/rag/index-docs.mjs",
|
|
||||||
"rag:search": "node scripts/rag/search-docs.mjs",
|
|
||||||
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
export type ExternalGenerationJobStatus =
|
|
||||||
| 'queued'
|
|
||||||
| 'running'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed';
|
|
||||||
|
|
||||||
export interface ExternalGenerationQueueOverview {
|
|
||||||
pendingCount: number;
|
|
||||||
runningCount: number;
|
|
||||||
updatedAtMicros: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalGenerationQueueOverviewResponse {
|
|
||||||
overview: ExternalGenerationQueueOverview;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalGenerationJobStatusRecord {
|
|
||||||
operationId: string;
|
|
||||||
status: ExternalGenerationJobStatus;
|
|
||||||
phaseLabel: string;
|
|
||||||
phaseDetail: string;
|
|
||||||
progress: number;
|
|
||||||
error?: string | null;
|
|
||||||
updatedAtMicros: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalGenerationJobStatusResponse {
|
|
||||||
job: ExternalGenerationJobStatusRecord;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
|
||||||
|
|
||||||
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
|
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
|
||||||
|
|
||||||
export type JumpHopStylePreset =
|
export type JumpHopStylePreset =
|
||||||
@@ -208,7 +206,6 @@ export interface JumpHopActionResponse {
|
|||||||
actionType: JumpHopActionType;
|
actionType: JumpHopActionType;
|
||||||
session: JumpHopSessionSnapshotResponse;
|
session: JumpHopSessionSnapshotResponse;
|
||||||
work: JumpHopWorkProfileResponse | null;
|
work: JumpHopWorkProfileResponse | null;
|
||||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JumpHopWorkSummaryResponse {
|
export interface JumpHopWorkSummaryResponse {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
|
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
|
||||||
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
|
||||||
|
|
||||||
export type PuzzleAgentSuggestedActionType =
|
export type PuzzleAgentSuggestedActionType =
|
||||||
| 'request_summary'
|
| 'request_summary'
|
||||||
@@ -42,7 +41,6 @@ export interface PuzzleAgentOperationRecord {
|
|||||||
phaseDetail: string;
|
phaseDetail: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PuzzleAgentActionRequest =
|
export type PuzzleAgentActionRequest =
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
|
||||||
|
|
||||||
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
|
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
|
||||||
|
|
||||||
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
|
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
|
||||||
@@ -111,7 +109,6 @@ export interface PuzzleClearActionResponse {
|
|||||||
actionType: PuzzleClearActionType;
|
actionType: PuzzleClearActionType;
|
||||||
session: PuzzleClearSessionSnapshotResponse;
|
session: PuzzleClearSessionSnapshotResponse;
|
||||||
work: PuzzleClearWorkProfileResponse | null;
|
work: PuzzleClearWorkProfileResponse | null;
|
||||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PuzzleClearWorkSummaryResponse {
|
export interface PuzzleClearWorkSummaryResponse {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
|
|
||||||
|
|
||||||
export type WoodenFishGenerationStatus =
|
export type WoodenFishGenerationStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'generating'
|
| 'generating'
|
||||||
@@ -106,7 +104,6 @@ export interface WoodenFishActionResponse {
|
|||||||
actionType: WoodenFishActionType;
|
actionType: WoodenFishActionType;
|
||||||
session: WoodenFishSessionSnapshotResponse;
|
session: WoodenFishSessionSnapshotResponse;
|
||||||
work: WoodenFishWorkProfileResponse | null;
|
work: WoodenFishWorkProfileResponse | null;
|
||||||
queueState?: ExternalGenerationJobStatusRecord | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WoodenFishWorkSummaryResponse {
|
export interface WoodenFishWorkSummaryResponse {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const API_VERSION = '2026-06-16';
|
export const API_VERSION = '2026-04-08';
|
||||||
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||||||
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';
|
export const API_RESPONSE_ENVELOPE_VERSION = 'v1';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type * from './contracts/creativeAgent';
|
|||||||
export type * from './contracts/customWorldAgent';
|
export type * from './contracts/customWorldAgent';
|
||||||
export * from './contracts/edutainmentBabyDrawing';
|
export * from './contracts/edutainmentBabyDrawing';
|
||||||
export * from './contracts/edutainmentBabyObject';
|
export * from './contracts/edutainmentBabyObject';
|
||||||
export * from './contracts/externalGeneration';
|
|
||||||
export type * from './contracts/hyper3d';
|
export type * from './contracts/hyper3d';
|
||||||
export * from './contracts/match3dAgent';
|
export * from './contracts/match3dAgent';
|
||||||
export * from './contracts/match3dRuntime';
|
export * from './contracts/match3dRuntime';
|
||||||
|
|||||||
@@ -23,46 +23,6 @@ const checks = [
|
|||||||
includes: 'genarrative-health-patrol.timer',
|
includes: 'genarrative-health-patrol.timer',
|
||||||
reason: 'Server-Provision 必须安装并启用健康巡检 timer。',
|
reason: 'Server-Provision 必须安装并启用健康巡检 timer。',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
file: 'scripts/jenkins-server-provision.sh',
|
|
||||||
includes: 'genarrative-external-generation-controller.service',
|
|
||||||
reason: 'Server-Provision 必须安装并启用外部生成 worker controller。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'scripts/jenkins-server-provision.sh',
|
|
||||||
includes: 'genarrative-external-generation-worker@1.service',
|
|
||||||
reason: 'Server-Provision 必须启用外部生成保底 worker 实例。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'scripts/deploy/production-api-deploy.sh',
|
|
||||||
includes: 'ensure_default_worker_service',
|
|
||||||
reason: 'API Deploy 必须在缺少 worker 实例时补启动默认外部生成 worker。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'scripts/deploy/production-api-deploy.sh',
|
|
||||||
includes: 'wait_for_worker_services',
|
|
||||||
reason: 'API Deploy 必须等待外部生成 worker 实例 active。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'scripts/deploy/production-api-deploy.sh',
|
|
||||||
includes: 'wait_for_worker_controller_service',
|
|
||||||
reason: 'API Deploy 必须重启并验活外部生成 worker controller。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'deploy/systemd/genarrative-external-generation-worker@.service',
|
|
||||||
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-worker',
|
|
||||||
reason: '外部生成 worker 模板必须作为独立 worker 进程角色运行。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'deploy/systemd/genarrative-external-generation-controller.service',
|
|
||||||
includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-controller',
|
|
||||||
reason: '外部生成 worker controller 必须作为独立进程角色运行。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file: 'scripts/ops/production-health-patrol.mjs',
|
|
||||||
includes: 'checkActiveWorkerInstances',
|
|
||||||
reason: '生产健康巡检必须检查至少一个外部生成 worker 实例 active。',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
file: 'scripts/build-production-release.sh',
|
file: 'scripts/build-production-release.sh',
|
||||||
includes: 'production-health-patrol.mjs',
|
includes: 'production-health-patrol.mjs',
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib"
|
|||||||
|
|
||||||
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
|
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
echo "spacetimedb-cli 2.5.0"
|
echo "spacetimedb-cli 2.4.1"
|
||||||
EOF
|
EOF
|
||||||
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
|
cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
echo "spacetimedb-standalone 2.5.0"
|
echo "spacetimedb-standalone 2.4.1"
|
||||||
EOF
|
EOF
|
||||||
chmod +x \
|
chmod +x \
|
||||||
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
|
"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \
|
||||||
@@ -58,7 +58,7 @@ if ! (
|
|||||||
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
|
OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \
|
||||||
OTELCOL_VERSION="0.151.0" \
|
OTELCOL_VERSION="0.151.0" \
|
||||||
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
|
SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \
|
||||||
SPACETIME_EXPECTED_VERSION="2.5.0" \
|
SPACETIME_EXPECTED_VERSION="2.4.1" \
|
||||||
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
|
"${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \
|
||||||
>"${OUTPUT_LOG}" 2>&1
|
>"${OUTPUT_LOG}" 2>&1
|
||||||
); then
|
); then
|
||||||
|
|||||||
@@ -475,14 +475,14 @@ function loadBaseSources(baseRef) {
|
|||||||
|
|
||||||
function getChangedFiles(baseRef) {
|
function getChangedFiles(baseRef) {
|
||||||
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
|
||||||
const untrackedModuleOutput =
|
const untrackedOutput =
|
||||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
|
||||||
const untrackedBindingsOutput =
|
const untrackedBindingsOutput =
|
||||||
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
|
tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
|
||||||
return new Set(
|
return new Set(
|
||||||
[
|
[
|
||||||
...diffOutput.split(/\u0000/u),
|
...diffOutput.split(/\u0000/u),
|
||||||
...untrackedModuleOutput.split(/\u0000/u),
|
...untrackedOutput.split(/\u0000/u),
|
||||||
...untrackedBindingsOutput.split(/\u0000/u),
|
...untrackedBindingsOutput.split(/\u0000/u),
|
||||||
]
|
]
|
||||||
.map(normalizePath)
|
.map(normalizePath)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.
|
|||||||
|
|
||||||
const documentTargets = [
|
const documentTargets = [
|
||||||
'docs',
|
'docs',
|
||||||
'docs/project-memory/shared-memory',
|
'.hermes/shared-memory',
|
||||||
];
|
];
|
||||||
|
|
||||||
const visualNovelImplementationTargets = [
|
const visualNovelImplementationTargets = [
|
||||||
@@ -202,7 +202,7 @@ const reportLines = [
|
|||||||
'## 扫描范围',
|
'## 扫描范围',
|
||||||
'',
|
'',
|
||||||
'- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
'- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
||||||
'- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`',
|
'- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`',
|
||||||
'- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
'- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
|
||||||
'',
|
'',
|
||||||
'## 扫描结论',
|
'## 扫描结论',
|
||||||
|
|||||||
@@ -1,839 +0,0 @@
|
|||||||
import {spawn} from 'node:child_process';
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import net from 'node:net';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const [, , rawCommand = 'help', ...rawArgs] = process.argv;
|
|
||||||
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml');
|
|
||||||
const smokeDir = path.join('deploy', 'container', 'worker-smoke');
|
|
||||||
const envPath = path.join(smokeDir, 'api-server.env');
|
|
||||||
const statePath = path.join(smokeDir, 'state.json');
|
|
||||||
const localImageDir = path.join(smokeDir, 'image');
|
|
||||||
const localImageDockerfilePath = path.join(localImageDir, 'Dockerfile.local');
|
|
||||||
const localImageBinaryPath = path.join(localImageDir, 'api-server');
|
|
||||||
const localCargoTargetDir = path.join('server-rs', 'target-worker-smoke');
|
|
||||||
const localSpacetimeImageDir = path.join(smokeDir, 'spacetimedb-image');
|
|
||||||
const localSpacetimeDockerfilePath = path.join(localSpacetimeImageDir, 'Dockerfile.local');
|
|
||||||
const localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime');
|
|
||||||
const localSpacetimeStandalonePath = path.join(
|
|
||||||
localSpacetimeImageDir,
|
|
||||||
'spacetimedb-standalone',
|
|
||||||
);
|
|
||||||
const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke';
|
|
||||||
const defaultDatabase =
|
|
||||||
process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke';
|
|
||||||
|
|
||||||
const command = rawCommand.trim();
|
|
||||||
const supportedCommands = new Set([
|
|
||||||
'help',
|
|
||||||
'init',
|
|
||||||
'build',
|
|
||||||
'up-spacetime',
|
|
||||||
'publish',
|
|
||||||
'up',
|
|
||||||
'enqueue',
|
|
||||||
'status',
|
|
||||||
'api-update',
|
|
||||||
'scale',
|
|
||||||
'logs',
|
|
||||||
'ps',
|
|
||||||
'down',
|
|
||||||
'smoke',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!supportedCommands.has(command)) {
|
|
||||||
printHelp(true);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await main();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[worker-smoke] ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
switch (command) {
|
|
||||||
case 'help':
|
|
||||||
printHelp(false);
|
|
||||||
return;
|
|
||||||
case 'init':
|
|
||||||
await ensureStateAndEnv({force: rawArgs.includes('--force')});
|
|
||||||
return;
|
|
||||||
case 'build':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await buildRuntimeImages();
|
|
||||||
return;
|
|
||||||
case 'up-spacetime':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await ensureSpacetimeImage();
|
|
||||||
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
|
|
||||||
await waitForSpacetime();
|
|
||||||
return;
|
|
||||||
case 'publish':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await publishModule();
|
|
||||||
return;
|
|
||||||
case 'up':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await upRuntime();
|
|
||||||
await waitForApi();
|
|
||||||
return;
|
|
||||||
case 'enqueue':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await enqueueSmokeJob();
|
|
||||||
return;
|
|
||||||
case 'status':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await printQueueStatus();
|
|
||||||
return;
|
|
||||||
case 'api-update':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await apiOnlyUpdate({build: rawArgs.includes('--build')});
|
|
||||||
return;
|
|
||||||
case 'scale':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await scaleWorkers(rawArgs[0] ?? '1');
|
|
||||||
return;
|
|
||||||
case 'logs':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await dockerCompose(['logs', ...rawArgs]);
|
|
||||||
return;
|
|
||||||
case 'ps':
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await dockerCompose(['ps', ...rawArgs]);
|
|
||||||
return;
|
|
||||||
case 'down':
|
|
||||||
await ensureStateAndEnv({create: false});
|
|
||||||
await dockerCompose(['down', ...rawArgs]);
|
|
||||||
return;
|
|
||||||
case 'smoke':
|
|
||||||
await runSmoke();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
throw new Error(`未知命令: ${command}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runSmoke() {
|
|
||||||
if (rawArgs.includes('--force')) {
|
|
||||||
await ensureStateAndEnv();
|
|
||||||
await dockerComposeCapture(['down', '-v'], {allowFailure: true});
|
|
||||||
}
|
|
||||||
const state = await ensureStateAndEnv({force: rawArgs.includes('--force')});
|
|
||||||
await assertSavedPortsAvailableForNewProject(state);
|
|
||||||
console.log(
|
|
||||||
`[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`,
|
|
||||||
);
|
|
||||||
await buildRuntimeImages();
|
|
||||||
await ensureSpacetimeImage();
|
|
||||||
await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']);
|
|
||||||
await waitForSpacetime();
|
|
||||||
await publishModule();
|
|
||||||
await upRuntime();
|
|
||||||
await waitForApi();
|
|
||||||
await assertWorkersRunning();
|
|
||||||
|
|
||||||
const beforeWorkerIds = await getContainerIds('external-generation-worker');
|
|
||||||
console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`);
|
|
||||||
|
|
||||||
const firstJobId = await enqueueSmokeJob({label: 'before-api-update'});
|
|
||||||
await waitForJobConsumed(firstJobId);
|
|
||||||
|
|
||||||
await apiOnlyUpdate({build: false});
|
|
||||||
const afterWorkerIds = await getContainerIds('external-generation-worker');
|
|
||||||
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
|
|
||||||
throw new Error(
|
|
||||||
`api-update 后 worker 容器发生变化: before=${beforeWorkerIds.join(',')} after=${afterWorkerIds.join(',')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log('[worker-smoke] api-only 更新未重建 worker 容器。');
|
|
||||||
|
|
||||||
const secondJobId = await enqueueSmokeJob({label: 'after-api-update'});
|
|
||||||
await waitForJobConsumed(secondJobId);
|
|
||||||
await printQueueStatus();
|
|
||||||
console.log('[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildRuntimeImages() {
|
|
||||||
const imageMode = resolveImageMode();
|
|
||||||
if (imageMode === 'local-binary') {
|
|
||||||
await buildLocalBinaryRuntimeImages();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await dockerCompose(['build', 'api-server', 'external-generation-worker']);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveImageMode() {
|
|
||||||
if (rawArgs.includes('--local-binary')) {
|
|
||||||
return 'local-binary';
|
|
||||||
}
|
|
||||||
const envMode = process.env.GENARRATIVE_WORKER_SMOKE_IMAGE_MODE;
|
|
||||||
if (!envMode || envMode === 'dockerfile') {
|
|
||||||
return 'dockerfile';
|
|
||||||
}
|
|
||||||
if (envMode === 'local-binary') {
|
|
||||||
return 'local-binary';
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`GENARRATIVE_WORKER_SMOKE_IMAGE_MODE 仅支持 dockerfile 或 local-binary: ${envMode}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildLocalBinaryRuntimeImages() {
|
|
||||||
const profile =
|
|
||||||
rawArgs.includes('--release') ||
|
|
||||||
process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release'
|
|
||||||
? 'release'
|
|
||||||
: 'debug';
|
|
||||||
const buildArgs = ['build', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'];
|
|
||||||
if (profile === 'release') {
|
|
||||||
buildArgs.push('--release');
|
|
||||||
}
|
|
||||||
const cargoImage = resolveLocalBinaryCargoImage();
|
|
||||||
const cargoHome = resolveLocalBinaryCargoHome();
|
|
||||||
mkdirSync(cargoHome, {recursive: true});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`,
|
|
||||||
);
|
|
||||||
await run('docker', [
|
|
||||||
'run',
|
|
||||||
'--rm',
|
|
||||||
'-u',
|
|
||||||
currentUserSpec(),
|
|
||||||
'-v',
|
|
||||||
`${projectRoot}:/workspace`,
|
|
||||||
'-v',
|
|
||||||
`${cargoHome}:/cargo-home`,
|
|
||||||
'-w',
|
|
||||||
'/workspace',
|
|
||||||
'-e',
|
|
||||||
'HOME=/cargo-home',
|
|
||||||
'-e',
|
|
||||||
'CARGO_HOME=/cargo-home',
|
|
||||||
'-e',
|
|
||||||
`CARGO_TARGET_DIR=/workspace/${toContainerPath(localCargoTargetDir)}`,
|
|
||||||
cargoImage,
|
|
||||||
'cargo',
|
|
||||||
'--config',
|
|
||||||
'build.rustc-wrapper=""',
|
|
||||||
'--config',
|
|
||||||
'target.x86_64-unknown-linux-gnu.linker="cc"',
|
|
||||||
'--config',
|
|
||||||
'target.x86_64-unknown-linux-gnu.rustflags=[]',
|
|
||||||
...buildArgs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server');
|
|
||||||
if (!existsSync(sourceBinaryPath)) {
|
|
||||||
throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(localImageDir, {recursive: true});
|
|
||||||
copyFileSync(sourceBinaryPath, localImageBinaryPath);
|
|
||||||
chmodSync(localImageBinaryPath, 0o755);
|
|
||||||
|
|
||||||
const baseImage = await resolveLocalBinaryBaseImage();
|
|
||||||
writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8');
|
|
||||||
|
|
||||||
await run('docker', [
|
|
||||||
'build',
|
|
||||||
'-f',
|
|
||||||
localImageDockerfilePath,
|
|
||||||
'-t',
|
|
||||||
`${projectName}-api-server`,
|
|
||||||
'-t',
|
|
||||||
`${projectName}-external-generation-worker`,
|
|
||||||
localImageDir,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocalBinaryCargoImage() {
|
|
||||||
return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocalBinaryCargoHome() {
|
|
||||||
if (process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME) {
|
|
||||||
return path.resolve(process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME);
|
|
||||||
}
|
|
||||||
if (!process.env.HOME) {
|
|
||||||
throw new Error('未找到 HOME,无法挂载本机 Cargo 缓存。');
|
|
||||||
}
|
|
||||||
return path.join(process.env.HOME, '.cargo');
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentUserSpec() {
|
|
||||||
if (typeof process.getuid === 'function' && typeof process.getgid === 'function') {
|
|
||||||
return `${process.getuid()}:${process.getgid()}`;
|
|
||||||
}
|
|
||||||
return '0:0';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSpacetimeImage() {
|
|
||||||
if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const imageName = localSpacetimeImageName();
|
|
||||||
const existingImage = await runCapture('docker', ['image', 'inspect', imageName], {
|
|
||||||
allowFailure: true,
|
|
||||||
quiet: true,
|
|
||||||
});
|
|
||||||
if (existingImage.code === 0 && !rawArgs.includes('--force')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spacetimePath = await resolveSpacetimeBinaryPath();
|
|
||||||
if (!spacetimePath) {
|
|
||||||
throw new Error('未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。');
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(localSpacetimeImageDir, {recursive: true});
|
|
||||||
copyFileSync(spacetimePath, localSpacetimeBinaryPath);
|
|
||||||
chmodSync(localSpacetimeBinaryPath, 0o755);
|
|
||||||
const standalonePath = path.join(path.dirname(spacetimePath), 'spacetimedb-standalone');
|
|
||||||
if (!existsSync(standalonePath)) {
|
|
||||||
throw new Error(`未找到本机 spacetimedb-standalone: ${standalonePath}`);
|
|
||||||
}
|
|
||||||
copyFileSync(standalonePath, localSpacetimeStandalonePath);
|
|
||||||
chmodSync(localSpacetimeStandalonePath, 0o755);
|
|
||||||
writeFileSync(localSpacetimeDockerfilePath, buildLocalSpacetimeDockerfile(), 'utf8');
|
|
||||||
|
|
||||||
console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`);
|
|
||||||
await run('docker', [
|
|
||||||
'build',
|
|
||||||
'-f',
|
|
||||||
localSpacetimeDockerfilePath,
|
|
||||||
'-t',
|
|
||||||
imageName,
|
|
||||||
localSpacetimeImageDir,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLocalSpacetimeDockerfile() {
|
|
||||||
return `FROM debian:bookworm-slim
|
|
||||||
WORKDIR /var/lib/spacetimedb
|
|
||||||
RUN apt-get update && \\
|
|
||||||
apt-get install -y --no-install-recommends ca-certificates libstdc++6 zlib1g && \\
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY spacetime /usr/local/bin/spacetime
|
|
||||||
COPY spacetimedb-standalone /usr/local/bin/spacetimedb-standalone
|
|
||||||
RUN chmod 0755 /usr/local/bin/spacetime /usr/local/bin/spacetimedb-standalone
|
|
||||||
ENTRYPOINT ["spacetime"]
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveSpacetimeBinaryPath() {
|
|
||||||
if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN) {
|
|
||||||
return process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN;
|
|
||||||
}
|
|
||||||
const versionResult = await runCapture('spacetime', ['--version'], {quiet: true});
|
|
||||||
const pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu);
|
|
||||||
if (pathMatch?.[1]) {
|
|
||||||
return pathMatch[1].trim();
|
|
||||||
}
|
|
||||||
const whichResult = await runCapture('which', ['spacetime'], {quiet: true});
|
|
||||||
return whichResult.stdout.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveLocalBinaryBaseImage() {
|
|
||||||
if (process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE) {
|
|
||||||
return process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE;
|
|
||||||
}
|
|
||||||
return 'debian:bookworm-slim';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLocalBinaryDockerfile(baseImage) {
|
|
||||||
return `FROM ${baseImage}
|
|
||||||
WORKDIR /srv/genarrative
|
|
||||||
RUN apt-get update && \\
|
|
||||||
apt-get install -y --no-install-recommends ca-certificates curl libssl3 zlib1g libzstd1 && \\
|
|
||||||
rm -rf /var/lib/apt/lists/* && \\
|
|
||||||
(id -u genarrative >/dev/null 2>&1 || useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative)
|
|
||||||
COPY api-server /usr/local/bin/api-server
|
|
||||||
RUN chmod 0755 /usr/local/bin/api-server && \\
|
|
||||||
mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox && \\
|
|
||||||
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
|
|
||||||
USER genarrative
|
|
||||||
EXPOSE 8082
|
|
||||||
ENV GENARRATIVE_ENV=container \\
|
|
||||||
GENARRATIVE_API_HOST=0.0.0.0 \\
|
|
||||||
GENARRATIVE_API_PORT=8082 \\
|
|
||||||
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
|
||||||
CMD ["api-server"]
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toContainerPath(localPath) {
|
|
||||||
return localPath.split(path.sep).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upRuntime() {
|
|
||||||
const services = ['api-server', 'external-generation-worker'];
|
|
||||||
if (rawArgs.includes('--with-nginx')) {
|
|
||||||
services.push('nginx');
|
|
||||||
}
|
|
||||||
await dockerCompose(['up', '-d', ...services]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureStateAndEnv(options = {}) {
|
|
||||||
const {force = false, create = true} = options;
|
|
||||||
if (!create && !existsSync(statePath)) {
|
|
||||||
return defaultState();
|
|
||||||
}
|
|
||||||
mkdirSync(smokeDir, {recursive: true});
|
|
||||||
|
|
||||||
if (!existsSync(statePath) || force) {
|
|
||||||
const state = {
|
|
||||||
database: defaultDatabase,
|
|
||||||
spacetimePort: await findAvailablePort(
|
|
||||||
Number(process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_PORT || 19101),
|
|
||||||
),
|
|
||||||
httpPort: await findAvailablePort(
|
|
||||||
Number(process.env.GENARRATIVE_WORKER_SMOKE_HTTP_PORT || 19080),
|
|
||||||
),
|
|
||||||
otlpGrpcPort: await findAvailablePort(
|
|
||||||
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_GRPC_PORT || 15317),
|
|
||||||
),
|
|
||||||
otlpHttpPort: await findAvailablePort(
|
|
||||||
Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_HTTP_PORT || 15318),
|
|
||||||
),
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = readState();
|
|
||||||
if (!existsSync(envPath) || force) {
|
|
||||||
writeFileSync(envPath, buildSmokeEnv(state), 'utf8');
|
|
||||||
}
|
|
||||||
console.log(`[worker-smoke] env=${envPath}`);
|
|
||||||
console.log(`[worker-smoke] state=${statePath}`);
|
|
||||||
console.log(`[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`);
|
|
||||||
console.log(`[worker-smoke] Nginx=http://127.0.0.1:${state.httpPort}`);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSmokeEnv(state) {
|
|
||||||
return `# 本文件由 scripts/container-worker-smoke.mjs 生成,仅用于本机隔离 worker smoke。
|
|
||||||
# 不要在这里写真实生产密钥;目录 deploy/container/worker-smoke/ 已被 gitignore。
|
|
||||||
GENARRATIVE_ENV=container-worker-smoke
|
|
||||||
GENARRATIVE_API_HOST=0.0.0.0
|
|
||||||
GENARRATIVE_API_PORT=8082
|
|
||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
|
||||||
GENARRATIVE_API_LISTEN_BACKLOG=256
|
|
||||||
GENARRATIVE_API_WORKER_THREADS=2
|
|
||||||
GENARRATIVE_PROCESS_ROLE=api
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=1
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=500
|
|
||||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=60
|
|
||||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=64
|
|
||||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=32
|
|
||||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=16
|
|
||||||
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=8
|
|
||||||
GENARRATIVE_TRACKING_OUTBOX_ENABLED=false
|
|
||||||
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
|
||||||
|
|
||||||
GENARRATIVE_OTEL_ENABLED=false
|
|
||||||
OTEL_SERVICE_NAME=genarrative-worker-smoke-api
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
|
|
||||||
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=worker-smoke,service.namespace=genarrative
|
|
||||||
|
|
||||||
GENARRATIVE_INTERNAL_API_SECRET=worker-smoke-internal-secret
|
|
||||||
GENARRATIVE_JWT_ISSUER=genarrative-worker-smoke
|
|
||||||
GENARRATIVE_JWT_SECRET=worker-smoke-jwt-secret
|
|
||||||
AUTH_REFRESH_COOKIE_SECURE=false
|
|
||||||
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true
|
|
||||||
|
|
||||||
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
|
|
||||||
GENARRATIVE_SPACETIME_DATABASE=${state.database}
|
|
||||||
GENARRATIVE_SPACETIME_TOKEN=
|
|
||||||
GENARRATIVE_SPACETIME_POOL_SIZE=2
|
|
||||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
|
|
||||||
|
|
||||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
|
||||||
GENARRATIVE_LLM_BASE_URL=
|
|
||||||
GENARRATIVE_LLM_API_KEY=
|
|
||||||
GENARRATIVE_LLM_MODEL=
|
|
||||||
VECTOR_ENGINE_BASE_URL=
|
|
||||||
VECTOR_ENGINE_API_KEY=
|
|
||||||
ALIYUN_OSS_BUCKET=
|
|
||||||
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
|
||||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
|
||||||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
|
||||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultState() {
|
|
||||||
return {
|
|
||||||
database: defaultDatabase,
|
|
||||||
spacetimePort: 19101,
|
|
||||||
httpPort: 19080,
|
|
||||||
otlpGrpcPort: 15317,
|
|
||||||
otlpHttpPort: 15318,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readState() {
|
|
||||||
if (!existsSync(statePath)) {
|
|
||||||
return defaultState();
|
|
||||||
}
|
|
||||||
return JSON.parse(readFileSync(statePath, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findAvailablePort(startPort) {
|
|
||||||
for (let port = startPort; port < startPort + 100; port += 1) {
|
|
||||||
if (await isPortAvailable(port)) {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(`未找到可用端口: ${startPort}-${startPort + 99}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPortAvailable(port) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.once('error', () => resolve(false));
|
|
||||||
server.once('listening', () => {
|
|
||||||
server.close(() => resolve(true));
|
|
||||||
});
|
|
||||||
server.listen(port, '127.0.0.1');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishModule() {
|
|
||||||
const state = readState();
|
|
||||||
const serverUrl = spacetimeServerUrl(state);
|
|
||||||
const publishArgs = [
|
|
||||||
'publish',
|
|
||||||
state.database,
|
|
||||||
'--server',
|
|
||||||
serverUrl,
|
|
||||||
'--module-path',
|
|
||||||
'server-rs/crates/spacetime-module',
|
|
||||||
'--delete-data=on-conflict',
|
|
||||||
'--anonymous',
|
|
||||||
'--yes=all',
|
|
||||||
'--no-config',
|
|
||||||
];
|
|
||||||
const buildOptions = process.env.GENARRATIVE_WORKER_SMOKE_STDB_BUILD_OPTIONS;
|
|
||||||
if (buildOptions) {
|
|
||||||
publishArgs.push('--build-options', buildOptions);
|
|
||||||
}
|
|
||||||
await run('spacetime', publishArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueSmokeJob(options = {}) {
|
|
||||||
if (!rawArgs.includes('--no-worker-check')) {
|
|
||||||
await assertWorkersRunning();
|
|
||||||
}
|
|
||||||
const state = readState();
|
|
||||||
const nowMicros = Date.now() * 1000;
|
|
||||||
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
||||||
const jobId = `extgen-smoke-${suffix}`;
|
|
||||||
const label = options.label || rawArgs[0] || 'manual';
|
|
||||||
const input = {
|
|
||||||
job_id: jobId,
|
|
||||||
dedupe_key: `worker-smoke:${label}:${suffix}`,
|
|
||||||
job_kind: 'worker_smoke_unsupported',
|
|
||||||
owner_user_id: 'worker-smoke-user',
|
|
||||||
source_module: 'worker-smoke',
|
|
||||||
source_entity_id: `worker-smoke-entity-${suffix}`,
|
|
||||||
request_label: `worker-smoke ${label}`,
|
|
||||||
request_payload_json: JSON.stringify({label, suffix}),
|
|
||||||
max_attempts: 1,
|
|
||||||
available_at_micros: nowMicros,
|
|
||||||
created_at_micros: nowMicros,
|
|
||||||
};
|
|
||||||
|
|
||||||
await run('spacetime', [
|
|
||||||
'call',
|
|
||||||
'--server',
|
|
||||||
spacetimeServerUrl(state),
|
|
||||||
'--anonymous',
|
|
||||||
'--yes',
|
|
||||||
'--no-config',
|
|
||||||
state.database,
|
|
||||||
'enqueue_external_generation_job_and_return',
|
|
||||||
JSON.stringify(input),
|
|
||||||
]);
|
|
||||||
console.log(`[worker-smoke] 已入队测试 job: ${jobId}`);
|
|
||||||
return jobId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function printQueueStatus() {
|
|
||||||
console.log('[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:');
|
|
||||||
await printServiceLogs('external-generation-worker', 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForJobConsumed(jobId) {
|
|
||||||
const deadline = Date.now() + 60_000;
|
|
||||||
let lastOutput = '';
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const result = await dockerComposeCapture(
|
|
||||||
['logs', '--no-color', 'external-generation-worker'],
|
|
||||||
{allowFailure: true, quiet: true},
|
|
||||||
);
|
|
||||||
lastOutput = `${result.stdout}\n${result.stderr}`;
|
|
||||||
if (lastOutput.includes(jobId) && lastOutput.includes('暂不支持的任务类型')) {
|
|
||||||
console.log(`[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(1000);
|
|
||||||
}
|
|
||||||
await printServiceLogs('external-generation-worker', 120);
|
|
||||||
throw new Error(`等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertSavedPortsAvailableForNewProject(state) {
|
|
||||||
const existingContainers = await getProjectContainerIds();
|
|
||||||
if (existingContainers.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ports = [
|
|
||||||
['SpacetimeDB', state.spacetimePort],
|
|
||||||
['Nginx', state.httpPort],
|
|
||||||
['OTLP gRPC', state.otlpGrpcPort],
|
|
||||||
['OTLP HTTP', state.otlpHttpPort],
|
|
||||||
];
|
|
||||||
for (const [label, port] of ports) {
|
|
||||||
if (!(await isPortAvailable(port))) {
|
|
||||||
throw new Error(
|
|
||||||
`${label} 端口 ${port} 已被占用;可执行 npm run container:worker-smoke -- smoke --force 重新分配隔离端口。`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProjectContainerIds() {
|
|
||||||
const result = await dockerComposeCapture(['ps', '-q'], {
|
|
||||||
allowFailure: true,
|
|
||||||
quiet: true,
|
|
||||||
});
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return result.stdout
|
|
||||||
.split(/\r?\n/u)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertWorkersRunning() {
|
|
||||||
const result = await dockerComposeCapture(
|
|
||||||
['ps', '--status', 'running', '-q', 'external-generation-worker'],
|
|
||||||
{allowFailure: true, quiet: true},
|
|
||||||
);
|
|
||||||
const workerIds = result.stdout
|
|
||||||
.split(/\r?\n/u)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (result.code === 0 && workerIds.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await printServiceLogs('external-generation-worker', 80);
|
|
||||||
throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function printServiceLogs(service, tail = 80) {
|
|
||||||
await dockerComposeCapture(['logs', '--tail', String(tail), service], {
|
|
||||||
allowFailure: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForSpacetime() {
|
|
||||||
const state = readState();
|
|
||||||
const url = `${spacetimeServerUrl(state)}/v1/ping`;
|
|
||||||
await waitForHttp(url, 'SpacetimeDB');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForApi() {
|
|
||||||
const deadline = Date.now() + 120_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const result = await dockerComposeCapture(
|
|
||||||
['exec', '-T', 'api-server', 'curl', '-fsS', 'http://127.0.0.1:8082/healthz'],
|
|
||||||
{allowFailure: true, quiet: true},
|
|
||||||
);
|
|
||||||
if (result.code === 0) {
|
|
||||||
console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
throw new Error('api-server 等待超时: api-server:8082/healthz');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHttp(url, label) {
|
|
||||||
const deadline = Date.now() + 120_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const result = await runCapture('curl', ['-fsS', '--max-time', '3', url], {
|
|
||||||
allowFailure: true,
|
|
||||||
});
|
|
||||||
if (result.code === 0) {
|
|
||||||
console.log(`[worker-smoke] ${label} 已就绪: ${url}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
throw new Error(`${label} 等待超时: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiOnlyUpdate({build}) {
|
|
||||||
const beforeWorkerIds = await getContainerIds('external-generation-worker');
|
|
||||||
const args = ['up', '-d', '--no-deps', '--force-recreate'];
|
|
||||||
if (build) {
|
|
||||||
args.push('--build');
|
|
||||||
}
|
|
||||||
args.push('api-server');
|
|
||||||
await dockerCompose(args);
|
|
||||||
await waitForApi();
|
|
||||||
const afterWorkerIds = await getContainerIds('external-generation-worker');
|
|
||||||
if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) {
|
|
||||||
throw new Error('API-only 更新不应重建 external-generation-worker 容器');
|
|
||||||
}
|
|
||||||
console.log('[worker-smoke] API-only 更新完成,worker 容器保持不变。');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scaleWorkers(rawCount) {
|
|
||||||
const count = Number.parseInt(rawCount, 10);
|
|
||||||
if (!Number.isInteger(count) || count < 0 || count > 16) {
|
|
||||||
throw new Error(`worker 数量必须是 0-16 的整数: ${rawCount}`);
|
|
||||||
}
|
|
||||||
await dockerCompose([
|
|
||||||
'up',
|
|
||||||
'-d',
|
|
||||||
'--scale',
|
|
||||||
`external-generation-worker=${count}`,
|
|
||||||
'external-generation-worker',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getContainerIds(service) {
|
|
||||||
const result = await dockerComposeCapture(['ps', '-q', service]);
|
|
||||||
return result.stdout
|
|
||||||
.split(/\r?\n/u)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dockerCompose(args) {
|
|
||||||
await run('docker', composeArgs(args), {env: composeEnv()});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dockerComposeCapture(args, options = {}) {
|
|
||||||
return runCapture('docker', composeArgs(args), {
|
|
||||||
env: composeEnv(),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function composeArgs(args) {
|
|
||||||
return ['compose', '-p', projectName, '-f', composeFile, ...args];
|
|
||||||
}
|
|
||||||
|
|
||||||
function composeEnv() {
|
|
||||||
const state = readState();
|
|
||||||
return {
|
|
||||||
...process.env,
|
|
||||||
GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env',
|
|
||||||
GENARRATIVE_CONTAINER_SPACETIME_IMAGE:
|
|
||||||
process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || localSpacetimeImageName(),
|
|
||||||
GENARRATIVE_CONTAINER_SPACETIME_PORT: String(state.spacetimePort),
|
|
||||||
GENARRATIVE_CONTAINER_HTTP_PORT: String(state.httpPort),
|
|
||||||
GENARRATIVE_CONTAINER_OTLP_GRPC_PORT: String(state.otlpGrpcPort),
|
|
||||||
GENARRATIVE_CONTAINER_OTLP_HTTP_PORT: String(state.otlpHttpPort),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function localSpacetimeImageName() {
|
|
||||||
return `${projectName}-spacetimedb:2.5.0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function spacetimeServerUrl(state) {
|
|
||||||
return `http://127.0.0.1:${state.spacetimePort}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(commandName, args, options = {}) {
|
|
||||||
const result = await runCapture(commandName, args, options);
|
|
||||||
if (result.code !== 0 && !options.allowFailure) {
|
|
||||||
throw new Error(`${commandName} ${args.join(' ')} 失败,exit=${result.code}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCapture(commandName, args, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(commandName, args, {
|
|
||||||
cwd: projectRoot,
|
|
||||||
env: options.env ?? process.env,
|
|
||||||
shell: false,
|
|
||||||
});
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
child.stdout?.on('data', (chunk) => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
stdout += text;
|
|
||||||
if (!options.quiet) {
|
|
||||||
process.stdout.write(text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
child.stderr?.on('data', (chunk) => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
stderr += text;
|
|
||||||
if (!options.quiet) {
|
|
||||||
process.stderr.write(text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
child.on('error', reject);
|
|
||||||
child.on('exit', (code, signal) => {
|
|
||||||
if (signal) {
|
|
||||||
reject(new Error(`${commandName} 被信号终止: ${signal}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve({code: code ?? 0, stdout, stderr});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp(isError) {
|
|
||||||
const output = isError ? console.error : console.log;
|
|
||||||
output(`Usage: npm run container:worker-smoke -- <command>
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
init [--force] 生成隔离 env 与端口 state
|
|
||||||
build [--local-binary] [--release]
|
|
||||||
构建 api-server / worker 镜像;--local-binary 让容器内 Cargo 复用本机缓存
|
|
||||||
up-spacetime 启动隔离 SpacetimeDB 与 otelcol
|
|
||||||
publish 向隔离 SpacetimeDB 发布 spacetime-module
|
|
||||||
up [--with-nginx] 启动 api-server / worker;需要 Nginx 时显式加 --with-nginx
|
|
||||||
enqueue [label] [--no-worker-check]
|
|
||||||
写入一个 unsupported 测试 job,验证 worker claim/fail
|
|
||||||
status 查看最近 worker 日志;external_generation_job 是 private table
|
|
||||||
api-update [--build] 仅重建/重启 api-server,不触碰 worker
|
|
||||||
scale <n> 调整 external-generation-worker 实例数
|
|
||||||
ps 查看隔离 compose 状态
|
|
||||||
logs [service] 查看隔离 compose 日志
|
|
||||||
down [-v] 停止隔离 compose,-v 会清理数据卷
|
|
||||||
smoke [--force] [--local-binary] [--release]
|
|
||||||
一键执行 build -> publish -> up -> enqueue -> api-update -> enqueue
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,10 @@ set -euo pipefail
|
|||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
用法:
|
用法:
|
||||||
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
||||||
默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。
|
|
||||||
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
||||||
失败时保留维护模式。
|
失败时保留维护模式。
|
||||||
EOF
|
EOF
|
||||||
@@ -224,144 +223,12 @@ ensure_runtime_env_and_dirs() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
list_worker_services() {
|
|
||||||
local pattern="$1"
|
|
||||||
|
|
||||||
if [[ -z "${pattern}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_default_worker_service() {
|
|
||||||
local pattern="$1"
|
|
||||||
local default_service="genarrative-external-generation-worker@1.service"
|
|
||||||
local template_service="genarrative-external-generation-worker@.service"
|
|
||||||
local services=()
|
|
||||||
|
|
||||||
if [[ -z "${pattern}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! systemctl cat "${template_service}" >/dev/null 2>&1; then
|
|
||||||
echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mapfile -t services < <(list_worker_services "${pattern}")
|
|
||||||
if [[ "${#services[@]}" -gt 0 ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}"
|
|
||||||
systemctl enable --now "${default_service}"
|
|
||||||
}
|
|
||||||
|
|
||||||
restart_worker_services() {
|
|
||||||
local pattern="$1"
|
|
||||||
local services=()
|
|
||||||
|
|
||||||
if [[ -z "${pattern}" ]]; then
|
|
||||||
echo "[production-api-deploy] 跳过外部生成 worker 重启。"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
ensure_default_worker_service "${pattern}"
|
|
||||||
mapfile -t services < <(list_worker_services "${pattern}")
|
|
||||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
|
||||||
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}"
|
|
||||||
systemctl restart "${services[@]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_worker_services() {
|
|
||||||
local pattern="$1"
|
|
||||||
local services=()
|
|
||||||
local all_active
|
|
||||||
|
|
||||||
if [[ -z "${pattern}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mapfile -t services < <(list_worker_services "${pattern}")
|
|
||||||
if [[ "${#services[@]}" -eq 0 ]]; then
|
|
||||||
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}"
|
|
||||||
for _ in {1..30}; do
|
|
||||||
all_active=1
|
|
||||||
for service in "${services[@]}"; do
|
|
||||||
if ! systemctl is-active --quiet "${service}"; then
|
|
||||||
all_active=0
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "${all_active}" -eq 1 ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
systemctl --no-pager --full status "${services[@]}" || true
|
|
||||||
echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active,发布失败。" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_worker_controller_service() {
|
|
||||||
local service="$1"
|
|
||||||
|
|
||||||
if [[ -z "${service}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! systemctl cat "${service}" >/dev/null 2>&1; then
|
|
||||||
echo "[production-api-deploy] 缺少外部生成 worker controller systemd 单元: ${service}" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 启用并重启外部生成 worker controller: ${service}"
|
|
||||||
systemctl enable "${service}"
|
|
||||||
systemctl restart "${service}"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_worker_controller_service() {
|
|
||||||
local service="$1"
|
|
||||||
|
|
||||||
if [[ -z "${service}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 等待外部生成 worker controller active: ${service}"
|
|
||||||
for _ in {1..30}; do
|
|
||||||
if systemctl is-active --quiet "${service}"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
systemctl --no-pager --full status "${service}" || true
|
|
||||||
echo "[production-api-deploy] 外部生成 worker controller 未在超时时间内进入 active,发布失败。" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SOURCE_DIR=""
|
SOURCE_DIR=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
RELEASE_ROOT="/opt/genarrative/releases"
|
RELEASE_ROOT="/opt/genarrative/releases"
|
||||||
CURRENT_LINK="/opt/genarrative/current"
|
CURRENT_LINK="/opt/genarrative/current"
|
||||||
SERVICE_NAME="genarrative-api.service"
|
SERVICE_NAME="genarrative-api.service"
|
||||||
WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service"
|
|
||||||
WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service"
|
|
||||||
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
||||||
API_ENV_FILE="/etc/genarrative/api-server.env"
|
API_ENV_FILE="/etc/genarrative/api-server.env"
|
||||||
DATABASE=""
|
DATABASE=""
|
||||||
@@ -394,22 +261,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
SERVICE_NAME="${2:?缺少 --service 的值}"
|
SERVICE_NAME="${2:?缺少 --service 的值}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--worker-service-pattern)
|
|
||||||
WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--no-worker-services)
|
|
||||||
WORKER_SERVICE_PATTERN=""
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--worker-controller-service)
|
|
||||||
WORKER_CONTROLLER_SERVICE="${2:?缺少 --worker-controller-service 的值}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--no-worker-controller)
|
|
||||||
WORKER_CONTROLLER_SERVICE=""
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--health-url)
|
--health-url)
|
||||||
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -532,10 +383,6 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
|
|||||||
|
|
||||||
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
||||||
systemctl restart "${SERVICE_NAME}"
|
systemctl restart "${SERVICE_NAME}"
|
||||||
restart_worker_services "${WORKER_SERVICE_PATTERN}"
|
|
||||||
wait_for_worker_services "${WORKER_SERVICE_PATTERN}"
|
|
||||||
ensure_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
|
|
||||||
wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}"
|
|
||||||
|
|
||||||
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
||||||
for _ in {1..30}; do
|
for _ in {1..30}; do
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml');
|
|||||||
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
const modulePath = resolve(serverRsDir, 'crates/spacetime-module');
|
||||||
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs');
|
||||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||||
const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env';
|
|
||||||
|
|
||||||
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web'];
|
||||||
const SERVICE_ALIASES = new Map([
|
const SERVICE_ALIASES = new Map([
|
||||||
@@ -400,39 +399,6 @@ function requireCommand(command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSccacheRustcWrapper(value) {
|
|
||||||
const wrapper = String(value ?? '').trim();
|
|
||||||
if (!wrapper) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = wrapper.split(/[\\/]/).pop()?.toLowerCase();
|
|
||||||
return command === 'sccache' || command === 'sccache.exe';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLocalRustProcessEnv(env, options = {}) {
|
|
||||||
const mergedEnv = {...env};
|
|
||||||
const wrappers = [
|
|
||||||
String(mergedEnv.RUSTC_WRAPPER ?? '').trim(),
|
|
||||||
String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(),
|
|
||||||
].filter(Boolean);
|
|
||||||
const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper));
|
|
||||||
if (customWrapper) {
|
|
||||||
mergedEnv.RUSTC_WRAPPER = customWrapper;
|
|
||||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper;
|
|
||||||
return mergedEnv;
|
|
||||||
}
|
|
||||||
|
|
||||||
mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
|
||||||
mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS;
|
|
||||||
if (options.log !== false) {
|
|
||||||
console.warn(
|
|
||||||
'[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return mergedEnv;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readWorkspaceSpacetimeVersion() {
|
function readWorkspaceSpacetimeVersion() {
|
||||||
const manifestText = readFileSync(manifestPath, 'utf8');
|
const manifestText = readFileSync(manifestPath, 'utf8');
|
||||||
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec(
|
||||||
@@ -485,7 +451,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({
|
|||||||
[
|
[
|
||||||
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
`本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
||||||
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
||||||
`请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`,
|
`请执行 spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion} 后重新运行本命令。`,
|
||||||
].join(''),
|
].join(''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -513,11 +479,9 @@ function assertReusableSpacetimeProcessVersionMatchesWorkspace({
|
|||||||
[
|
[
|
||||||
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
`正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`,
|
||||||
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
'版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。',
|
||||||
'请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ',
|
'请停止当前 SpacetimeDB 进程,执行 spacetime version use ',
|
||||||
workspaceVersion,
|
workspaceVersion,
|
||||||
' && spacetime version use ',
|
' 后重新运行 npm run dev:spacetime。',
|
||||||
workspaceVersion,
|
|
||||||
',然后重新运行 npm run dev:spacetime。',
|
|
||||||
].join(''),
|
].join(''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -812,7 +776,7 @@ class DevRunner {
|
|||||||
this.writeDevStackState();
|
this.writeDevStackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareLinuxPortRange() {
|
async prepareLinuxPortRange(command) {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1264,7 +1228,7 @@ class DevRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async publishSpacetimeModule() {
|
async publishSpacetimeModule() {
|
||||||
const env = buildLocalRustProcessEnv(this.baseEnv);
|
const env = {...this.baseEnv};
|
||||||
this.prepareMigrationBootstrapSecret(env);
|
this.prepareMigrationBootstrapSecret(env);
|
||||||
|
|
||||||
const args = buildSpacetimePublishArgs({
|
const args = buildSpacetimePublishArgs({
|
||||||
@@ -1327,7 +1291,7 @@ class DevRunner {
|
|||||||
await this.ensureApiServerSpacetimeToken();
|
await this.ensureApiServerSpacetimeToken();
|
||||||
|
|
||||||
const mergedEnv = buildApiServerProcessEnv({
|
const mergedEnv = buildApiServerProcessEnv({
|
||||||
baseEnv: buildLocalRustProcessEnv(this.baseEnv),
|
baseEnv: this.baseEnv,
|
||||||
options: this.options,
|
options: this.options,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
});
|
});
|
||||||
@@ -2160,20 +2124,19 @@ function buildApiServerProcessEnv({baseEnv, options, state}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
DevRunner,
|
||||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||||
assertSpacetimeToolVersionMatchesWorkspace,
|
assertSpacetimeToolVersionMatchesWorkspace,
|
||||||
buildApiServerProcessEnv,
|
buildApiServerProcessEnv,
|
||||||
buildDevStackSnapshot,
|
buildDevStackSnapshot,
|
||||||
buildLocalRustProcessEnv,
|
|
||||||
buildSpacetimePublishArgs,
|
buildSpacetimePublishArgs,
|
||||||
createDevServerSpawnOptions,
|
createDevServerSpawnOptions,
|
||||||
createWatchConfigs,
|
createWatchConfigs,
|
||||||
DevRunner,
|
|
||||||
isDirectModuleExecution,
|
|
||||||
isSpacetimePublishPermissionError,
|
isSpacetimePublishPermissionError,
|
||||||
|
isDirectModuleExecution,
|
||||||
normalizeCargoVersionRequirement,
|
normalizeCargoVersionRequirement,
|
||||||
parseArgs,
|
|
||||||
parseSpacetimeToolVersion,
|
parseSpacetimeToolVersion,
|
||||||
|
parseArgs,
|
||||||
resolveDevStackStatePath,
|
resolveDevStackStatePath,
|
||||||
shouldAcceptWatchEvent,
|
shouldAcceptWatchEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ import {join} from 'node:path';
|
|||||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DevRunner,
|
||||||
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
assertReusableSpacetimeProcessVersionMatchesWorkspace,
|
||||||
assertSpacetimeToolVersionMatchesWorkspace,
|
assertSpacetimeToolVersionMatchesWorkspace,
|
||||||
buildApiServerProcessEnv,
|
buildApiServerProcessEnv,
|
||||||
buildDevStackSnapshot,
|
buildDevStackSnapshot,
|
||||||
buildLocalRustProcessEnv,
|
|
||||||
buildSpacetimePublishArgs,
|
buildSpacetimePublishArgs,
|
||||||
createDevServerSpawnOptions,
|
createDevServerSpawnOptions,
|
||||||
createWatchConfigs,
|
createWatchConfigs,
|
||||||
DevRunner,
|
|
||||||
isDirectModuleExecution,
|
isDirectModuleExecution,
|
||||||
isSpacetimePublishPermissionError,
|
isSpacetimePublishPermissionError,
|
||||||
normalizeCargoVersionRequirement,
|
normalizeCargoVersionRequirement,
|
||||||
parseArgs,
|
|
||||||
parseSpacetimeToolVersion,
|
parseSpacetimeToolVersion,
|
||||||
|
parseArgs,
|
||||||
resolveDevStackStatePath,
|
resolveDevStackStatePath,
|
||||||
shouldAcceptWatchEvent,
|
shouldAcceptWatchEvent,
|
||||||
} from './dev.mjs';
|
} from './dev.mjs';
|
||||||
@@ -186,35 +185,6 @@ describe('dev scheduler api-server env', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dev scheduler Rust build env', () => {
|
|
||||||
test('local dev Rust env bypasses project sccache wrapper', () => {
|
|
||||||
const env = buildLocalRustProcessEnv(
|
|
||||||
{
|
|
||||||
RUSTC_WRAPPER: '/usr/bin/sccache',
|
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
|
||||||
},
|
|
||||||
{log: false},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache');
|
|
||||||
expect(env.RUSTC_WRAPPER).not.toBe('sccache');
|
|
||||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('local dev Rust env keeps healthy custom wrapper untouched', () => {
|
|
||||||
const env = buildLocalRustProcessEnv(
|
|
||||||
{
|
|
||||||
RUSTC_WRAPPER: 'custom-wrapper',
|
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
|
|
||||||
},
|
|
||||||
{log: false},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(env.RUSTC_WRAPPER).toBe('custom-wrapper');
|
|
||||||
expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dev scheduler stack state file', () => {
|
describe('dev scheduler stack state file', () => {
|
||||||
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
test('状态文件路径固定在根目录 .app/dev-stack.json', () => {
|
||||||
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe(
|
||||||
@@ -434,24 +404,24 @@ describe('dev scheduler watch routing', () => {
|
|||||||
|
|
||||||
describe('dev scheduler spacetime refresh', () => {
|
describe('dev scheduler spacetime refresh', () => {
|
||||||
test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => {
|
test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => {
|
||||||
expect(normalizeCargoVersionRequirement('=2.5.0')).toBe('2.5.0');
|
expect(normalizeCargoVersionRequirement('=2.4.1')).toBe('2.4.1');
|
||||||
expect(normalizeCargoVersionRequirement('2.5.0')).toBe('2.5.0');
|
expect(normalizeCargoVersionRequirement('2.4.1')).toBe('2.4.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('解析 spacetime --version 输出里的 tool version', () => {
|
test('解析 spacetime --version 输出里的 tool version', () => {
|
||||||
const version = parseSpacetimeToolVersion(`
|
const version = parseSpacetimeToolVersion(`
|
||||||
A new version of SpacetimeDB is available: v2.5.0 (current: v2.4.1)
|
A new version of SpacetimeDB is available: v2.4.1 (current: v2.4.0)
|
||||||
spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0;
|
spacetimedb tool version 2.4.1; spacetimedb-lib version 2.4.1;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
expect(version).toBe('2.5.0');
|
expect(version).toBe('2.4.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
|
test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertSpacetimeToolVersionMatchesWorkspace({
|
assertSpacetimeToolVersionMatchesWorkspace({
|
||||||
toolVersion: '2.1.0',
|
toolVersion: '2.1.0',
|
||||||
workspaceVersion: '2.5.0',
|
workspaceVersion: '2.4.1',
|
||||||
}),
|
}),
|
||||||
).toThrow('procedure 返回值 BSATN 反序列化失败');
|
).toThrow('procedure 返回值 BSATN 反序列化失败');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ set -euo pipefail
|
|||||||
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
|
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
|
||||||
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
|
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
|
||||||
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
|
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
|
||||||
WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
|
|
||||||
CONTROLLER_ENV_FILE="${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}"
|
|
||||||
GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
|
GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}"
|
||||||
GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
|
GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}"
|
||||||
GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
|
GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}"
|
||||||
@@ -244,47 +242,6 @@ sync_otelcol_install() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_otelcol_runtime() {
|
|
||||||
if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
|
||||||
echo "+ ensure system user/group otelcol"
|
|
||||||
echo "+ install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol"
|
|
||||||
echo "+ install -d -m 0755 -o root -g root /etc/otelcol"
|
|
||||||
echo "+ install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative"
|
|
||||||
echo "+ install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! getent group otelcol >/dev/null 2>&1; then
|
|
||||||
groupadd --system otelcol
|
|
||||||
fi
|
|
||||||
if ! id otelcol >/dev/null 2>&1; then
|
|
||||||
useradd --system --gid otelcol --home-dir /var/lib/otelcol --shell /usr/sbin/nologin otelcol
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol
|
|
||||||
install -d -m 0755 -o root -g root /etc/otelcol
|
|
||||||
install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative
|
|
||||||
install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml
|
|
||||||
chown root:root /etc/otelcol/genarrative-debug.yaml
|
|
||||||
}
|
|
||||||
|
|
||||||
stamp_database_backup_timer_now() {
|
|
||||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
|
||||||
echo "+ install -d -m 0755 /var/lib/systemd/timers"
|
|
||||||
echo "+ touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -d -m 0755 /var/lib/systemd/timers
|
|
||||||
# 避免 provision 在当天 03:20 之后启动 timer 时因 Persistent=true 立刻补跑冷备份、
|
|
||||||
# 进而在初始化/发布窗口中意外停止 spacetimedb.service。
|
|
||||||
touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_spacetime_install() {
|
sync_spacetime_install() {
|
||||||
local root_dir="$1"
|
local root_dir="$1"
|
||||||
local target_bin_dir="${root_dir}/bin/current"
|
local target_bin_dir="${root_dir}/bin/current"
|
||||||
@@ -501,7 +458,6 @@ ensure_spacetime_owner_client_token() {
|
|||||||
echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..."
|
echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 中文注释:这里是 provision 内部为 spacetimedb 运行用户隔离 CLI 登录态的受控用法,不作为人工 spacetime 命令示例。
|
|
||||||
if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then
|
if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then
|
||||||
echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2
|
echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2
|
||||||
printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2
|
printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2
|
||||||
@@ -580,14 +536,6 @@ render_api_env_example() {
|
|||||||
deploy/env/api-server.env.example
|
deploy/env/api-server.env.example
|
||||||
}
|
}
|
||||||
|
|
||||||
render_external_generation_worker_env_example() {
|
|
||||||
cat deploy/env/external-generation-worker.env.example
|
|
||||||
}
|
|
||||||
|
|
||||||
render_external_generation_controller_env_example() {
|
|
||||||
cat deploy/env/external-generation-controller.env.example
|
|
||||||
}
|
|
||||||
|
|
||||||
render_otelcol_service() {
|
render_otelcol_service() {
|
||||||
cat deploy/systemd/otelcol-contrib.service
|
cat deploy/systemd/otelcol-contrib.service
|
||||||
}
|
}
|
||||||
@@ -774,30 +722,6 @@ render_api_service() {
|
|||||||
deploy/systemd/genarrative-api.service
|
deploy/systemd/genarrative-api.service
|
||||||
}
|
}
|
||||||
|
|
||||||
render_external_generation_worker_service() {
|
|
||||||
local current_escaped api_env_escaped worker_env_escaped
|
|
||||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
|
||||||
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
|
||||||
worker_env_escaped="$(escape_sed_replacement "${WORKER_ENV_FILE}")"
|
|
||||||
sed \
|
|
||||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
|
||||||
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
|
|
||||||
-e "s|/etc/genarrative/external-generation-worker.env|${worker_env_escaped}|g" \
|
|
||||||
deploy/systemd/genarrative-external-generation-worker@.service
|
|
||||||
}
|
|
||||||
|
|
||||||
render_external_generation_controller_service() {
|
|
||||||
local current_escaped api_env_escaped controller_env_escaped
|
|
||||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
|
||||||
api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
|
||||||
controller_env_escaped="$(escape_sed_replacement "${CONTROLLER_ENV_FILE}")"
|
|
||||||
sed \
|
|
||||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
|
||||||
-e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \
|
|
||||||
-e "s|/etc/genarrative/external-generation-controller.env|${controller_env_escaped}|g" \
|
|
||||||
deploy/systemd/genarrative-external-generation-controller.service
|
|
||||||
}
|
|
||||||
|
|
||||||
render_database_backup_service() {
|
render_database_backup_service() {
|
||||||
local current_escaped env_escaped
|
local current_escaped env_escaped
|
||||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||||
@@ -818,8 +742,6 @@ render_health_patrol_service() {
|
|||||||
|
|
||||||
require_path deploy/systemd/spacetimedb.service
|
require_path deploy/systemd/spacetimedb.service
|
||||||
require_path deploy/systemd/genarrative-api.service
|
require_path deploy/systemd/genarrative-api.service
|
||||||
require_path deploy/systemd/genarrative-external-generation-worker@.service
|
|
||||||
require_path deploy/systemd/genarrative-external-generation-controller.service
|
|
||||||
require_path deploy/systemd/genarrative-database-backup.service
|
require_path deploy/systemd/genarrative-database-backup.service
|
||||||
require_path deploy/systemd/genarrative-database-backup.timer
|
require_path deploy/systemd/genarrative-database-backup.timer
|
||||||
require_path deploy/systemd/genarrative-health-patrol.service
|
require_path deploy/systemd/genarrative-health-patrol.service
|
||||||
@@ -830,8 +752,6 @@ require_path deploy/nginx/genarrative.conf
|
|||||||
require_path deploy/nginx/genarrative-dev-http.conf
|
require_path deploy/nginx/genarrative-dev-http.conf
|
||||||
require_path deploy/nginx/snippets/genarrative-maintenance.conf
|
require_path deploy/nginx/snippets/genarrative-maintenance.conf
|
||||||
require_path deploy/env/api-server.env.example
|
require_path deploy/env/api-server.env.example
|
||||||
require_path deploy/env/external-generation-worker.env.example
|
|
||||||
require_path deploy/env/external-generation-controller.env.example
|
|
||||||
require_path scripts/deploy/maintenance-on.sh
|
require_path scripts/deploy/maintenance-on.sh
|
||||||
require_path scripts/deploy/maintenance-off.sh
|
require_path scripts/deploy/maintenance-off.sh
|
||||||
require_path scripts/deploy/maintenance-status.sh
|
require_path scripts/deploy/maintenance-status.sh
|
||||||
@@ -875,25 +795,19 @@ sync_spacetime_install "${SPACETIME_ROOT}"
|
|||||||
|
|
||||||
spacetimedb_service="$(mktemp)"
|
spacetimedb_service="$(mktemp)"
|
||||||
api_service="$(mktemp)"
|
api_service="$(mktemp)"
|
||||||
external_generation_worker_service="$(mktemp)"
|
|
||||||
external_generation_controller_service="$(mktemp)"
|
|
||||||
database_backup_service="$(mktemp)"
|
database_backup_service="$(mktemp)"
|
||||||
health_patrol_service="$(mktemp)"
|
health_patrol_service="$(mktemp)"
|
||||||
render_spacetimedb_service >"${spacetimedb_service}"
|
render_spacetimedb_service >"${spacetimedb_service}"
|
||||||
render_api_service >"${api_service}"
|
render_api_service >"${api_service}"
|
||||||
render_external_generation_worker_service >"${external_generation_worker_service}"
|
|
||||||
render_external_generation_controller_service >"${external_generation_controller_service}"
|
|
||||||
render_database_backup_service >"${database_backup_service}"
|
render_database_backup_service >"${database_backup_service}"
|
||||||
render_health_patrol_service >"${health_patrol_service}"
|
render_health_patrol_service >"${health_patrol_service}"
|
||||||
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
|
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
|
||||||
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
|
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
|
||||||
install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644
|
|
||||||
install_file "${external_generation_controller_service}" /etc/systemd/system/genarrative-external-generation-controller.service 0644
|
|
||||||
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
|
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
|
||||||
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
|
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
|
||||||
install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644
|
install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644
|
||||||
install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/genarrative-health-patrol.timer 0644
|
install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/genarrative-health-patrol.timer 0644
|
||||||
rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${database_backup_service}" "${health_patrol_service}"
|
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}" "${health_patrol_service}"
|
||||||
|
|
||||||
if [[ ! -f "${API_ENV_FILE}" ]]; then
|
if [[ ! -f "${API_ENV_FILE}" ]]; then
|
||||||
echo "+ create ${API_ENV_FILE} from example"
|
echo "+ create ${API_ENV_FILE} from example"
|
||||||
@@ -907,31 +821,8 @@ else
|
|||||||
fi
|
fi
|
||||||
ensure_api_runtime_env_defaults
|
ensure_api_runtime_env_defaults
|
||||||
|
|
||||||
if [[ ! -f "${WORKER_ENV_FILE}" ]]; then
|
|
||||||
echo "+ create ${WORKER_ENV_FILE} from example"
|
|
||||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
|
||||||
render_external_generation_worker_env_example >"${WORKER_ENV_FILE}"
|
|
||||||
chmod 0600 "${WORKER_ENV_FILE}"
|
|
||||||
chown root:root "${WORKER_ENV_FILE}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then
|
|
||||||
echo "+ create ${CONTROLLER_ENV_FILE} from example"
|
|
||||||
if [[ "${DRY_RUN}" != "true" ]]; then
|
|
||||||
render_external_generation_controller_env_example >"${CONTROLLER_ENV_FILE}"
|
|
||||||
chmod 0600 "${CONTROLLER_ENV_FILE}"
|
|
||||||
chown root:root "${CONTROLLER_ENV_FILE}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[server-provision] 已存在 controller 环境文件,保留不覆盖: ${CONTROLLER_ENV_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||||
sync_otelcol_install
|
sync_otelcol_install
|
||||||
ensure_otelcol_runtime
|
|
||||||
otelcol_service="$(mktemp)"
|
otelcol_service="$(mktemp)"
|
||||||
render_otelcol_service >"${otelcol_service}"
|
render_otelcol_service >"${otelcol_service}"
|
||||||
install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
|
install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
|
||||||
@@ -951,9 +842,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
|||||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||||
run_cmd systemctl enable otelcol-contrib.service
|
run_cmd systemctl enable otelcol-contrib.service
|
||||||
fi
|
fi
|
||||||
stamp_database_backup_timer_now
|
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-health-patrol.timer
|
||||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer
|
|
||||||
run_cmd systemctl start genarrative-database-backup.timer
|
|
||||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||||
run_cmd systemctl restart otelcol-contrib.service
|
run_cmd systemctl restart otelcol-contrib.service
|
||||||
fi
|
fi
|
||||||
@@ -962,12 +851,8 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
|||||||
ensure_spacetime_owner_client_token
|
ensure_spacetime_owner_client_token
|
||||||
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
|
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
|
||||||
run_cmd systemctl restart genarrative-api.service
|
run_cmd systemctl restart genarrative-api.service
|
||||||
run_cmd systemctl enable --now genarrative-external-generation-worker@1.service
|
|
||||||
run_cmd systemctl restart genarrative-external-generation-worker@1.service
|
|
||||||
run_cmd systemctl enable --now genarrative-external-generation-controller.service
|
|
||||||
run_cmd systemctl restart genarrative-external-generation-controller.service
|
|
||||||
else
|
else
|
||||||
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会启用并启动默认 worker 与 controller。"
|
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ const DEFAULT_PUBLIC_PATHS = [
|
|||||||
|
|
||||||
const DEFAULT_SERVICES = [
|
const DEFAULT_SERVICES = [
|
||||||
'genarrative-api.service',
|
'genarrative-api.service',
|
||||||
'genarrative-external-generation-controller.service',
|
|
||||||
'spacetimedb.service',
|
'spacetimedb.service',
|
||||||
'nginx.service',
|
'nginx.service',
|
||||||
];
|
];
|
||||||
const WORKER_SERVICE_PATTERN = 'genarrative-external-generation-worker@*.service';
|
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
console.log(`Usage:
|
console.log(`Usage:
|
||||||
@@ -218,61 +216,6 @@ async function checkService(serviceName, timeoutMs) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkActiveWorkerInstances(config) {
|
|
||||||
const result = await runCommand(
|
|
||||||
'systemctl',
|
|
||||||
[
|
|
||||||
'list-units',
|
|
||||||
WORKER_SERVICE_PATTERN,
|
|
||||||
'--type=service',
|
|
||||||
'--state=active',
|
|
||||||
'--no-legend',
|
|
||||||
'--plain',
|
|
||||||
'--no-pager',
|
|
||||||
],
|
|
||||||
config.timeoutMs,
|
|
||||||
);
|
|
||||||
if (result.code !== 0) {
|
|
||||||
return checkResult(
|
|
||||||
'service:external-generation-workers',
|
|
||||||
'CRITICAL',
|
|
||||||
'无法枚举外部生成 worker 实例',
|
|
||||||
{
|
|
||||||
command: result.command,
|
|
||||||
stderr: result.stderr.trim() || result.error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const services = result.stdout
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim().split(/\s+/u)[0])
|
|
||||||
.filter((service) =>
|
|
||||||
/^genarrative-external-generation-worker@.+\.service$/u.test(service),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (services.length === 0) {
|
|
||||||
return checkResult(
|
|
||||||
'service:external-generation-workers',
|
|
||||||
'CRITICAL',
|
|
||||||
'没有 active 的外部生成 worker 实例',
|
|
||||||
{
|
|
||||||
command: result.command,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkResult(
|
|
||||||
'service:external-generation-workers',
|
|
||||||
'OK',
|
|
||||||
`${services.length} 个 worker active`,
|
|
||||||
{
|
|
||||||
command: result.command,
|
|
||||||
services,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestUrl(url, timeoutMs) {
|
function requestUrl(url, timeoutMs) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -367,10 +310,6 @@ async function checkRecentJournal(config) {
|
|||||||
'-u',
|
'-u',
|
||||||
'genarrative-api.service',
|
'genarrative-api.service',
|
||||||
'-u',
|
'-u',
|
||||||
'genarrative-external-generation-controller.service',
|
|
||||||
'-u',
|
|
||||||
WORKER_SERVICE_PATTERN,
|
|
||||||
'-u',
|
|
||||||
'spacetimedb.service',
|
'spacetimedb.service',
|
||||||
'-u',
|
'-u',
|
||||||
'nginx.service',
|
'nginx.service',
|
||||||
@@ -487,7 +426,6 @@ async function main() {
|
|||||||
for (const serviceName of DEFAULT_SERVICES) {
|
for (const serviceName of DEFAULT_SERVICES) {
|
||||||
checks.push(await checkService(serviceName, config.timeoutMs));
|
checks.push(await checkService(serviceName, config.timeoutMs));
|
||||||
}
|
}
|
||||||
checks.push(await checkActiveWorkerInstances(config));
|
|
||||||
|
|
||||||
checks.push(await checkHttp('api:/healthz', joinUrl(config.apiBaseUrl, '/healthz'), config));
|
checks.push(await checkHttp('api:/healthz', joinUrl(config.apiBaseUrl, '/healthz'), config));
|
||||||
checks.push(await checkHttp('api:/readyz', joinUrl(config.apiBaseUrl, '/readyz'), config));
|
checks.push(await checkHttp('api:/readyz', joinUrl(config.apiBaseUrl, '/readyz'), config));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr
|
|||||||
OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}"
|
OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}"
|
||||||
OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}"
|
OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}"
|
||||||
SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}"
|
SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}"
|
||||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}"
|
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1}"
|
||||||
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}"
|
SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}"
|
||||||
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}"
|
SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}"
|
||||||
SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}"
|
SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}"
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# 本地 RAG
|
|
||||||
|
|
||||||
本目录提供项目文档的本地 RAG 索引脚本,主要供 Agent 在执行任务前后检索项目上下文使用。它不是新的人工阅读入口;开发者仍按 `AGENTS.md`、`docs/README.md` 和 `docs/project-memory/` 阅读项目文档。
|
|
||||||
|
|
||||||
项目默认不安装 RAG 运行时依赖,也不把 LanceDB、Transformers.js 或本地模型写入根 `package.json`。
|
|
||||||
|
|
||||||
## 运行时依赖
|
|
||||||
|
|
||||||
RAG 运行时依赖安装在 gitignored 的 `.rag/runtime/`,模型缓存和向量库也都在 `.rag/` 下。
|
|
||||||
|
|
||||||
Agent 需要启用 RAG 检索时,应先询问用户是否安装本地依赖。用户确认后执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .rag/runtime
|
|
||||||
npm init -y --prefix .rag/runtime
|
|
||||||
npm install --prefix .rag/runtime @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
不要把这些依赖加入根 `package.json`。
|
|
||||||
|
|
||||||
## 索引
|
|
||||||
|
|
||||||
首次运行会下载本地 embedding 模型到 `.rag/models/`。默认模型为 `Xenova/multilingual-e5-small`,适合中英文混合文档。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:index
|
|
||||||
```
|
|
||||||
|
|
||||||
小样本 smoke:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:index -- --limit-files 3
|
|
||||||
```
|
|
||||||
|
|
||||||
只查看分片,不加载模型:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:index -- --limit-files 3 --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
## 搜索
|
|
||||||
|
|
||||||
默认输出 Agent 上下文包,包含来源、分数、候选上下文和使用规则:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8
|
|
||||||
```
|
|
||||||
|
|
||||||
可限制上下文包大小:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --limit 8 --max-chars 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
可输出结构化格式,便于 Agent 或其它工具解析:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format json
|
|
||||||
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format jsonl
|
|
||||||
```
|
|
||||||
|
|
||||||
如只想看短摘要,可使用旧式文本结果:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run rag:search -- --query "SpacetimeDB schema 变更默认值" --format text
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent 使用规则:
|
|
||||||
|
|
||||||
- 把 RAG 输出视为候选上下文,不直接当作最终事实。
|
|
||||||
- 需要精确改代码或文档时,仍要打开对应源文件核对。
|
|
||||||
- 来源冲突时,以当前代码和最新 `docs/` 为准。
|
|
||||||
|
|
||||||
## 索引范围
|
|
||||||
|
|
||||||
索引范围由 `scripts/rag/rag-config.json` 配置,默认包含:
|
|
||||||
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `CONTEXT.md`,如果存在
|
|
||||||
- `docs/project-memory/`
|
|
||||||
- `docs/`
|
|
||||||
|
|
||||||
`.hermes/` 是 Hermes 工具目录,不作为项目知识库索引源。
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { mkdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildChunkId,
|
|
||||||
chunkText,
|
|
||||||
createEmbedder,
|
|
||||||
extractTitle,
|
|
||||||
hasFlag,
|
|
||||||
listSourceFiles,
|
|
||||||
loadRagRuntime,
|
|
||||||
parseLimitFiles,
|
|
||||||
readConfig,
|
|
||||||
repoRoot,
|
|
||||||
} from './rag-utils.mjs';
|
|
||||||
|
|
||||||
const config = readConfig();
|
|
||||||
const limitFiles = parseLimitFiles(process.argv);
|
|
||||||
const dryRun = hasFlag(process.argv, '--dry-run');
|
|
||||||
|
|
||||||
const files = listSourceFiles(config, limitFiles);
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const text = readFileSync(file.path, 'utf8');
|
|
||||||
const title = extractTitle(text, file.rel);
|
|
||||||
for (const chunk of chunkText(text, config.chunk ?? {})) {
|
|
||||||
rows.push({
|
|
||||||
id: buildChunkId(file.rel, chunk.index),
|
|
||||||
path: file.rel,
|
|
||||||
title,
|
|
||||||
chunk_index: chunk.index,
|
|
||||||
source_weight: file.weight,
|
|
||||||
text: chunk.text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[rag:index] source files=${files.length}, chunks=${rows.length}`);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
for (const row of rows.slice(0, 10)) {
|
|
||||||
console.log(`- ${row.id} ${row.title}`);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw new Error('No RAG chunks found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lancedb, transformers } = await loadRagRuntime(config);
|
|
||||||
const embed = await createEmbedder(transformers, config.model);
|
|
||||||
|
|
||||||
for (let index = 0; index < rows.length; index += 1) {
|
|
||||||
rows[index].vector = await embed(rows[index].text, 'passage');
|
|
||||||
if ((index + 1) % 25 === 0 || index + 1 === rows.length) {
|
|
||||||
console.log(`[rag:index] embedded ${index + 1}/${rows.length}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(join(repoRoot, config.databaseDir), { recursive: true });
|
|
||||||
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
|
|
||||||
await db.createTable(config.tableName, rows, { mode: 'overwrite' });
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[rag:index] wrote table=${config.tableName}, db=${config.databaseDir}, model=${config.model}`,
|
|
||||||
);
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"runtimeDir": ".rag/runtime",
|
|
||||||
"databaseDir": ".rag/lancedb",
|
|
||||||
"modelCacheDir": ".rag/models",
|
|
||||||
"tableName": "project_docs",
|
|
||||||
"model": "Xenova/multilingual-e5-small",
|
|
||||||
"chunk": {
|
|
||||||
"maxChars": 1600,
|
|
||||||
"overlapChars": 220
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"path": "AGENTS.md",
|
|
||||||
"weight": 1.4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "CONTEXT.md",
|
|
||||||
"weight": 1.3,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs/project-memory",
|
|
||||||
"weight": 1.35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "docs",
|
|
||||||
"weight": 1.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
".git/",
|
|
||||||
".rag/",
|
|
||||||
".hermes/",
|
|
||||||
".codegraph/",
|
|
||||||
".app/",
|
|
||||||
"node_modules/",
|
|
||||||
"dist/",
|
|
||||||
"build/",
|
|
||||||
"coverage/",
|
|
||||||
"logs/",
|
|
||||||
"output/",
|
|
||||||
"server-rs/target/",
|
|
||||||
"server-rs/target-",
|
|
||||||
"tmp/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
||||||
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
||||||
|
|
||||||
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
||||||
export const configPath = join(repoRoot, 'scripts/rag/rag-config.json');
|
|
||||||
|
|
||||||
export function readConfig() {
|
|
||||||
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePath(filePath) {
|
|
||||||
return filePath.replace(/\\/gu, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function repoRelative(filePath) {
|
|
||||||
return normalizePath(relative(repoRoot, filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveRepoPath(filePath) {
|
|
||||||
return resolve(repoRoot, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRuntimeNodeModules(config) {
|
|
||||||
return join(repoRoot, config.runtimeDir, 'node_modules');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertLocalRuntime(config) {
|
|
||||||
const runtimeModules = getRuntimeNodeModules(config);
|
|
||||||
const hasLance = existsSync(join(runtimeModules, '@lancedb/lancedb'));
|
|
||||||
const hasTransformers = existsSync(join(runtimeModules, '@huggingface/transformers'));
|
|
||||||
|
|
||||||
if (hasLance && hasTransformers) {
|
|
||||||
return runtimeModules;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
'本地 RAG 运行时依赖尚未安装。',
|
|
||||||
'按项目约定,RAG 依赖不进入根 package.json,也不默认安装。',
|
|
||||||
'需要启用 RAG 时,Agent 必须先询问用户,然后在本地 gitignored 目录安装:',
|
|
||||||
'',
|
|
||||||
` mkdir -p ${config.runtimeDir}`,
|
|
||||||
` npm init -y --prefix ${config.runtimeDir}`,
|
|
||||||
` npm install --prefix ${config.runtimeDir} @lancedb/lancedb@0.30.0 @huggingface/transformers@4.2.0`,
|
|
||||||
'',
|
|
||||||
`当前检查目录:${runtimeModules}`,
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRagRuntime(config) {
|
|
||||||
const runtimeModules = assertLocalRuntime(config);
|
|
||||||
const lancedb = await import(
|
|
||||||
pathToFileURL(join(runtimeModules, '@lancedb/lancedb/dist/index.js')).href
|
|
||||||
);
|
|
||||||
const transformers = await import(
|
|
||||||
pathToFileURL(
|
|
||||||
join(runtimeModules, '@huggingface/transformers/dist/transformers.node.mjs'),
|
|
||||||
).href
|
|
||||||
);
|
|
||||||
|
|
||||||
transformers.env.cacheDir = join(repoRoot, config.modelCacheDir);
|
|
||||||
transformers.env.useFSCache = true;
|
|
||||||
transformers.env.allowRemoteModels = true;
|
|
||||||
|
|
||||||
return { lancedb, transformers };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listSourceFiles(config, limitFiles = Number.POSITIVE_INFINITY) {
|
|
||||||
const excluded = config.exclude ?? [];
|
|
||||||
const files = [];
|
|
||||||
const seen = new Set();
|
|
||||||
|
|
||||||
for (const source of config.sources ?? []) {
|
|
||||||
const sourcePath = resolveRepoPath(source.path);
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
if (!source.optional) {
|
|
||||||
throw new Error(`RAG source not found: ${source.path}`);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePath of walkTextFiles(sourcePath, excluded)) {
|
|
||||||
const rel = repoRelative(filePath);
|
|
||||||
if (seen.has(rel)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(rel);
|
|
||||||
files.push({ path: filePath, rel, weight: source.weight ?? 1 });
|
|
||||||
if (files.length >= limitFiles) {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function walkTextFiles(targetPath, excluded) {
|
|
||||||
const stat = statSync(targetPath);
|
|
||||||
if (stat.isFile()) {
|
|
||||||
return shouldReadFile(targetPath, excluded) ? [targetPath] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = [];
|
|
||||||
const walk = (dir) => {
|
|
||||||
for (const name of readdirSync(dir)) {
|
|
||||||
const child = join(dir, name);
|
|
||||||
const rel = `${repoRelative(child)}${statSync(child).isDirectory() ? '/' : ''}`;
|
|
||||||
if (excluded.some((prefix) => rel.startsWith(prefix))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childStat = statSync(child);
|
|
||||||
if (childStat.isDirectory()) {
|
|
||||||
walk(child);
|
|
||||||
} else if (shouldReadFile(child, excluded)) {
|
|
||||||
files.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(targetPath);
|
|
||||||
return files.sort((a, b) => repoRelative(a).localeCompare(repoRelative(b)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldReadFile(filePath, excluded) {
|
|
||||||
const rel = repoRelative(filePath);
|
|
||||||
if (excluded.some((prefix) => rel.startsWith(prefix))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (rel === 'AGENTS.md' || rel === 'CONTEXT.md' || rel.endsWith('/README.md')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return new Set(['.md', '.txt']).has(extname(filePath).toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chunkText(text, options) {
|
|
||||||
const maxChars = options.maxChars ?? 1600;
|
|
||||||
const overlapChars = options.overlapChars ?? 220;
|
|
||||||
const normalized = text.replace(/\r\n?/gu, '\n').trim();
|
|
||||||
if (!normalized) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = normalized.split(/\n(?=#{1,6}\s+)/u);
|
|
||||||
const chunks = [];
|
|
||||||
let current = '';
|
|
||||||
|
|
||||||
const pushCurrent = () => {
|
|
||||||
const trimmed = current.trim();
|
|
||||||
if (trimmed) {
|
|
||||||
chunks.push(trimmed);
|
|
||||||
}
|
|
||||||
current = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if ((current.length + block.length + 2) <= maxChars) {
|
|
||||||
current = current ? `${current}\n\n${block}` : block;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pushCurrent();
|
|
||||||
if (block.length <= maxChars) {
|
|
||||||
current = block;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (let start = 0; start < block.length; start += Math.max(1, maxChars - overlapChars)) {
|
|
||||||
chunks.push(block.slice(start, start + maxChars).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pushCurrent();
|
|
||||||
|
|
||||||
return chunks.map((chunk, index) => ({ index, text: chunk }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildChunkId(filePath, chunkIndex) {
|
|
||||||
return `${filePath}#${chunkIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractTitle(text, fallback) {
|
|
||||||
const title = text.match(/^#\s+(.+)$/mu)?.[1]?.trim();
|
|
||||||
return title || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEmbedder(transformers, model) {
|
|
||||||
const extractor = await transformers.pipeline('feature-extraction', model);
|
|
||||||
|
|
||||||
return async function embed(text, type) {
|
|
||||||
const prefix = type === 'query' ? 'query: ' : 'passage: ';
|
|
||||||
const output = await extractor(`${prefix}${text}`, {
|
|
||||||
pooling: 'mean',
|
|
||||||
normalize: true,
|
|
||||||
});
|
|
||||||
return Array.from(output.data, Number);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseLimitFiles(argv) {
|
|
||||||
const value = readArg(argv, '--limit-files');
|
|
||||||
if (!value) {
|
|
||||||
return Number.POSITIVE_INFINITY;
|
|
||||||
}
|
|
||||||
const parsed = Number(value);
|
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
||||||
throw new Error(`Invalid --limit-files value: ${value}`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readArg(argv, name, fallback = undefined) {
|
|
||||||
const index = argv.indexOf(name);
|
|
||||||
if (index === -1) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return argv[index + 1] ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasFlag(argv, name) {
|
|
||||||
return argv.includes(name);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createEmbedder,
|
|
||||||
hasFlag,
|
|
||||||
loadRagRuntime,
|
|
||||||
readArg,
|
|
||||||
readConfig,
|
|
||||||
repoRoot,
|
|
||||||
} from './rag-utils.mjs';
|
|
||||||
|
|
||||||
const config = readConfig();
|
|
||||||
const query = readArg(process.argv, '--query') ?? process.argv.slice(2).join(' ');
|
|
||||||
const limit = Number(readArg(process.argv, '--limit', '8'));
|
|
||||||
const maxChars = Number(readArg(process.argv, '--max-chars', '12000'));
|
|
||||||
const format = readArg(process.argv, '--format', 'context');
|
|
||||||
const includeText = !hasFlag(process.argv, '--no-text');
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
throw new Error(
|
|
||||||
'Usage: node scripts/rag/search-docs.mjs --query "搜索内容" [--limit 8] [--format context|json|jsonl|text] [--max-chars 12000]',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['context', 'json', 'jsonl', 'text'].includes(format)) {
|
|
||||||
throw new Error(`Unsupported --format value: ${format}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(limit) || limit <= 0 || !Number.isInteger(limit)) {
|
|
||||||
throw new Error(`Invalid --limit value: ${limit}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(maxChars) || maxChars <= 0 || !Number.isInteger(maxChars)) {
|
|
||||||
throw new Error(`Invalid --max-chars value: ${maxChars}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lancedb, transformers } = await loadRagRuntime(config);
|
|
||||||
const embed = await createEmbedder(transformers, config.model);
|
|
||||||
const queryVector = await embed(query, 'query');
|
|
||||||
|
|
||||||
const db = await lancedb.connect(join(repoRoot, config.databaseDir));
|
|
||||||
const table = await db.openTable(config.tableName);
|
|
||||||
const rawResults = await table
|
|
||||||
.vectorSearch(queryVector)
|
|
||||||
.select(['id', 'path', 'title', 'chunk_index', 'source_weight', 'text', '_distance'])
|
|
||||||
.limit(Math.max(limit * 3, limit))
|
|
||||||
.toArray();
|
|
||||||
|
|
||||||
const results = rawResults
|
|
||||||
.map((row) => ({
|
|
||||||
...row,
|
|
||||||
score: (1 / (1 + Number(row._distance ?? 0))) * Number(row.source_weight ?? 1),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, limit);
|
|
||||||
|
|
||||||
const payload = buildAgentPayload(query, results, {
|
|
||||||
model: config.model,
|
|
||||||
tableName: config.tableName,
|
|
||||||
maxChars,
|
|
||||||
includeText,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (format === 'json') {
|
|
||||||
console.log(JSON.stringify(payload, null, 2));
|
|
||||||
} else if (format === 'jsonl') {
|
|
||||||
for (const result of payload.results) {
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
}
|
|
||||||
} else if (format === 'text') {
|
|
||||||
printTextResults(payload.results);
|
|
||||||
} else {
|
|
||||||
console.log(formatContextPack(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAgentPayload(searchQuery, rows, options) {
|
|
||||||
const outputRows = [];
|
|
||||||
let remainingChars = options.maxChars;
|
|
||||||
|
|
||||||
for (const [index, row] of rows.entries()) {
|
|
||||||
const source = `${row.path}#${row.chunk_index}`;
|
|
||||||
const text = String(row.text ?? '').trim();
|
|
||||||
const result = {
|
|
||||||
rank: index + 1,
|
|
||||||
id: row.id,
|
|
||||||
source,
|
|
||||||
path: row.path,
|
|
||||||
title: row.title,
|
|
||||||
chunkIndex: Number(row.chunk_index),
|
|
||||||
score: Number(row.score),
|
|
||||||
distance: Number(row._distance ?? 0),
|
|
||||||
sourceWeight: Number(row.source_weight ?? 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.includeText) {
|
|
||||||
const capped = capText(text, Math.max(0, remainingChars));
|
|
||||||
result.text = capped.text;
|
|
||||||
result.truncated = capped.truncated;
|
|
||||||
remainingChars -= result.text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
outputRows.push(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: 'genarrative-rag-context',
|
|
||||||
query: searchQuery,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
model: options.model,
|
|
||||||
table: options.tableName,
|
|
||||||
maxChars: options.maxChars,
|
|
||||||
remainingChars,
|
|
||||||
resultCount: outputRows.length,
|
|
||||||
usage: [
|
|
||||||
'This context pack is primarily for Agent consumption.',
|
|
||||||
'Use sources as candidate context and inspect authoritative files before editing when exact line-level changes matter.',
|
|
||||||
'Prefer docs/project-memory and current docs over stale historical notes when sources conflict.',
|
|
||||||
],
|
|
||||||
results: outputRows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function capText(text, budget) {
|
|
||||||
if (budget <= 0) {
|
|
||||||
return { text: '', truncated: text.length > 0 };
|
|
||||||
}
|
|
||||||
if (text.length <= budget) {
|
|
||||||
return { text, truncated: false };
|
|
||||||
}
|
|
||||||
return { text: `${text.slice(0, Math.max(0, budget - 18)).trimEnd()}\n[TRUNCATED]`, truncated: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContextPack(payload) {
|
|
||||||
const lines = [
|
|
||||||
'# Genarrative RAG Context',
|
|
||||||
'',
|
|
||||||
`query: ${payload.query}`,
|
|
||||||
`model: ${payload.model}`,
|
|
||||||
`results: ${payload.resultCount}`,
|
|
||||||
`maxChars: ${payload.maxChars}`,
|
|
||||||
'',
|
|
||||||
'## Agent Usage',
|
|
||||||
'',
|
|
||||||
'- This context pack is primarily for Agent consumption.',
|
|
||||||
'- Treat sources as candidate context; inspect authoritative files before exact edits.',
|
|
||||||
'- If sources conflict, prefer current code and current docs over stale historical notes.',
|
|
||||||
'',
|
|
||||||
'## Sources',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const result of payload.results) {
|
|
||||||
lines.push(
|
|
||||||
`${result.rank}. ${result.source} score=${result.score.toFixed(4)} distance=${result.distance.toFixed(4)} title=${result.title}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('', '## Context', '');
|
|
||||||
|
|
||||||
for (const result of payload.results) {
|
|
||||||
const fence = buildMarkdownFence(result.text ?? '');
|
|
||||||
lines.push(
|
|
||||||
`### [${result.rank}] ${result.title}`,
|
|
||||||
'',
|
|
||||||
`source: ${result.source}`,
|
|
||||||
`score: ${result.score.toFixed(4)}`,
|
|
||||||
'',
|
|
||||||
`${fence}text`,
|
|
||||||
result.text ?? '',
|
|
||||||
fence,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMarkdownFence(text) {
|
|
||||||
const longest = Math.max(3, ...Array.from(text.matchAll(/`+/gu), (match) => match[0].length));
|
|
||||||
return '`'.repeat(longest + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function printTextResults(rows) {
|
|
||||||
for (const result of rows) {
|
|
||||||
const preview = String(result.text ?? '').replace(/\s+/gu, ' ').slice(0, 260);
|
|
||||||
console.log(
|
|
||||||
[
|
|
||||||
`${result.rank}. ${result.source}`,
|
|
||||||
` title: ${result.title}`,
|
|
||||||
` score: ${result.score.toFixed(4)} distance: ${result.distance.toFixed(4)}`,
|
|
||||||
` ${preview}`,
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1835
server-rs/Cargo.lock
generated
1835
server-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,6 @@ members = [
|
|||||||
"crates/platform-wechat",
|
"crates/platform-wechat",
|
||||||
"crates/platform-speech",
|
"crates/platform-speech",
|
||||||
"crates/platform-agent",
|
"crates/platform-agent",
|
||||||
"crates/server-manager-panel",
|
|
||||||
"crates/shared-contracts",
|
"crates/shared-contracts",
|
||||||
"crates/shared-kernel",
|
"crates/shared-kernel",
|
||||||
"crates/shared-logging",
|
"crates/shared-logging",
|
||||||
@@ -121,9 +120,9 @@ serde_urlencoded = "0.7"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
socket2 = "0.6"
|
socket2 = "0.6"
|
||||||
spacetimedb = "=2.5.0"
|
spacetimedb = "=2.4.1"
|
||||||
spacetimedb-sdk = "=2.5.0"
|
spacetimedb-sdk = "=2.4.1"
|
||||||
spacetimedb-lib = { version = "=2.5.0", default-features = false }
|
spacetimedb-lib = { version = "=2.4.1", default-features = false }
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ shared-kernel = { workspace = true }
|
|||||||
shared-logging = { workspace = true }
|
shared-logging = { workspace = true }
|
||||||
socket2 = { workspace = true }
|
socket2 = { workspace = true }
|
||||||
spacetime-client = { workspace = true }
|
spacetime-client = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal", "process"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
time = { workspace = true, features = ["formatting"] }
|
time = { workspace = true, features = ["formatting"] }
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
当前基础响应头约定:
|
当前基础响应头约定:
|
||||||
|
|
||||||
1. 所有响应都会回写 `x-request-id`。
|
1. 所有响应都会回写 `x-request-id`。
|
||||||
2. 所有响应都会回写固定的 `x-api-version`,值来自 `shared_contracts::api::API_VERSION`,当前为 `2026-06-16`,并与 body `meta.apiVersion` 保持一致。
|
2. 所有响应都会回写固定的 `x-api-version`,当前值与 body `meta.apiVersion` 保持一致。
|
||||||
3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。
|
3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。
|
||||||
4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。
|
4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(modules::profile::router(state.clone()))
|
.merge(modules::profile::router(state.clone()))
|
||||||
.merge(modules::assets::router(state.clone()))
|
.merge(modules::assets::router(state.clone()))
|
||||||
.merge(modules::platform::router(state.clone()))
|
.merge(modules::platform::router(state.clone()))
|
||||||
.merge(modules::external_generation::router(state.clone()))
|
|
||||||
.merge(modules::play_flow::router(state.clone()))
|
.merge(modules::play_flow::router(state.clone()))
|
||||||
.route(
|
.route(
|
||||||
"/api/profile/recharge/wechat/notify",
|
"/api/profile/recharge/wechat/notify",
|
||||||
@@ -486,14 +485,14 @@ mod tests {
|
|||||||
.headers()
|
.headers()
|
||||||
.get("x-api-version")
|
.get("x-api-version")
|
||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
Some("2026-06-16")
|
Some("2026-04-08")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response
|
response
|
||||||
.headers()
|
.headers()
|
||||||
.get("x-route-version")
|
.get("x-route-version")
|
||||||
.and_then(|value| value.to_str().ok()),
|
.and_then(|value| value.to_str().ok()),
|
||||||
Some("2026-06-16")
|
Some("2026-04-08")
|
||||||
);
|
);
|
||||||
assert!(response.headers().contains_key("x-response-time-ms"));
|
assert!(response.headers().contains_key("x-response-time-ms"));
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ use axum::http::StatusCode;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use spacetime_client::SpacetimeClientError;
|
use spacetime_client::SpacetimeClientError;
|
||||||
|
|
||||||
use crate::{
|
use crate::{http_error::AppError, state::AppState};
|
||||||
http_error::AppError,
|
|
||||||
state::AppState,
|
|
||||||
wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||||
|
|
||||||
@@ -52,7 +48,7 @@ where
|
|||||||
match operation.await {
|
match operation.await {
|
||||||
Ok(value) => Ok(value),
|
Ok(value) => Ok(value),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if points_consumed && should_refund_asset_operation_error(&error) {
|
if points_consumed {
|
||||||
refund_asset_operation_points(
|
refund_asset_operation_points(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -67,20 +63,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn should_refund_asset_operation_error(error: &AppError) -> bool {
|
|
||||||
let message = error.body_text();
|
|
||||||
// 中文注释:worker lease guard 拒绝表示当前进程已失去队列写权限;
|
|
||||||
// 这类 stale worker 失败不能补偿退款,否则可能冲掉后续合法 worker 的同一账本扣费。
|
|
||||||
!(message.contains("external_generation_job")
|
|
||||||
&& (message.contains("lease")
|
|
||||||
|| message.contains("worker")
|
|
||||||
|| message.contains("job_kind")
|
|
||||||
|| message.contains("source_")
|
|
||||||
|| message.contains("owner_user_id")
|
|
||||||
|| message.contains("不存在")
|
|
||||||
|| message.contains("不是 running 状态")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
/// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。
|
||||||
async fn consume_asset_operation_points(
|
async fn consume_asset_operation_points(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -108,11 +90,22 @@ async fn consume_asset_operation_points(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
|
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||||
|
// 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。
|
||||||
|
tracing::warn!(
|
||||||
|
owner_user_id,
|
||||||
|
asset_kind,
|
||||||
|
asset_id,
|
||||||
|
error = %error,
|
||||||
|
"资产操作泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
Err(error) => Err(map_asset_operation_wallet_error(error)),
|
Err(error) => Err(map_asset_operation_wallet_error(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 外部生成或发布 mutation 失败后补偿退款;立即退款失败会进入 outbox,避免覆盖原始业务错误。
|
/// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。
|
||||||
async fn refund_asset_operation_points(
|
async fn refund_asset_operation_points(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -124,74 +117,22 @@ async fn refund_asset_operation_points(
|
|||||||
"asset_operation_refund:{}:{}:{}",
|
"asset_operation_refund:{}:{}:{}",
|
||||||
owner_user_id, asset_kind, asset_id
|
owner_user_id, asset_kind, asset_id
|
||||||
);
|
);
|
||||||
let created_at_micros = current_utc_micros();
|
|
||||||
if let Err(error) = state
|
if let Err(error) = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.refund_profile_wallet_points(
|
.refund_profile_wallet_points(
|
||||||
owner_user_id.to_string(),
|
owner_user_id.to_string(),
|
||||||
points_cost,
|
points_cost,
|
||||||
ledger_id.clone(),
|
ledger_id,
|
||||||
created_at_micros,
|
current_utc_micros(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let refund_error = error.to_string();
|
|
||||||
if let Some(outbox) = state.wallet_refund_outbox() {
|
|
||||||
match outbox
|
|
||||||
.enqueue(WalletRefundOutboxRecord {
|
|
||||||
owner_user_id: owner_user_id.to_string(),
|
|
||||||
amount: points_cost,
|
|
||||||
ledger_id: ledger_id.clone(),
|
|
||||||
created_at_micros,
|
|
||||||
asset_kind: asset_kind.to_string(),
|
|
||||||
asset_id: asset_id.to_string(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(WalletRefundOutboxEnqueueOutcome::Enqueued) => {
|
|
||||||
tracing::warn!(
|
|
||||||
owner_user_id,
|
|
||||||
asset_kind,
|
|
||||||
asset_id,
|
|
||||||
ledger_id,
|
|
||||||
error = %refund_error,
|
|
||||||
"资产操作失败后的泥点退款立即执行失败,已写入 wallet refund outbox"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Ok(WalletRefundOutboxEnqueueOutcome::Dropped { reason }) => {
|
|
||||||
tracing::error!(
|
|
||||||
owner_user_id,
|
|
||||||
asset_kind,
|
|
||||||
asset_id,
|
|
||||||
ledger_id,
|
|
||||||
reason,
|
|
||||||
error = %refund_error,
|
|
||||||
"资产操作失败后的泥点退款立即执行失败,且 wallet refund outbox 因容量限制丢弃"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(outbox_error) => {
|
|
||||||
tracing::error!(
|
|
||||||
owner_user_id,
|
|
||||||
asset_kind,
|
|
||||||
asset_id,
|
|
||||||
ledger_id,
|
|
||||||
refund_error = %refund_error,
|
|
||||||
outbox_error = %outbox_error,
|
|
||||||
"资产操作失败后的泥点退款立即执行失败,且写入 wallet refund outbox 失败"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
asset_kind,
|
asset_kind,
|
||||||
asset_id,
|
asset_id,
|
||||||
ledger_id,
|
error = %error,
|
||||||
error = %refund_error,
|
"资产操作失败后的泥点退款失败"
|
||||||
"资产操作失败后的泥点退款失败,且 wallet refund outbox 未启用"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +185,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() {
|
fn asset_operation_billing_skips_spacetime_connectivity_errors() {
|
||||||
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
|
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
|
||||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||||
&SpacetimeClientError::ConnectDropped
|
&SpacetimeClientError::ConnectDropped
|
||||||
@@ -263,31 +204,4 @@ mod tests {
|
|||||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn asset_operation_billing_does_not_refund_stale_worker_lease_errors() {
|
|
||||||
let stale_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": "spacetimedb",
|
|
||||||
"message": "external_generation_job lease 已过期",
|
|
||||||
}));
|
|
||||||
let completed_job_error =
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": "spacetimedb",
|
|
||||||
"message": "external_generation_job 当前不是 running 状态",
|
|
||||||
}));
|
|
||||||
let missing_job_error =
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": "spacetimedb",
|
|
||||||
"message": "external_generation_job 不存在",
|
|
||||||
}));
|
|
||||||
let ordinary_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
|
||||||
"provider": "vector-engine",
|
|
||||||
"message": "图片生成失败",
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert!(!should_refund_asset_operation_error(&stale_error));
|
|
||||||
assert!(!should_refund_asset_operation_error(&completed_job_error));
|
|
||||||
assert!(!should_refund_asset_operation_error(&missing_job_error));
|
|
||||||
assert!(should_refund_asset_operation_error(&ordinary_error));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use time::{Duration as TimeDuration, OffsetDateTime};
|
|||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
asset_billing::execute_billable_asset_operation_with_cost,
|
asset_billing::execute_billable_asset_operation_with_cost,
|
||||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
auth::AuthenticatedAccessToken,
|
||||||
generated_image_assets::{
|
generated_image_assets::{
|
||||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||||
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
|
||||||
@@ -306,12 +306,11 @@ pub async fn generate_bark_battle_image_asset(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToString::to_string);
|
.map(ToString::to_string);
|
||||||
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
|
let points_cost = resolve_bark_battle_image_asset_points_cost(&state, &payload).await;
|
||||||
let billing_asset_id = request_context.request_id().to_string();
|
|
||||||
let result = execute_billable_asset_operation_with_cost(
|
let result = execute_billable_asset_operation_with_cost(
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
bark_battle_slot_asset_kind(&slot),
|
bark_battle_slot_asset_kind(&slot),
|
||||||
billing_asset_id.as_str(),
|
asset_id.as_str(),
|
||||||
points_cost,
|
points_cost,
|
||||||
async {
|
async {
|
||||||
generate_and_persist_bark_battle_image_asset(
|
generate_and_persist_bark_battle_image_asset(
|
||||||
@@ -507,13 +506,13 @@ pub async fn get_bark_battle_runtime_config(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(work_id): Path<String>,
|
Path(work_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||||
|
|
||||||
let config = state
|
let config = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.get_bark_battle_runtime_config(work_id, Some(principal.subject().to_string()))
|
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
@@ -527,7 +526,7 @@ pub async fn start_bark_battle_run(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(work_id): Path<String>,
|
Path(work_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||||
@@ -544,7 +543,7 @@ pub async fn start_bark_battle_run(
|
|||||||
};
|
};
|
||||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||||
|
|
||||||
let owner_user_id = principal.subject().to_string();
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
let runtime_config = state
|
let runtime_config = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
||||||
@@ -594,13 +593,12 @@ pub async fn start_bark_battle_run(
|
|||||||
record_work_play_start_after_success(
|
record_work_play_start_after_success(
|
||||||
&state,
|
&state,
|
||||||
&request_context,
|
&request_context,
|
||||||
WorkPlayTrackingDraft::runtime_principal(
|
WorkPlayTrackingDraft::new(
|
||||||
BARK_BATTLE_PLAY_TYPE_ID,
|
BARK_BATTLE_PLAY_TYPE_ID,
|
||||||
work_id.clone(),
|
work_id.clone(),
|
||||||
&principal,
|
&authenticated,
|
||||||
"/api/runtime/bark-battle/...",
|
"/api/runtime/bark-battle/...",
|
||||||
)
|
)
|
||||||
.owner_user_id(owner_user_id.clone())
|
|
||||||
.extra(json!({
|
.extra(json!({
|
||||||
"runId": run_snapshot.run_id,
|
"runId": run_snapshot.run_id,
|
||||||
"workId": work_id,
|
"workId": work_id,
|
||||||
@@ -609,7 +607,6 @@ pub async fn start_bark_battle_run(
|
|||||||
"difficultyPreset": runtime_config.difficulty_preset,
|
"difficultyPreset": runtime_config.difficulty_preset,
|
||||||
"sourceRoute": request.source_route,
|
"sourceRoute": request.source_route,
|
||||||
"clientRuntimeVersion": request.client_runtime_version,
|
"clientRuntimeVersion": request.client_runtime_version,
|
||||||
"principalKind": principal.kind().as_str(),
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -641,12 +638,12 @@ pub async fn get_bark_battle_run(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(run_id): Path<String>,
|
Path(run_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||||
let run = state
|
let run = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.get_bark_battle_run(run_id, principal.subject().to_string())
|
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||||
@@ -660,7 +657,7 @@ pub async fn finish_bark_battle_run(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(run_id): Path<String>,
|
Path(run_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||||
@@ -701,7 +698,7 @@ pub async fn finish_bark_battle_run(
|
|||||||
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
||||||
run_id,
|
run_id,
|
||||||
run_token: payload.run_token,
|
run_token: payload.run_token,
|
||||||
owner_user_id: principal.subject().to_string(),
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
work_id: payload.work_id.clone(),
|
work_id: payload.work_id.clone(),
|
||||||
config_version: u64::from(payload.config_version),
|
config_version: u64::from(payload.config_version),
|
||||||
ruleset_version: payload.ruleset_version.clone(),
|
ruleset_version: payload.ruleset_version.clone(),
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
asset_billing::execute_billable_asset_operation,
|
asset_billing::execute_billable_asset_operation,
|
||||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
auth::AuthenticatedAccessToken,
|
||||||
character_visual_assets::try_apply_background_alpha_to_png,
|
character_visual_assets::try_apply_background_alpha_to_png,
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
platform_errors::map_oss_error,
|
platform_errors::map_oss_error,
|
||||||
@@ -224,7 +224,7 @@ pub async fn record_big_fish_play(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
|
payload: Result<Json<RecordBigFishPlayRequest>, JsonRejection>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = payload.map_err(|error| {
|
let Json(payload) = payload.map_err(|error| {
|
||||||
@@ -242,7 +242,7 @@ pub async fn record_big_fish_play(
|
|||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.record_big_fish_play(BigFishPlayReportRecordInput {
|
.record_big_fish_play(BigFishPlayReportRecordInput {
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
user_id: principal.subject().to_string(),
|
user_id: authenticated.claims().user_id().to_string(),
|
||||||
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
|
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
|
||||||
reported_at_micros: current_utc_micros(),
|
reported_at_micros: current_utc_micros(),
|
||||||
})
|
})
|
||||||
@@ -254,14 +254,13 @@ pub async fn record_big_fish_play(
|
|||||||
record_work_play_start_after_success(
|
record_work_play_start_after_success(
|
||||||
&state,
|
&state,
|
||||||
&request_context,
|
&request_context,
|
||||||
WorkPlayTrackingDraft::runtime_principal(
|
WorkPlayTrackingDraft::new(
|
||||||
"big-fish",
|
"big-fish",
|
||||||
session_id.clone(),
|
session_id.clone(),
|
||||||
&principal,
|
&authenticated,
|
||||||
"/api/runtime/big-fish/sessions/{session_id}/play",
|
"/api/runtime/big-fish/sessions/{session_id}/play",
|
||||||
)
|
)
|
||||||
.run_id(session_id.clone())
|
.run_id(session_id.clone()),
|
||||||
.owner_user_id(principal.subject().to_string()),
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -280,7 +279,7 @@ pub async fn start_big_fish_run(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(session_id): Path<String>,
|
Path(session_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||||
|
|
||||||
@@ -289,7 +288,7 @@ pub async fn start_big_fish_run(
|
|||||||
.start_big_fish_run(BigFishRunStartRecordInput {
|
.start_big_fish_run(BigFishRunStartRecordInput {
|
||||||
run_id: build_prefixed_uuid_id("big-fish-run-"),
|
run_id: build_prefixed_uuid_id("big-fish-run-"),
|
||||||
session_id,
|
session_id,
|
||||||
owner_user_id: principal.subject().to_string(),
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
started_at_micros: current_utc_micros(),
|
started_at_micros: current_utc_micros(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -340,13 +339,13 @@ pub async fn get_big_fish_run(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(run_id): Path<String>,
|
Path(run_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||||
|
|
||||||
let run = state
|
let run = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.get_big_fish_run(run_id, principal.subject().to_string())
|
.get_big_fish_run(run_id, authenticated.claims().user_id().to_string())
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||||
@@ -364,7 +363,7 @@ pub async fn submit_big_fish_input(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(run_id): Path<String>,
|
Path(run_id): Path<String>,
|
||||||
Extension(request_context): Extension<RequestContext>,
|
Extension(request_context): Extension<RequestContext>,
|
||||||
Extension(principal): Extension<RuntimePrincipal>,
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
|
payload: Result<Json<SubmitBigFishInputRequest>, JsonRejection>,
|
||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let Json(payload) = payload.map_err(|error| {
|
let Json(payload) = payload.map_err(|error| {
|
||||||
@@ -385,7 +384,7 @@ pub async fn submit_big_fish_input(
|
|||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.submit_big_fish_input(BigFishInputSubmitRecordInput {
|
.submit_big_fish_input(BigFishInputSubmitRecordInput {
|
||||||
run_id,
|
run_id,
|
||||||
owner_user_id: principal.subject().to_string(),
|
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||||
x: payload.x,
|
x: payload.x,
|
||||||
y: payload.y,
|
y: payload.y,
|
||||||
submitted_at_micros: current_utc_micros(),
|
submitted_at_micros: current_utc_micros(),
|
||||||
@@ -722,7 +721,7 @@ pub async fn execute_big_fish_action(
|
|||||||
"big_fish_publish_game" => Some("big_fish_publish_game"),
|
"big_fish_publish_game" => Some("big_fish_publish_game"),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id());
|
let billing_asset_id = format!("{session_id}:{now}");
|
||||||
let session_operation = async {
|
let session_operation = async {
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
"big_fish_compile_draft" => {
|
"big_fish_compile_draft" => {
|
||||||
|
|||||||
@@ -22,19 +22,6 @@ pub struct AppConfig {
|
|||||||
pub bind_port: u16,
|
pub bind_port: u16,
|
||||||
pub listen_backlog: i32,
|
pub listen_backlog: i32,
|
||||||
pub worker_threads: Option<usize>,
|
pub worker_threads: Option<usize>,
|
||||||
pub process_role: ProcessRole,
|
|
||||||
pub external_generation_mode: ExternalGenerationMode,
|
|
||||||
pub external_generation_worker_id: String,
|
|
||||||
pub external_generation_worker_concurrency: usize,
|
|
||||||
pub external_generation_worker_poll_interval: Duration,
|
|
||||||
pub external_generation_worker_lease: Duration,
|
|
||||||
pub external_generation_controller_min_workers: usize,
|
|
||||||
pub external_generation_controller_max_workers: usize,
|
|
||||||
pub external_generation_controller_target_jobs_per_worker: usize,
|
|
||||||
pub external_generation_controller_poll_interval: Duration,
|
|
||||||
pub external_generation_controller_scale_down_idle_rounds: u32,
|
|
||||||
pub external_generation_controller_service_template: String,
|
|
||||||
pub external_generation_controller_dry_run: bool,
|
|
||||||
pub max_concurrent_requests: Option<usize>,
|
pub max_concurrent_requests: Option<usize>,
|
||||||
pub gallery_max_concurrent_requests: Option<usize>,
|
pub gallery_max_concurrent_requests: Option<usize>,
|
||||||
pub detail_max_concurrent_requests: Option<usize>,
|
pub detail_max_concurrent_requests: Option<usize>,
|
||||||
@@ -45,11 +32,6 @@ pub struct AppConfig {
|
|||||||
pub tracking_outbox_batch_size: usize,
|
pub tracking_outbox_batch_size: usize,
|
||||||
pub tracking_outbox_flush_interval: Duration,
|
pub tracking_outbox_flush_interval: Duration,
|
||||||
pub tracking_outbox_max_bytes: u64,
|
pub tracking_outbox_max_bytes: u64,
|
||||||
pub wallet_refund_outbox_enabled: bool,
|
|
||||||
pub wallet_refund_outbox_dir: PathBuf,
|
|
||||||
pub wallet_refund_outbox_batch_size: usize,
|
|
||||||
pub wallet_refund_outbox_flush_interval: Duration,
|
|
||||||
pub wallet_refund_outbox_max_bytes: u64,
|
|
||||||
pub log_filter: String,
|
pub log_filter: String,
|
||||||
pub otel_enabled: bool,
|
pub otel_enabled: bool,
|
||||||
pub admin_username: Option<String>,
|
pub admin_username: Option<String>,
|
||||||
@@ -184,56 +166,6 @@ pub struct AppConfig {
|
|||||||
pub slow_request_threshold_ms: u64,
|
pub slow_request_threshold_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ProcessRole {
|
|
||||||
Api,
|
|
||||||
ExternalGenerationWorker,
|
|
||||||
ExternalGenerationController,
|
|
||||||
All,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ExternalGenerationMode {
|
|
||||||
Inline,
|
|
||||||
Queue,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExternalGenerationMode {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Inline => "inline",
|
|
||||||
Self::Queue => "queue",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_inline(self) -> bool {
|
|
||||||
matches!(self, Self::Inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProcessRole {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Api => "api",
|
|
||||||
Self::ExternalGenerationWorker => "external-generation-worker",
|
|
||||||
Self::ExternalGenerationController => "external-generation-controller",
|
|
||||||
Self::All => "all",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runs_http(self) -> bool {
|
|
||||||
matches!(self, Self::Api | Self::All)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runs_external_generation_worker(self) -> bool {
|
|
||||||
matches!(self, Self::ExternalGenerationWorker | Self::All)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runs_external_generation_controller(self) -> bool {
|
|
||||||
matches!(self, Self::ExternalGenerationController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -241,20 +173,6 @@ impl Default for AppConfig {
|
|||||||
bind_port: 3000,
|
bind_port: 3000,
|
||||||
listen_backlog: 1024,
|
listen_backlog: 1024,
|
||||||
worker_threads: None,
|
worker_threads: None,
|
||||||
process_role: ProcessRole::Api,
|
|
||||||
external_generation_mode: ExternalGenerationMode::Queue,
|
|
||||||
external_generation_worker_id: default_external_generation_worker_id(),
|
|
||||||
external_generation_worker_concurrency: 2,
|
|
||||||
external_generation_worker_poll_interval: Duration::from_millis(2_000),
|
|
||||||
external_generation_worker_lease: Duration::from_secs(3_600),
|
|
||||||
external_generation_controller_min_workers: 1,
|
|
||||||
external_generation_controller_max_workers: 8,
|
|
||||||
external_generation_controller_target_jobs_per_worker: 2,
|
|
||||||
external_generation_controller_poll_interval: Duration::from_millis(10_000),
|
|
||||||
external_generation_controller_scale_down_idle_rounds: 6,
|
|
||||||
external_generation_controller_service_template:
|
|
||||||
"genarrative-external-generation-worker@{}.service".to_string(),
|
|
||||||
external_generation_controller_dry_run: false,
|
|
||||||
max_concurrent_requests: None,
|
max_concurrent_requests: None,
|
||||||
gallery_max_concurrent_requests: None,
|
gallery_max_concurrent_requests: None,
|
||||||
detail_max_concurrent_requests: None,
|
detail_max_concurrent_requests: None,
|
||||||
@@ -265,11 +183,6 @@ impl Default for AppConfig {
|
|||||||
tracking_outbox_batch_size: 500,
|
tracking_outbox_batch_size: 500,
|
||||||
tracking_outbox_flush_interval: Duration::from_millis(1_000),
|
tracking_outbox_flush_interval: Duration::from_millis(1_000),
|
||||||
tracking_outbox_max_bytes: 256 * 1024 * 1024,
|
tracking_outbox_max_bytes: 256 * 1024 * 1024,
|
||||||
wallet_refund_outbox_enabled: true,
|
|
||||||
wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"),
|
|
||||||
wallet_refund_outbox_batch_size: 100,
|
|
||||||
wallet_refund_outbox_flush_interval: Duration::from_millis(1_000),
|
|
||||||
wallet_refund_outbox_max_bytes: 64 * 1024 * 1024,
|
|
||||||
log_filter: "info,tower_http=info".to_string(),
|
log_filter: "info,tower_http=info".to_string(),
|
||||||
otel_enabled: false,
|
otel_enabled: false,
|
||||||
admin_username: None,
|
admin_username: None,
|
||||||
@@ -451,78 +364,6 @@ impl AppConfig {
|
|||||||
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
||||||
config.worker_threads = Some(worker_threads);
|
config.worker_threads = Some(worker_threads);
|
||||||
}
|
}
|
||||||
if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) {
|
|
||||||
config.process_role = process_role;
|
|
||||||
}
|
|
||||||
if let Some(external_generation_mode) =
|
|
||||||
read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"])
|
|
||||||
{
|
|
||||||
config.external_generation_mode = external_generation_mode;
|
|
||||||
}
|
|
||||||
if let Some(worker_id) =
|
|
||||||
read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"])
|
|
||||||
{
|
|
||||||
config.external_generation_worker_id = worker_id;
|
|
||||||
}
|
|
||||||
if let Some(concurrency) =
|
|
||||||
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY"])
|
|
||||||
{
|
|
||||||
config.external_generation_worker_concurrency = concurrency.max(1);
|
|
||||||
}
|
|
||||||
if let Some(poll_interval_ms) = read_first_positive_u64_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS",
|
|
||||||
]) {
|
|
||||||
config.external_generation_worker_poll_interval =
|
|
||||||
Duration::from_millis(poll_interval_ms);
|
|
||||||
}
|
|
||||||
if let Some(lease_seconds) = read_first_duration_seconds_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS",
|
|
||||||
]) {
|
|
||||||
config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1));
|
|
||||||
}
|
|
||||||
if let Some(min_workers) =
|
|
||||||
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"])
|
|
||||||
{
|
|
||||||
config.external_generation_controller_min_workers = min_workers;
|
|
||||||
}
|
|
||||||
if let Some(max_workers) =
|
|
||||||
read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS"])
|
|
||||||
{
|
|
||||||
config.external_generation_controller_max_workers = max_workers;
|
|
||||||
}
|
|
||||||
if config.external_generation_controller_max_workers
|
|
||||||
< config.external_generation_controller_min_workers
|
|
||||||
{
|
|
||||||
config.external_generation_controller_max_workers =
|
|
||||||
config.external_generation_controller_min_workers;
|
|
||||||
}
|
|
||||||
if let Some(target_jobs_per_worker) = read_first_usize_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER",
|
|
||||||
]) {
|
|
||||||
config.external_generation_controller_target_jobs_per_worker =
|
|
||||||
target_jobs_per_worker.max(1);
|
|
||||||
}
|
|
||||||
if let Some(poll_interval_ms) = read_first_positive_u64_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS",
|
|
||||||
]) {
|
|
||||||
config.external_generation_controller_poll_interval =
|
|
||||||
Duration::from_millis(poll_interval_ms);
|
|
||||||
}
|
|
||||||
if let Some(idle_rounds) = read_first_u32_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS",
|
|
||||||
]) {
|
|
||||||
config.external_generation_controller_scale_down_idle_rounds = idle_rounds;
|
|
||||||
}
|
|
||||||
if let Some(service_template) = read_first_non_empty_env(&[
|
|
||||||
"GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE",
|
|
||||||
]) {
|
|
||||||
config.external_generation_controller_service_template = service_template;
|
|
||||||
}
|
|
||||||
if let Some(dry_run) =
|
|
||||||
read_first_bool_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN"])
|
|
||||||
{
|
|
||||||
config.external_generation_controller_dry_run = dry_run;
|
|
||||||
}
|
|
||||||
if let Some(max_concurrent_requests) =
|
if let Some(max_concurrent_requests) =
|
||||||
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
||||||
{
|
{
|
||||||
@@ -568,27 +409,6 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.tracking_outbox_max_bytes = max_bytes;
|
config.tracking_outbox_max_bytes = max_bytes;
|
||||||
}
|
}
|
||||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) {
|
|
||||||
config.wallet_refund_outbox_enabled = enabled;
|
|
||||||
}
|
|
||||||
if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_DIR"]) {
|
|
||||||
config.wallet_refund_outbox_dir = PathBuf::from(dir);
|
|
||||||
}
|
|
||||||
if let Some(batch_size) =
|
|
||||||
read_first_usize_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE"])
|
|
||||||
{
|
|
||||||
config.wallet_refund_outbox_batch_size = batch_size;
|
|
||||||
}
|
|
||||||
if let Some(flush_interval_ms) =
|
|
||||||
read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS"])
|
|
||||||
{
|
|
||||||
config.wallet_refund_outbox_flush_interval = Duration::from_millis(flush_interval_ms);
|
|
||||||
}
|
|
||||||
if let Some(max_bytes) =
|
|
||||||
read_first_positive_u64_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES"])
|
|
||||||
{
|
|
||||||
config.wallet_refund_outbox_max_bytes = max_bytes;
|
|
||||||
}
|
|
||||||
if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) {
|
if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) {
|
||||||
config.otel_enabled = otel_enabled;
|
config.otel_enabled = otel_enabled;
|
||||||
}
|
}
|
||||||
@@ -1202,22 +1022,6 @@ fn read_first_llm_provider_env(keys: &[&str]) -> Option<LlmProvider> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_first_process_role_env(keys: &[&str]) -> Option<ProcessRole> {
|
|
||||||
keys.iter().find_map(|key| {
|
|
||||||
env::var(key)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| parse_process_role(&value))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_first_external_generation_mode_env(keys: &[&str]) -> Option<ExternalGenerationMode> {
|
|
||||||
keys.iter().find_map(|key| {
|
|
||||||
env::var(key)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| parse_external_generation_mode(&value))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||||
keys.iter().find_map(|key| {
|
keys.iter().find_map(|key| {
|
||||||
env::var(key)
|
env::var(key)
|
||||||
@@ -1265,49 +1069,6 @@ fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
|
|||||||
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
|
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_external_generation_worker_id() -> String {
|
|
||||||
let host = env::var("HOSTNAME")
|
|
||||||
.or_else(|_| env::var("COMPUTERNAME"))
|
|
||||||
.unwrap_or_else(|_| "local".to_string());
|
|
||||||
format!("{}-{}", host.trim(), std::process::id())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_process_role(value: &str) -> Option<ProcessRole> {
|
|
||||||
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
|
|
||||||
"api" => Some(ProcessRole::Api),
|
|
||||||
"external-generation-worker" | "external_generation_worker" | "worker" => {
|
|
||||||
Some(ProcessRole::ExternalGenerationWorker)
|
|
||||||
}
|
|
||||||
"external-generation-controller" | "external_generation_controller" | "controller" => {
|
|
||||||
Some(ProcessRole::ExternalGenerationController)
|
|
||||||
}
|
|
||||||
"all" => Some(ProcessRole::All),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_external_generation_mode(value: &str) -> Option<ExternalGenerationMode> {
|
|
||||||
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
|
|
||||||
"inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline),
|
|
||||||
"queue" | "queued" | "worker" | "async" | "asynchronous" => {
|
|
||||||
Some(ExternalGenerationMode::Queue)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trim_quoted_env_value(raw: &str) -> &str {
|
|
||||||
let raw = raw.trim();
|
|
||||||
raw.strip_prefix('"')
|
|
||||||
.and_then(|value| value.strip_suffix('"'))
|
|
||||||
.or_else(|| {
|
|
||||||
raw.strip_prefix('\'')
|
|
||||||
.and_then(|value| value.strip_suffix('\''))
|
|
||||||
})
|
|
||||||
.unwrap_or(raw)
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
fn read_first_positive_u16_env(keys: &[&str]) -> Option<u16> {
|
||||||
keys.iter().find_map(|key| {
|
keys.iter().find_map(|key| {
|
||||||
env::var(key)
|
env::var(key)
|
||||||
@@ -1428,8 +1189,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode,
|
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
|
||||||
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
|
||||||
};
|
};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
@@ -1471,91 +1231,6 @@ mod tests {
|
|||||||
assert_eq!(parse_bool("'off'"), Some(false));
|
assert_eq!(parse_bool("'off'"), Some(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn process_role_controls_http_and_external_generation_worker_roles() {
|
|
||||||
assert_eq!(parse_process_role("api"), Some(ProcessRole::Api));
|
|
||||||
assert_eq!(
|
|
||||||
parse_process_role("\"external-generation-worker\""),
|
|
||||||
Some(ProcessRole::ExternalGenerationWorker)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_process_role("'external_generation_worker'"),
|
|
||||||
Some(ProcessRole::ExternalGenerationWorker)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_process_role("worker"),
|
|
||||||
Some(ProcessRole::ExternalGenerationWorker)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_process_role("controller"),
|
|
||||||
Some(ProcessRole::ExternalGenerationController)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_process_role("'external_generation_controller'"),
|
|
||||||
Some(ProcessRole::ExternalGenerationController)
|
|
||||||
);
|
|
||||||
assert_eq!(parse_process_role("all"), Some(ProcessRole::All));
|
|
||||||
assert_eq!(parse_process_role("unknown"), None);
|
|
||||||
|
|
||||||
assert!(ProcessRole::Api.runs_http());
|
|
||||||
assert!(!ProcessRole::Api.runs_external_generation_worker());
|
|
||||||
assert!(!ProcessRole::Api.runs_external_generation_controller());
|
|
||||||
assert!(!ProcessRole::ExternalGenerationWorker.runs_http());
|
|
||||||
assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker());
|
|
||||||
assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller());
|
|
||||||
assert!(!ProcessRole::ExternalGenerationController.runs_http());
|
|
||||||
assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker());
|
|
||||||
assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller());
|
|
||||||
assert!(ProcessRole::All.runs_http());
|
|
||||||
assert!(ProcessRole::All.runs_external_generation_worker());
|
|
||||||
assert!(!ProcessRole::All.runs_external_generation_controller());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn external_generation_mode_parses_inline_and_queue_aliases() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_external_generation_mode("inline"),
|
|
||||||
Some(ExternalGenerationMode::Inline)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_external_generation_mode("'sync'"),
|
|
||||||
Some(ExternalGenerationMode::Inline)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_external_generation_mode("\"queue\""),
|
|
||||||
Some(ExternalGenerationMode::Queue)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_external_generation_mode("worker"),
|
|
||||||
Some(ExternalGenerationMode::Queue)
|
|
||||||
);
|
|
||||||
assert_eq!(parse_external_generation_mode("unknown"), None);
|
|
||||||
|
|
||||||
assert!(ExternalGenerationMode::Inline.is_inline());
|
|
||||||
assert!(!ExternalGenerationMode::Queue.is_inline());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_env_reads_external_generation_mode() {
|
|
||||||
let _guard = ENV_LOCK
|
|
||||||
.get_or_init(|| Mutex::new(()))
|
|
||||||
.lock()
|
|
||||||
.expect("env lock");
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline");
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = AppConfig::from_env();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.external_generation_mode,
|
|
||||||
ExternalGenerationMode::Inline
|
|
||||||
);
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
@@ -1705,11 +1380,6 @@ mod tests {
|
|||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES");
|
|
||||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||||
std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048");
|
std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048");
|
||||||
std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6");
|
std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6");
|
||||||
@@ -1726,14 +1396,6 @@ mod tests {
|
|||||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
|
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
|
||||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
|
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
|
||||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
|
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
|
||||||
std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false");
|
|
||||||
std::env::set_var(
|
|
||||||
"GENARRATIVE_WALLET_REFUND_OUTBOX_DIR",
|
|
||||||
"/tmp/genarrative-wallet-refund-outbox",
|
|
||||||
);
|
|
||||||
std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE", "50");
|
|
||||||
std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS", "3000");
|
|
||||||
std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES", "524288");
|
|
||||||
std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true");
|
std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,17 +1421,6 @@ mod tests {
|
|||||||
std::time::Duration::from_millis(2_000)
|
std::time::Duration::from_millis(2_000)
|
||||||
);
|
);
|
||||||
assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
|
assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
|
||||||
assert!(!config.wallet_refund_outbox_enabled);
|
|
||||||
assert_eq!(
|
|
||||||
config.wallet_refund_outbox_dir,
|
|
||||||
std::path::PathBuf::from("/tmp/genarrative-wallet-refund-outbox")
|
|
||||||
);
|
|
||||||
assert_eq!(config.wallet_refund_outbox_batch_size, 50);
|
|
||||||
assert_eq!(
|
|
||||||
config.wallet_refund_outbox_flush_interval,
|
|
||||||
std::time::Duration::from_millis(3_000)
|
|
||||||
);
|
|
||||||
assert_eq!(config.wallet_refund_outbox_max_bytes, 524_288);
|
|
||||||
assert!(config.otel_enabled);
|
assert!(config.otel_enabled);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1785,11 +1436,6 @@ mod tests {
|
|||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS");
|
|
||||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES");
|
|
||||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -547,12 +547,11 @@ pub async fn generate_custom_world_scene_image(
|
|||||||
require_openai_image_settings(&state)
|
require_openai_image_settings(&state)
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
let asset_id = format!("custom-scene-{}", current_utc_millis());
|
||||||
let billing_asset_id = request_context.request_id().to_string();
|
|
||||||
let asset = execute_billable_asset_operation(
|
let asset = execute_billable_asset_operation(
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
"scene_image",
|
"scene_image",
|
||||||
billing_asset_id.as_str(),
|
asset_id.as_str(),
|
||||||
async {
|
async {
|
||||||
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
let settings = require_openai_image_settings(&state)?.with_external_api_audit_context(
|
||||||
&request_context,
|
&request_context,
|
||||||
@@ -807,12 +806,11 @@ pub async fn generate_custom_world_cover_image(
|
|||||||
require_dashscope_settings(&state)
|
require_dashscope_settings(&state)
|
||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
let asset_id = format!("custom-cover-{}", current_utc_millis());
|
||||||
let billing_asset_id = request_context.request_id().to_string();
|
|
||||||
let asset = execute_billable_asset_operation(
|
let asset = execute_billable_asset_operation(
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
"custom_world_cover",
|
"custom_world_cover",
|
||||||
billing_asset_id.as_str(),
|
asset_id.as_str(),
|
||||||
async {
|
async {
|
||||||
let settings = require_dashscope_settings(&state)?;
|
let settings = require_dashscope_settings(&state)?;
|
||||||
let http_client = build_dashscope_http_client(&settings)?;
|
let http_client = build_dashscope_http_client(&settings)?;
|
||||||
@@ -1013,12 +1011,11 @@ pub async fn generate_custom_world_opening_cg(
|
|||||||
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
|
||||||
|
|
||||||
let opening_cg_id = normalized.opening_cg_id.clone();
|
let opening_cg_id = normalized.opening_cg_id.clone();
|
||||||
let billing_asset_id = request_context.request_id().to_string();
|
|
||||||
let generated = execute_billable_asset_operation_with_cost(
|
let generated = execute_billable_asset_operation_with_cost(
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
"custom_world_opening_cg",
|
"custom_world_opening_cg",
|
||||||
billing_asset_id.as_str(),
|
opening_cg_id.as_str(),
|
||||||
OPENING_CG_POINTS_COST,
|
OPENING_CG_POINTS_COST,
|
||||||
async {
|
async {
|
||||||
let image_settings = require_openai_image_settings(&state)?
|
let image_settings = require_openai_image_settings(&state)?
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Extension, Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
use serde_json::json;
|
|
||||||
use shared_contracts::external_generation::{
|
|
||||||
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
|
|
||||||
ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview,
|
|
||||||
ExternalGenerationQueueOverviewResponse,
|
|
||||||
};
|
|
||||||
use spacetime_client::{
|
|
||||||
ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
|
|
||||||
ExternalGenerationQueueStatsRecord, SpacetimeClientError,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
|
||||||
request_context::RequestContext, state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation";
|
|
||||||
|
|
||||||
pub async fn get_external_generation_queue_overview(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
) -> Result<Json<serde_json::Value>, Response> {
|
|
||||||
let stats = state
|
|
||||||
.spacetime_client()
|
|
||||||
.get_external_generation_queue_stats()
|
|
||||||
.await
|
|
||||||
.map_err(|error| external_generation_error_response(&request_context, error))?;
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
|
||||||
Some(&request_context),
|
|
||||||
ExternalGenerationQueueOverviewResponse {
|
|
||||||
overview: map_external_generation_queue_overview(stats),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_external_generation_job_status(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
||||||
Path(job_id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, Response> {
|
|
||||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
|
||||||
let job = state
|
|
||||||
.spacetime_client()
|
|
||||||
.get_external_generation_job(ExternalGenerationJobGetRecordInput {
|
|
||||||
job_id,
|
|
||||||
owner_user_id,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| external_generation_error_response(&request_context, error))?;
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
|
||||||
Some(&request_context),
|
|
||||||
ExternalGenerationJobStatusResponse {
|
|
||||||
job: map_external_generation_job_status(job),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_external_generation_queue_overview(
|
|
||||||
stats: ExternalGenerationQueueStatsRecord,
|
|
||||||
) -> ExternalGenerationQueueOverview {
|
|
||||||
ExternalGenerationQueueOverview {
|
|
||||||
pending_count: stats.pending_count,
|
|
||||||
running_count: stats.running_active_count,
|
|
||||||
updated_at_micros: stats.now_micros,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_external_generation_job_status(
|
|
||||||
job: ExternalGenerationJobRecord,
|
|
||||||
) -> ExternalGenerationJobStatusRecord {
|
|
||||||
let (status, phase_detail, progress) = match job.status.as_str() {
|
|
||||||
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
|
|
||||||
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
|
|
||||||
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
|
|
||||||
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
|
|
||||||
};
|
|
||||||
|
|
||||||
ExternalGenerationJobStatusRecord {
|
|
||||||
operation_id: job.job_id,
|
|
||||||
status,
|
|
||||||
phase_label: job.request_label,
|
|
||||||
phase_detail: phase_detail.to_string(),
|
|
||||||
progress,
|
|
||||||
error: job.last_error_message,
|
|
||||||
updated_at_micros: job.updated_at_micros,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn external_generation_error_response(
|
|
||||||
request_context: &RequestContext,
|
|
||||||
error: SpacetimeClientError,
|
|
||||||
) -> Response {
|
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
|
||||||
.with_details(json!({
|
|
||||||
"provider": EXTERNAL_GENERATION_PROVIDER,
|
|
||||||
"message": error.to_string(),
|
|
||||||
}))
|
|
||||||
.into_response_with_context(Some(request_context))
|
|
||||||
}
|
|
||||||
@@ -1,750 +0,0 @@
|
|||||||
use std::{future::Future, io, pin::Pin, time::Duration};
|
|
||||||
|
|
||||||
use axum::extract::FromRef;
|
|
||||||
use serde_json::json;
|
|
||||||
use shared_kernel::offset_datetime_to_unix_micros;
|
|
||||||
use spacetime_client::{
|
|
||||||
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
|
|
||||||
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord,
|
|
||||||
ExternalGenerationJobRenewLeaseRecordInput,
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
task::JoinSet,
|
|
||||||
time::{Instant, sleep},
|
|
||||||
};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
jump_hop::{
|
|
||||||
JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload,
|
|
||||||
execute_jump_hop_compile_draft_worker_job,
|
|
||||||
},
|
|
||||||
puzzle::{
|
|
||||||
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
|
|
||||||
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
|
|
||||||
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
|
|
||||||
execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim,
|
|
||||||
},
|
|
||||||
puzzle_clear::{
|
|
||||||
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload,
|
|
||||||
execute_puzzle_clear_compile_draft_worker_job,
|
|
||||||
},
|
|
||||||
request_context::RequestContext,
|
|
||||||
state::{AppState, PuzzleApiState},
|
|
||||||
wooden_fish::{
|
|
||||||
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload,
|
|
||||||
execute_wooden_fish_generate_image_assets_worker_job,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft";
|
|
||||||
pub(crate) const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images";
|
|
||||||
pub(crate) const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background";
|
|
||||||
|
|
||||||
pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<(), io::Error> {
|
|
||||||
let worker_id = state.config.external_generation_worker_id.clone();
|
|
||||||
let concurrency = state.config.external_generation_worker_concurrency.max(1);
|
|
||||||
let poll_interval = state.config.external_generation_worker_poll_interval;
|
|
||||||
let lease = state.config.external_generation_worker_lease;
|
|
||||||
let mut tasks = JoinSet::new();
|
|
||||||
let mut shutdown = external_generation_worker_shutdown_signal();
|
|
||||||
|
|
||||||
info!(
|
|
||||||
worker_id,
|
|
||||||
concurrency,
|
|
||||||
poll_interval_ms = poll_interval.as_millis(),
|
|
||||||
lease_seconds = lease.as_secs(),
|
|
||||||
"external generation worker 已启动"
|
|
||||||
);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
while tasks.len() >= concurrency {
|
|
||||||
if await_worker_task_or_shutdown(&mut tasks, &mut shutdown).await {
|
|
||||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let available = concurrency.saturating_sub(tasks.len()).max(1);
|
|
||||||
let now_micros = current_utc_micros();
|
|
||||||
let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease));
|
|
||||||
|
|
||||||
let claim_jobs = state.spacetime_client().claim_external_generation_jobs(
|
|
||||||
ExternalGenerationJobClaimRecordInput {
|
|
||||||
worker_id: worker_id.clone(),
|
|
||||||
limit: available.min(u32::MAX as usize) as u32,
|
|
||||||
lease_expires_at_micros,
|
|
||||||
claimed_at_micros: now_micros,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tokio::pin!(claim_jobs);
|
|
||||||
let jobs = match tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => {
|
|
||||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
result = &mut claim_jobs => result
|
|
||||||
} {
|
|
||||||
Ok(jobs) => jobs,
|
|
||||||
Err(error) => {
|
|
||||||
error!(error = %error, "领取外部生成任务失败,等待下一轮重试");
|
|
||||||
if await_one_task_or_sleep_or_shutdown(
|
|
||||||
&mut tasks,
|
|
||||||
sleep(poll_interval),
|
|
||||||
&mut shutdown,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if jobs.is_empty() {
|
|
||||||
if await_one_task_or_sleep_or_shutdown(&mut tasks, sleep(poll_interval), &mut shutdown)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
drain_external_generation_worker_tasks(&mut tasks).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for job in jobs {
|
|
||||||
let state = state.clone();
|
|
||||||
let worker_id = worker_id.clone();
|
|
||||||
tasks.spawn(async move {
|
|
||||||
if let Err(error) =
|
|
||||||
process_external_generation_job(state, worker_id, lease, job).await
|
|
||||||
{
|
|
||||||
error!(error = %error, "external generation worker 执行任务失败");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalGenerationShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
|
|
||||||
|
|
||||||
fn external_generation_worker_shutdown_signal() -> ExternalGenerationShutdownSignal {
|
|
||||||
Box::pin(async {
|
|
||||||
wait_for_external_generation_worker_shutdown_signal().await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
async fn wait_for_external_generation_worker_shutdown_signal() {
|
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
|
||||||
|
|
||||||
let mut sigterm = signal(SignalKind::terminate()).ok();
|
|
||||||
tokio::select! {
|
|
||||||
result = tokio::signal::ctrl_c() => {
|
|
||||||
if let Err(error) = result {
|
|
||||||
warn!(error = %error, "external generation worker 监听 SIGINT 失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = async {
|
|
||||||
if let Some(sigterm) = sigterm.as_mut() {
|
|
||||||
sigterm.recv().await;
|
|
||||||
} else {
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
}
|
|
||||||
} => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
async fn wait_for_external_generation_worker_shutdown_signal() {
|
|
||||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
|
||||||
warn!(error = %error, "external generation worker 监听 Ctrl-C 失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn await_worker_task(tasks: &mut JoinSet<()>) {
|
|
||||||
if let Some(result) = tasks.join_next().await
|
|
||||||
&& let Err(error) = result
|
|
||||||
{
|
|
||||||
error!(error = %error, "external generation worker 子任务 panic");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn await_worker_task_or_shutdown(
|
|
||||||
tasks: &mut JoinSet<()>,
|
|
||||||
shutdown: &mut ExternalGenerationShutdownSignal,
|
|
||||||
) -> bool {
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => true,
|
|
||||||
_ = await_worker_task(tasks) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn await_one_task_or_sleep_or_shutdown(
|
|
||||||
tasks: &mut JoinSet<()>,
|
|
||||||
sleeper: impl Future<Output = ()>,
|
|
||||||
shutdown: &mut ExternalGenerationShutdownSignal,
|
|
||||||
) -> bool {
|
|
||||||
tokio::pin!(sleeper);
|
|
||||||
if tasks.is_empty() {
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => true,
|
|
||||||
_ = &mut sleeper => false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => true,
|
|
||||||
_ = &mut sleeper => false,
|
|
||||||
result = tasks.join_next() => {
|
|
||||||
if let Some(Err(error)) = result {
|
|
||||||
error!(error = %error, "external generation worker 子任务 panic");
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn drain_external_generation_worker_tasks(tasks: &mut JoinSet<()>) {
|
|
||||||
info!(
|
|
||||||
in_flight_jobs = tasks.len(),
|
|
||||||
"external generation worker 收到停机信号,停止领取新任务并等待当前任务完成"
|
|
||||||
);
|
|
||||||
while !tasks.is_empty() {
|
|
||||||
await_worker_task(tasks).await;
|
|
||||||
}
|
|
||||||
info!("external generation worker 已完成优雅停机");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_external_generation_job(
|
|
||||||
state: AppState,
|
|
||||||
worker_id: String,
|
|
||||||
lease: Duration,
|
|
||||||
job: ExternalGenerationJobRecord,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let heartbeat_interval = external_generation_worker_heartbeat_interval(lease);
|
|
||||||
let work = process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone());
|
|
||||||
tokio::pin!(work);
|
|
||||||
let heartbeat = sleep(heartbeat_interval);
|
|
||||||
tokio::pin!(heartbeat);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
result = &mut work => return result,
|
|
||||||
_ = &mut heartbeat => {
|
|
||||||
renew_job_lease(&state, &worker_id, &job, lease).await?;
|
|
||||||
heartbeat.as_mut().reset(Instant::now() + heartbeat_interval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_external_generation_job_once(
|
|
||||||
state: AppState,
|
|
||||||
worker_id: String,
|
|
||||||
job: ExternalGenerationJobRecord,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
match job.job_kind.as_str() {
|
|
||||||
PUZZLE_COMPILE_DRAFT_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<PuzzleCompileDraftWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("拼图生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
|
||||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
|
||||||
match execute_puzzle_compile_draft_worker_job(
|
|
||||||
&puzzle_state,
|
|
||||||
&request_context,
|
|
||||||
payload.clone(),
|
|
||||||
write_guard,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
let result = complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"progressPercent": session.progress_percent,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if result.is_ok() {
|
|
||||||
release_puzzle_compile_background_claim(&puzzle_state, &payload);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let message = error.body_text();
|
|
||||||
let should_release_claim = error.should_fail_queue_job();
|
|
||||||
let result = fail_queue_job_after_worker_error(
|
|
||||||
&state, &worker_id, &job, &error, &message,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if result.is_ok() && should_release_claim {
|
|
||||||
release_puzzle_compile_background_claim(&puzzle_state, &payload);
|
|
||||||
}
|
|
||||||
result?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PUZZLE_GENERATE_IMAGES_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<PuzzleGenerateImagesWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("拼图关卡图片生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
|
||||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
|
||||||
match execute_puzzle_generate_images_worker_job(
|
|
||||||
&puzzle_state,
|
|
||||||
&request_context,
|
|
||||||
payload,
|
|
||||||
write_guard,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"progressPercent": session.progress_percent,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let message = error.body_text();
|
|
||||||
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
|
|
||||||
.await?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<PuzzleGenerateUiBackgroundWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("拼图 UI 背景图生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
let puzzle_state = PuzzleApiState::from_ref(&state);
|
|
||||||
let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?;
|
|
||||||
match execute_puzzle_generate_ui_background_worker_job(
|
|
||||||
&puzzle_state,
|
|
||||||
&request_context,
|
|
||||||
payload,
|
|
||||||
write_guard,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"progressPercent": session.progress_percent,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let message = error.body_text();
|
|
||||||
fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message)
|
|
||||||
.await?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JUMP_HOP_COMPILE_DRAFT_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<JumpHopCompileDraftWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("跳一跳生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
match execute_jump_hop_compile_draft_worker_job(&state, &request_context, payload).await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"status": session.status,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(response) => {
|
|
||||||
let message = response_error_message(response).await;
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<PuzzleClearCompileDraftWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("拼消消生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"status": session.status,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(response) => {
|
|
||||||
let message = response_error_message(response).await;
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => {
|
|
||||||
let payload = match serde_json::from_str::<WoodenFishGenerateImageAssetsWorkerPayload>(
|
|
||||||
job.request_payload_json.as_str(),
|
|
||||||
) {
|
|
||||||
Ok(payload) => payload,
|
|
||||||
Err(error) => {
|
|
||||||
let message = format!("敲木鱼图片生成任务参数解析失败:{error}");
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
return Err(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request_context = RequestContext::new(
|
|
||||||
format!("external-generation-worker-{}", job.job_id),
|
|
||||||
format!("external-generation-worker {}", job.job_kind),
|
|
||||||
std::time::Duration::ZERO,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
match execute_wooden_fish_generate_image_assets_worker_job(
|
|
||||||
&state,
|
|
||||||
&request_context,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(session) => {
|
|
||||||
complete_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
Some(
|
|
||||||
json!({
|
|
||||||
"sessionId": session.session_id,
|
|
||||||
"status": session.status,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Err(response) => {
|
|
||||||
let message = response_error_message(response).await;
|
|
||||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
|
||||||
Err(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unknown => {
|
|
||||||
warn!(
|
|
||||||
job_id = job.job_id,
|
|
||||||
job_kind = unknown,
|
|
||||||
"external generation worker 收到暂不支持的任务类型"
|
|
||||||
);
|
|
||||||
fail_job(
|
|
||||||
&state,
|
|
||||||
&worker_id,
|
|
||||||
&job,
|
|
||||||
format!("暂不支持的外部生成任务类型:{unknown}"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn response_error_message(response: axum::response::Response) -> String {
|
|
||||||
use axum::body::to_bytes;
|
|
||||||
let status = response.status();
|
|
||||||
let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(error) => {
|
|
||||||
return format!("外部生成任务失败:{status},响应读取失败:{error}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
|
|
||||||
if body_text.is_empty() {
|
|
||||||
return format!("外部生成任务失败:{status}");
|
|
||||||
}
|
|
||||||
if let Ok(body_json) = serde_json::from_str::<serde_json::Value>(&body_text)
|
|
||||||
&& let Some(message) = body_json
|
|
||||||
.get("error")
|
|
||||||
.and_then(|error| error.get("message"))
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|message| !message.is_empty())
|
|
||||||
{
|
|
||||||
return message.to_string();
|
|
||||||
}
|
|
||||||
body_text
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fail_queue_job_after_worker_error(
|
|
||||||
state: &AppState,
|
|
||||||
worker_id: &str,
|
|
||||||
job: &ExternalGenerationJobRecord,
|
|
||||||
error: &crate::puzzle::PuzzleExternalGenerationWorkerError,
|
|
||||||
message: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if error.should_fail_queue_job() {
|
|
||||||
return fail_job(state, worker_id, job, message.to_string()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!(
|
|
||||||
job_id = job.job_id,
|
|
||||||
job_kind = job.job_kind,
|
|
||||||
"external generation worker 业务失败态尚未写回,保留任务租约等待后续重试"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn complete_job(
|
|
||||||
state: &AppState,
|
|
||||||
worker_id: &str,
|
|
||||||
job: &ExternalGenerationJobRecord,
|
|
||||||
result_payload_json: Option<String>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
|
||||||
.spacetime_client()
|
|
||||||
.complete_external_generation_job(ExternalGenerationJobCompleteRecordInput {
|
|
||||||
job_id: job.job_id.clone(),
|
|
||||||
worker_id: worker_id.to_string(),
|
|
||||||
lease_token: require_job_lease_token(job)?,
|
|
||||||
result_payload_json,
|
|
||||||
completed_at_micros: current_utc_micros(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fail_job(
|
|
||||||
state: &AppState,
|
|
||||||
worker_id: &str,
|
|
||||||
job: &ExternalGenerationJobRecord,
|
|
||||||
error_message: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let now_micros = current_utc_micros();
|
|
||||||
state
|
|
||||||
.spacetime_client()
|
|
||||||
.fail_external_generation_job(ExternalGenerationJobFailRecordInput {
|
|
||||||
job_id: job.job_id.clone(),
|
|
||||||
worker_id: worker_id.to_string(),
|
|
||||||
lease_token: require_job_lease_token(job)?,
|
|
||||||
error_message,
|
|
||||||
retry_after_micros: now_micros.saturating_add(60_000_000),
|
|
||||||
failed_at_micros: now_micros,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn renew_job_lease(
|
|
||||||
state: &AppState,
|
|
||||||
worker_id: &str,
|
|
||||||
job: &ExternalGenerationJobRecord,
|
|
||||||
lease: Duration,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let now_micros = current_utc_micros();
|
|
||||||
state
|
|
||||||
.spacetime_client()
|
|
||||||
.renew_external_generation_job_lease(ExternalGenerationJobRenewLeaseRecordInput {
|
|
||||||
job_id: job.job_id.clone(),
|
|
||||||
worker_id: worker_id.to_string(),
|
|
||||||
lease_token: require_job_lease_token(job)?,
|
|
||||||
lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)),
|
|
||||||
renewed_at_micros: now_micros,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_job_lease_token(job: &ExternalGenerationJobRecord) -> Result<String, String> {
|
|
||||||
job.lease_token
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.ok_or_else(|| format!("external_generation_job {} 缺少 lease token", job.job_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_external_generation_write_lease_guard(
|
|
||||||
worker_id: &str,
|
|
||||||
job: &ExternalGenerationJobRecord,
|
|
||||||
) -> Result<ExternalGenerationWriteLeaseGuard, String> {
|
|
||||||
Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job(
|
|
||||||
job.job_id.clone(),
|
|
||||||
worker_id.to_string(),
|
|
||||||
require_job_lease_token(job)?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn duration_micros_i64(duration: Duration) -> i64 {
|
|
||||||
duration.as_micros().min(i64::MAX as u128) as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration {
|
|
||||||
let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64;
|
|
||||||
Duration::from_millis(heartbeat_millis)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_utc_micros() -> i64 {
|
|
||||||
offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_write_guard_uses_claimed_job_lease_token() {
|
|
||||||
let job = external_generation_job_record_fixture(Some("lease-1"));
|
|
||||||
|
|
||||||
let guard = build_external_generation_write_lease_guard("worker-a", &job)
|
|
||||||
.expect("guard should build");
|
|
||||||
|
|
||||||
assert_eq!(guard.job_id.as_deref(), Some("extgen-1"));
|
|
||||||
assert_eq!(guard.worker_id.as_deref(), Some("worker-a"));
|
|
||||||
assert_eq!(guard.lease_token.as_deref(), Some("lease-1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_write_guard_requires_claimed_job_lease_token() {
|
|
||||||
let job = external_generation_job_record_fixture(None);
|
|
||||||
|
|
||||||
let error = build_external_generation_write_lease_guard("worker-a", &job)
|
|
||||||
.expect_err("missing token should fail");
|
|
||||||
|
|
||||||
assert!(error.contains("缺少 lease token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn external_generation_job_record_fixture(
|
|
||||||
lease_token: Option<&str>,
|
|
||||||
) -> ExternalGenerationJobRecord {
|
|
||||||
ExternalGenerationJobRecord {
|
|
||||||
job_id: "extgen-1".to_string(),
|
|
||||||
dedupe_key: "puzzle:generate_puzzle_images:session-1:extgen-1".to_string(),
|
|
||||||
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
|
|
||||||
owner_user_id: "user-1".to_string(),
|
|
||||||
source_module: "puzzle".to_string(),
|
|
||||||
source_entity_id: "session-1:puzzle-level-1".to_string(),
|
|
||||||
request_label: "拼图关卡图片生成".to_string(),
|
|
||||||
request_payload_json: "{}".to_string(),
|
|
||||||
status: "running".to_string(),
|
|
||||||
attempt: 1,
|
|
||||||
max_attempts: 1,
|
|
||||||
last_error_message: None,
|
|
||||||
worker_id: Some("worker-a".to_string()),
|
|
||||||
lease_expires_at: Some("2026-06-03T00:00:00Z".to_string()),
|
|
||||||
available_at: "2026-06-03T00:00:00Z".to_string(),
|
|
||||||
result_payload_json: None,
|
|
||||||
created_at: "2026-06-03T00:00:00Z".to_string(),
|
|
||||||
started_at: Some("2026-06-03T00:00:00Z".to_string()),
|
|
||||||
completed_at: None,
|
|
||||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
|
||||||
lease_token: lease_token.map(ToOwned::to_owned),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
use std::{collections::BTreeSet, future::Future, io, pin::Pin, process::Stdio, time::Duration};
|
|
||||||
|
|
||||||
use spacetime_client::ExternalGenerationQueueStatsRecord;
|
|
||||||
use tokio::{
|
|
||||||
process::Command,
|
|
||||||
time::{Instant, sleep},
|
|
||||||
};
|
|
||||||
use tracing::{error, info, warn};
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct ExternalGenerationWorkerControllerConfig {
|
|
||||||
min_workers: usize,
|
|
||||||
max_workers: usize,
|
|
||||||
target_jobs_per_worker: usize,
|
|
||||||
poll_interval: Duration,
|
|
||||||
scale_down_idle_rounds: u32,
|
|
||||||
service_template: String,
|
|
||||||
dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
struct ExternalGenerationWorkerControllerDecision {
|
|
||||||
desired_workers: usize,
|
|
||||||
should_scale_down: bool,
|
|
||||||
idle_rounds: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ExternalGenerationWorkerControllerState {
|
|
||||||
idle_rounds: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn run_external_generation_worker_controller(
|
|
||||||
state: AppState,
|
|
||||||
) -> Result<(), io::Error> {
|
|
||||||
let config = ExternalGenerationWorkerControllerConfig::from_state(&state);
|
|
||||||
let mut controller_state = ExternalGenerationWorkerControllerState::default();
|
|
||||||
let mut shutdown = external_generation_controller_shutdown_signal();
|
|
||||||
|
|
||||||
info!(
|
|
||||||
min_workers = config.min_workers,
|
|
||||||
max_workers = config.max_workers,
|
|
||||||
target_jobs_per_worker = config.target_jobs_per_worker,
|
|
||||||
poll_interval_ms = config.poll_interval.as_millis(),
|
|
||||||
scale_down_idle_rounds = config.scale_down_idle_rounds,
|
|
||||||
service_template = config.service_template,
|
|
||||||
dry_run = config.dry_run,
|
|
||||||
"external generation worker controller 已启动"
|
|
||||||
);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let tick = run_external_generation_controller_tick(&state, &config, &mut controller_state);
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => {
|
|
||||||
info!("external generation worker controller 收到停机信号");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
result = tick => {
|
|
||||||
if let Err(error) = result {
|
|
||||||
error!(error = %error, "external generation worker controller 本轮扩缩容失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_tick = sleep(config.poll_interval);
|
|
||||||
tokio::pin!(next_tick);
|
|
||||||
tokio::select! {
|
|
||||||
_ = shutdown.as_mut() => {
|
|
||||||
info!("external generation worker controller 收到停机信号");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
_ = &mut next_tick => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_external_generation_controller_tick(
|
|
||||||
state: &AppState,
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
controller_state: &mut ExternalGenerationWorkerControllerState,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let stats = state
|
|
||||||
.spacetime_client()
|
|
||||||
.get_external_generation_queue_stats()
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("读取 external_generation_job 队列统计失败:{error}"))?;
|
|
||||||
let active_instances = list_active_external_generation_worker_instances(config).await?;
|
|
||||||
let current_workers = active_instances.len();
|
|
||||||
let decision = decide_external_generation_worker_target(
|
|
||||||
&stats,
|
|
||||||
current_workers,
|
|
||||||
controller_state.idle_rounds,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
controller_state.idle_rounds = decision.idle_rounds;
|
|
||||||
|
|
||||||
info!(
|
|
||||||
pending = stats.pending_count,
|
|
||||||
delayed_pending = stats.delayed_pending_count,
|
|
||||||
claimable = stats.claimable_count,
|
|
||||||
running_active = stats.running_active_count,
|
|
||||||
expired_running = stats.expired_running_count,
|
|
||||||
oldest_claimable_age_ms = stats.oldest_claimable_age_micros.unwrap_or(0) / 1_000,
|
|
||||||
current_workers,
|
|
||||||
desired_workers = decision.desired_workers,
|
|
||||||
idle_rounds = decision.idle_rounds,
|
|
||||||
"external generation worker controller 完成队列评估"
|
|
||||||
);
|
|
||||||
|
|
||||||
reconcile_external_generation_worker_instances(config, &active_instances, &decision).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decide_external_generation_worker_target(
|
|
||||||
stats: &ExternalGenerationQueueStatsRecord,
|
|
||||||
current_workers: usize,
|
|
||||||
previous_idle_rounds: u32,
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
) -> ExternalGenerationWorkerControllerDecision {
|
|
||||||
let pressure = stats
|
|
||||||
.claimable_pending_count
|
|
||||||
.saturating_add(stats.running_active_count)
|
|
||||||
.saturating_add(stats.expired_running_count);
|
|
||||||
let desired_from_pressure =
|
|
||||||
ceil_div_usize(pressure as usize, config.target_jobs_per_worker.max(1));
|
|
||||||
let desired_workers = desired_from_pressure.clamp(config.min_workers, config.max_workers);
|
|
||||||
let is_idle = stats.claimable_count == 0
|
|
||||||
&& stats.expired_running_count == 0
|
|
||||||
&& stats.running_active_count == 0
|
|
||||||
&& desired_workers <= config.min_workers;
|
|
||||||
let idle_rounds = if is_idle {
|
|
||||||
previous_idle_rounds.saturating_add(1)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let should_scale_down = current_workers > desired_workers
|
|
||||||
&& idle_rounds >= config.scale_down_idle_rounds
|
|
||||||
&& config.scale_down_idle_rounds > 0;
|
|
||||||
|
|
||||||
ExternalGenerationWorkerControllerDecision {
|
|
||||||
desired_workers,
|
|
||||||
should_scale_down,
|
|
||||||
idle_rounds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reconcile_external_generation_worker_instances(
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
active_instances: &BTreeSet<usize>,
|
|
||||||
decision: &ExternalGenerationWorkerControllerDecision,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let current_workers = active_instances.len();
|
|
||||||
let mut started = 0usize;
|
|
||||||
for instance in 1..=config.max_workers {
|
|
||||||
if current_workers.saturating_add(started) >= decision.desired_workers {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !active_instances.contains(&instance) {
|
|
||||||
systemctl_worker_instance(config, "start", instance).await?;
|
|
||||||
started = started.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if decision.desired_workers > current_workers && started == 0 {
|
|
||||||
warn!(
|
|
||||||
current_workers,
|
|
||||||
desired_workers = decision.desired_workers,
|
|
||||||
"external generation worker controller 未找到可启动的缺口实例"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if started > 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if decision.should_scale_down && decision.desired_workers < current_workers {
|
|
||||||
if let Some(instance) = active_instances
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.copied()
|
|
||||||
.find(|instance| *instance > config.min_workers.max(1))
|
|
||||||
{
|
|
||||||
systemctl_worker_instance(config, "stop", instance).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_active_external_generation_worker_instances(
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
) -> Result<BTreeSet<usize>, String> {
|
|
||||||
let mut active_instances = BTreeSet::new();
|
|
||||||
for instance in 1..=config.max_workers {
|
|
||||||
if is_external_generation_worker_instance_active(config, instance).await? {
|
|
||||||
active_instances.insert(instance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(active_instances)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_external_generation_worker_instance_active(
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
instance: usize,
|
|
||||||
) -> Result<bool, String> {
|
|
||||||
let service = format_worker_service_name(&config.service_template, instance)?;
|
|
||||||
if config.dry_run {
|
|
||||||
return Ok(instance <= config.min_workers);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = Command::new("systemctl")
|
|
||||||
.arg("is-active")
|
|
||||||
.arg("--quiet")
|
|
||||||
.arg(&service)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("执行 systemctl is-active {service} 失败:{error}"))?;
|
|
||||||
Ok(output.status.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn systemctl_worker_instance(
|
|
||||||
config: &ExternalGenerationWorkerControllerConfig,
|
|
||||||
action: &str,
|
|
||||||
instance: usize,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let service = format_worker_service_name(&config.service_template, instance)?;
|
|
||||||
if config.dry_run {
|
|
||||||
info!(
|
|
||||||
action,
|
|
||||||
service, "external generation worker controller dry-run 跳过 systemctl"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let output = Command::new("systemctl")
|
|
||||||
.arg(action)
|
|
||||||
.arg(&service)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("执行 systemctl {action} {service} 失败:{error}"))?;
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(format!(
|
|
||||||
"systemctl {action} {service} 返回失败 status={} stderr={}",
|
|
||||||
output.status, stderr
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
action,
|
|
||||||
service,
|
|
||||||
elapsed_ms = started_at.elapsed().as_millis(),
|
|
||||||
"external generation worker controller 已执行 systemctl"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_worker_service_name(template: &str, instance: usize) -> Result<String, String> {
|
|
||||||
let instance = instance.to_string();
|
|
||||||
if template.contains("{}") {
|
|
||||||
return Ok(template.replacen("{}", &instance, 1));
|
|
||||||
}
|
|
||||||
if template.contains("%i") {
|
|
||||||
return Ok(template.replacen("%i", &instance, 1));
|
|
||||||
}
|
|
||||||
Err("external generation controller service template 必须包含 {} 或 %i".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ceil_div_usize(value: usize, divisor: usize) -> usize {
|
|
||||||
if value == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
value.saturating_add(divisor.saturating_sub(1)) / divisor.max(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExternalGenerationWorkerControllerConfig {
|
|
||||||
fn from_state(state: &AppState) -> Self {
|
|
||||||
let min_workers = state.config.external_generation_controller_min_workers;
|
|
||||||
let max_workers = state
|
|
||||||
.config
|
|
||||||
.external_generation_controller_max_workers
|
|
||||||
.max(min_workers);
|
|
||||||
Self {
|
|
||||||
min_workers,
|
|
||||||
max_workers,
|
|
||||||
target_jobs_per_worker: state
|
|
||||||
.config
|
|
||||||
.external_generation_controller_target_jobs_per_worker
|
|
||||||
.max(1),
|
|
||||||
poll_interval: state.config.external_generation_controller_poll_interval,
|
|
||||||
scale_down_idle_rounds: state
|
|
||||||
.config
|
|
||||||
.external_generation_controller_scale_down_idle_rounds,
|
|
||||||
service_template: state
|
|
||||||
.config
|
|
||||||
.external_generation_controller_service_template
|
|
||||||
.clone(),
|
|
||||||
dry_run: state.config.external_generation_controller_dry_run,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExternalGenerationControllerShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
|
|
||||||
|
|
||||||
fn external_generation_controller_shutdown_signal() -> ExternalGenerationControllerShutdownSignal {
|
|
||||||
Box::pin(async {
|
|
||||||
wait_for_external_generation_controller_shutdown_signal().await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
async fn wait_for_external_generation_controller_shutdown_signal() {
|
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
|
||||||
|
|
||||||
let mut sigterm = signal(SignalKind::terminate()).ok();
|
|
||||||
tokio::select! {
|
|
||||||
result = tokio::signal::ctrl_c() => {
|
|
||||||
if let Err(error) = result {
|
|
||||||
warn!(error = %error, "external generation worker controller 监听 SIGINT 失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = async {
|
|
||||||
if let Some(sigterm) = sigterm.as_mut() {
|
|
||||||
sigterm.recv().await;
|
|
||||||
} else {
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
}
|
|
||||||
} => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
async fn wait_for_external_generation_controller_shutdown_signal() {
|
|
||||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
|
||||||
warn!(error = %error, "external generation worker controller 监听 Ctrl-C 失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scales_up_to_max_when_queue_pressure_is_high() {
|
|
||||||
let config = controller_config_fixture();
|
|
||||||
let stats = stats_fixture(120, 0, 8);
|
|
||||||
|
|
||||||
let decision = decide_external_generation_worker_target(&stats, 1, 0, &config);
|
|
||||||
|
|
||||||
assert_eq!(decision.desired_workers, 8);
|
|
||||||
assert!(!decision.should_scale_down);
|
|
||||||
assert_eq!(decision.idle_rounds, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scale_down_requires_consecutive_idle_rounds() {
|
|
||||||
let config = controller_config_fixture();
|
|
||||||
let stats = stats_fixture(0, 0, 0);
|
|
||||||
|
|
||||||
let first = decide_external_generation_worker_target(&stats, 5, 0, &config);
|
|
||||||
let ready = decide_external_generation_worker_target(
|
|
||||||
&stats,
|
|
||||||
5,
|
|
||||||
config.scale_down_idle_rounds.saturating_sub(1),
|
|
||||||
&config,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(first.desired_workers, config.min_workers);
|
|
||||||
assert!(!first.should_scale_down);
|
|
||||||
assert!(ready.should_scale_down);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn running_jobs_hold_capacity_before_scale_down() {
|
|
||||||
let config = controller_config_fixture();
|
|
||||||
let stats = stats_fixture(0, 6, 0);
|
|
||||||
|
|
||||||
let decision = decide_external_generation_worker_target(&stats, 5, 5, &config);
|
|
||||||
|
|
||||||
assert_eq!(decision.desired_workers, 3);
|
|
||||||
assert!(!decision.should_scale_down);
|
|
||||||
assert_eq!(decision.idle_rounds, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expired_running_jobs_are_not_counted_twice_as_claimable_pressure() {
|
|
||||||
let config = controller_config_fixture();
|
|
||||||
let stats = stats_fixture(0, 0, 3);
|
|
||||||
|
|
||||||
let decision = decide_external_generation_worker_target(&stats, 1, 0, &config);
|
|
||||||
|
|
||||||
assert_eq!(decision.desired_workers, 2);
|
|
||||||
assert!(!decision.should_scale_down);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn formats_worker_service_name_with_supported_templates() {
|
|
||||||
assert_eq!(
|
|
||||||
format_worker_service_name("genarrative-external-generation-worker@{}.service", 3)
|
|
||||||
.expect("format"),
|
|
||||||
"genarrative-external-generation-worker@3.service"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
format_worker_service_name("worker@%i.service", 7).expect("format"),
|
|
||||||
"worker@7.service"
|
|
||||||
);
|
|
||||||
assert!(format_worker_service_name("worker.service", 1).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dry_run_reconcile_does_not_start_low_number_gaps_when_capacity_is_enough() {
|
|
||||||
let config = controller_config_fixture();
|
|
||||||
let active_instances = BTreeSet::from([3usize, 4usize]);
|
|
||||||
let decision = ExternalGenerationWorkerControllerDecision {
|
|
||||||
desired_workers: 2,
|
|
||||||
should_scale_down: false,
|
|
||||||
idle_rounds: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result =
|
|
||||||
reconcile_external_generation_worker_instances(&config, &active_instances, &decision)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn controller_config_fixture() -> ExternalGenerationWorkerControllerConfig {
|
|
||||||
ExternalGenerationWorkerControllerConfig {
|
|
||||||
min_workers: 1,
|
|
||||||
max_workers: 8,
|
|
||||||
target_jobs_per_worker: 2,
|
|
||||||
poll_interval: Duration::from_secs(10),
|
|
||||||
scale_down_idle_rounds: 3,
|
|
||||||
service_template: "genarrative-external-generation-worker@{}.service".to_string(),
|
|
||||||
dry_run: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stats_fixture(
|
|
||||||
claimable_pending_count: u32,
|
|
||||||
running_active_count: u32,
|
|
||||||
expired_running_count: u32,
|
|
||||||
) -> ExternalGenerationQueueStatsRecord {
|
|
||||||
let claimable_count = claimable_pending_count.saturating_add(expired_running_count);
|
|
||||||
ExternalGenerationQueueStatsRecord {
|
|
||||||
pending_count: claimable_pending_count,
|
|
||||||
delayed_pending_count: 0,
|
|
||||||
claimable_pending_count,
|
|
||||||
running_active_count,
|
|
||||||
expired_running_count,
|
|
||||||
terminal_count: 0,
|
|
||||||
claimable_count,
|
|
||||||
oldest_claimable_age_micros: None,
|
|
||||||
now_micros: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
928
server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs
Normal file
928
server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
// 跳一跳图集自适应切片算法模块。
|
||||||
|
// 提供两种基于图像内容的自适应 cell 边界检测算法:
|
||||||
|
// - SeedRefinement: 种子点精修(默认),在固定网格分界线附近搜索 density 最低点。
|
||||||
|
// - ValleyDetection: 全谷检测,高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优。
|
||||||
|
// 两种算法均参数化,支持任意 rows × cols 网格配置,默认 6×3。
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use image;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
http_error::AppError,
|
||||||
|
jump_hop::{
|
||||||
|
JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices,
|
||||||
|
JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS,
|
||||||
|
crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label,
|
||||||
|
jump_hop_tile_type_by_index,
|
||||||
|
},
|
||||||
|
openai_image_generation::DownloadedOpenAiImage,
|
||||||
|
};
|
||||||
|
use shared_contracts::jump_hop::JumpHopTileFaceKey;
|
||||||
|
|
||||||
|
/// 默认 tile 行数
|
||||||
|
pub(crate) const DEFAULT_TILE_ROWS: u32 = 6;
|
||||||
|
/// 默认 tile 列数
|
||||||
|
pub(crate) const DEFAULT_TILE_COLS: u32 = 3;
|
||||||
|
|
||||||
|
/// 自适应切片算法类型(控制 atlas 级 6×3 cell 网格检测)
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AtlasSliceAlgorithm {
|
||||||
|
/// 种子点精修:在固定网格分界线附近搜索 density 最低点(默认)
|
||||||
|
SeedRefinement,
|
||||||
|
/// 全谷检测:高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优
|
||||||
|
ValleyDetection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AtlasSliceAlgorithm {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::SeedRefinement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自适应 cell 网格检测结果
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct AdaptiveCellGrid {
|
||||||
|
/// 行边界位置 [height],长度 = rows + 1
|
||||||
|
pub row_boundaries: Vec<u32>,
|
||||||
|
/// 列边界位置 [width],长度 = cols + 1
|
||||||
|
pub col_boundaries: Vec<u32>,
|
||||||
|
/// 使用的算法
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub algorithm: AtlasSliceAlgorithm,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Density 计算
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 从 RGBA 像素计算行投影 density(每行非透明像素占比)
|
||||||
|
pub(crate) fn compute_row_density(pixels: &[u8], width: u32, height: u32) -> Vec<f32> {
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let stride = w * 4;
|
||||||
|
let mut density = vec![0.0f32; h];
|
||||||
|
let total = w as f32;
|
||||||
|
for y in 0..h {
|
||||||
|
let row_start = y * stride;
|
||||||
|
let mut content = 0u32;
|
||||||
|
for x in 0..w {
|
||||||
|
if pixels[row_start + x * 4 + 3] > 0 {
|
||||||
|
content += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
density[y] = content as f32 / total;
|
||||||
|
}
|
||||||
|
density
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 RGBA 像素计算列投影 density(每列非透明像素占比)
|
||||||
|
pub(crate) fn compute_col_density(pixels: &[u8], width: u32, height: u32) -> Vec<f32> {
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let stride = w * 4;
|
||||||
|
let mut density = vec![0.0f32; w];
|
||||||
|
let total = h as f32;
|
||||||
|
for x in 0..w {
|
||||||
|
let mut content = 0u32;
|
||||||
|
for y in 0..h {
|
||||||
|
if pixels[y * stride + x * 4 + 3] > 0 {
|
||||||
|
content += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
density[x] = content as f32 / total;
|
||||||
|
}
|
||||||
|
density
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 共享工具
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 在 [seed-radius, seed+radius] 范围内找 density 最小值的 index
|
||||||
|
fn find_min_density_position(density: &[f32], seed: u32, radius: u32) -> u32 {
|
||||||
|
let lo = seed.saturating_sub(radius) as usize;
|
||||||
|
let hi = (seed + radius).min(density.len().saturating_sub(1) as u32) as usize;
|
||||||
|
if lo >= density.len() || lo > hi {
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
let mut best = seed as usize;
|
||||||
|
let mut best_val = density[best.min(density.len() - 1)];
|
||||||
|
for i in lo..=hi {
|
||||||
|
if density[i] < best_val {
|
||||||
|
best_val = density[i];
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保证边界单调递增(禁止交叉)
|
||||||
|
fn enforce_monotonic(boundaries: &mut [u32]) {
|
||||||
|
for i in 1..boundaries.len() {
|
||||||
|
if boundaries[i] <= boundaries[i - 1] {
|
||||||
|
boundaries[i] = boundaries[i - 1] + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 算法 A: 种子点精修 (Seed Refinement)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 种子点精修:对每条固定网格分界线,在 ±radius 搜索窗口内找 density 最低点。
|
||||||
|
///
|
||||||
|
/// * `density` - 一维投影 density 序列
|
||||||
|
/// * `seeds` - 固定网格分界线位置(不含 0 和 max)
|
||||||
|
/// * `radius` - 搜索半径
|
||||||
|
///
|
||||||
|
/// 返回精修后的分界线位置(不含 0 和 max)。
|
||||||
|
pub(crate) fn refine_boundaries_seed(
|
||||||
|
density: &[f32],
|
||||||
|
seeds: &[u32],
|
||||||
|
radius: u32,
|
||||||
|
) -> Vec<u32> {
|
||||||
|
let mut refined = Vec::with_capacity(seeds.len());
|
||||||
|
for &seed in seeds {
|
||||||
|
let pos = find_min_density_position(density, seed, radius);
|
||||||
|
refined.push(pos);
|
||||||
|
}
|
||||||
|
enforce_monotonic(&mut refined);
|
||||||
|
refined
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 种子点精修完整流程:计算 density → 生成种子 → 精修 → 组装边界
|
||||||
|
pub(crate) fn detect_cell_grid_seed(
|
||||||
|
pixels: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
) -> AdaptiveCellGrid {
|
||||||
|
let row_density = compute_row_density(pixels, width, height);
|
||||||
|
let col_density = compute_col_density(pixels, width, height);
|
||||||
|
|
||||||
|
let cell_height = (height / rows).max(1);
|
||||||
|
let cell_width = (width / cols).max(1);
|
||||||
|
let radius_row = (cell_height / 3).max(1);
|
||||||
|
let radius_col = (cell_width / 3).max(1);
|
||||||
|
|
||||||
|
let row_seeds: Vec<u32> = (1..rows).map(|i| i * height / rows).collect();
|
||||||
|
let col_seeds: Vec<u32> = (1..cols).map(|i| i * width / cols).collect();
|
||||||
|
|
||||||
|
let row_splits = refine_boundaries_seed(&row_density, &row_seeds, radius_row);
|
||||||
|
let col_splits = refine_boundaries_seed(&col_density, &col_seeds, radius_col);
|
||||||
|
|
||||||
|
let mut row_boundaries = vec![0u32];
|
||||||
|
row_boundaries.extend(row_splits);
|
||||||
|
row_boundaries.push(height);
|
||||||
|
|
||||||
|
let mut col_boundaries = vec![0u32];
|
||||||
|
col_boundaries.extend(col_splits);
|
||||||
|
col_boundaries.push(width);
|
||||||
|
|
||||||
|
AdaptiveCellGrid {
|
||||||
|
row_boundaries,
|
||||||
|
col_boundaries,
|
||||||
|
algorithm: AtlasSliceAlgorithm::SeedRefinement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 算法 B: 谷检测 (Valley Detection)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 一维高斯平滑核
|
||||||
|
fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec<f32> {
|
||||||
|
let n = signal.len();
|
||||||
|
if n == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let radius = (sigma * 3.0).ceil() as isize;
|
||||||
|
let mut kernel = Vec::new();
|
||||||
|
let mut kernel_sum = 0.0f32;
|
||||||
|
for i in -radius..=radius {
|
||||||
|
let w = (-(i as f32).powi(2) / (2.0 * sigma * sigma)).exp();
|
||||||
|
kernel.push(w);
|
||||||
|
kernel_sum += w;
|
||||||
|
}
|
||||||
|
for w in &mut kernel {
|
||||||
|
*w /= kernel_sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = vec![0.0f32; n];
|
||||||
|
for i in 0..n {
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
let mut w_sum = 0.0f32;
|
||||||
|
for (k, &w) in kernel.iter().enumerate() {
|
||||||
|
let idx = i as isize + k as isize - radius;
|
||||||
|
if idx >= 0 && idx < n as isize {
|
||||||
|
acc += signal[idx as usize] * w;
|
||||||
|
w_sum += w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w_sum > 0.0 {
|
||||||
|
result[i] = acc / w_sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 低于阈值的连续区间 → 候选谷列表
|
||||||
|
fn extract_valleys_below_threshold(
|
||||||
|
signal: &[f32],
|
||||||
|
threshold: f32,
|
||||||
|
) -> Vec<(usize, usize)> {
|
||||||
|
let n = signal.len();
|
||||||
|
let mut valleys = Vec::new();
|
||||||
|
let mut in_valley = false;
|
||||||
|
let mut start = 0usize;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
if signal[i] <= threshold {
|
||||||
|
if !in_valley {
|
||||||
|
start = i;
|
||||||
|
in_valley = true;
|
||||||
|
}
|
||||||
|
} else if in_valley {
|
||||||
|
valleys.push((start, i - 1));
|
||||||
|
in_valley = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in_valley {
|
||||||
|
valleys.push((start, n - 1));
|
||||||
|
}
|
||||||
|
valleys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并间距 < min_gap 的相邻谷
|
||||||
|
fn merge_close_valleys(
|
||||||
|
valleys: &[(usize, usize)],
|
||||||
|
min_gap: usize,
|
||||||
|
) -> Vec<(usize, usize)> {
|
||||||
|
if valleys.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let mut merged = Vec::new();
|
||||||
|
let mut cur_start = valleys[0].0;
|
||||||
|
let mut cur_end = valleys[0].1;
|
||||||
|
|
||||||
|
for &(s, e) in &valleys[1..] {
|
||||||
|
if s - cur_end <= min_gap {
|
||||||
|
cur_end = e;
|
||||||
|
} else {
|
||||||
|
merged.push((cur_start, cur_end));
|
||||||
|
cur_start = s;
|
||||||
|
cur_end = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push((cur_start, cur_end));
|
||||||
|
merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 谷的几何中心
|
||||||
|
fn valley_centers(valleys: &[(usize, usize)]) -> Vec<u32> {
|
||||||
|
valleys.iter().map(|&(s, e)| ((s + e) / 2) as u32).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组
|
||||||
|
fn select_spaced_valleys(
|
||||||
|
centers: &[u32],
|
||||||
|
expected_spacing: f32,
|
||||||
|
target_count: usize,
|
||||||
|
) -> Vec<u32> {
|
||||||
|
if centers.len() <= target_count {
|
||||||
|
return centers.to_vec();
|
||||||
|
}
|
||||||
|
let mut best_score = f32::MAX;
|
||||||
|
let mut best_centers = vec![];
|
||||||
|
|
||||||
|
for start in 0..=centers.len() - target_count {
|
||||||
|
let window = ¢ers[start..start + target_count];
|
||||||
|
let mut score = 0.0f32;
|
||||||
|
for i in 1..window.len() {
|
||||||
|
let ratio = (window[i] - window[i - 1]) as f32 / expected_spacing;
|
||||||
|
score += (ratio - 1.0).powi(2);
|
||||||
|
}
|
||||||
|
if score < best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_centers = window.to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best_centers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 谷检测完整流程
|
||||||
|
pub(crate) fn refine_boundaries_valley(
|
||||||
|
density: &[f32],
|
||||||
|
expected_cell_count: u32,
|
||||||
|
expected_cell_size: f32,
|
||||||
|
total_length: u32,
|
||||||
|
) -> Result<Vec<u32>, &'static str> {
|
||||||
|
if expected_cell_count <= 1 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let expected_valleys = (expected_cell_count - 1) as usize;
|
||||||
|
|
||||||
|
// 步骤1: 高斯平滑
|
||||||
|
let sigma = expected_cell_size / 4.0;
|
||||||
|
let smoothed = gaussian_smooth_1d(density, sigma);
|
||||||
|
|
||||||
|
// 步骤2: 自适应阈值
|
||||||
|
let peak = smoothed.iter().cloned().fold(0.0f32, f32::max);
|
||||||
|
let threshold = f32::max(peak * 0.15, 0.02);
|
||||||
|
|
||||||
|
// 步骤3: 提取候选谷
|
||||||
|
let raw_valleys = extract_valleys_below_threshold(&smoothed, threshold);
|
||||||
|
if raw_valleys.is_empty() {
|
||||||
|
return Err("未检测到候选谷");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤4: 合并相邻谷
|
||||||
|
let min_gap = (expected_cell_size * 0.5) as usize;
|
||||||
|
let merged = merge_close_valleys(&raw_valleys, min_gap);
|
||||||
|
|
||||||
|
// 步骤5: 过滤窄噪声谷(宽度 < 3px)
|
||||||
|
let filtered: Vec<_> = merged
|
||||||
|
.into_iter()
|
||||||
|
.filter(|&(s, e)| e >= s && e - s >= 3)
|
||||||
|
.collect();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return Err("过滤后无有效谷");
|
||||||
|
}
|
||||||
|
|
||||||
|
let centers = valley_centers(&filtered);
|
||||||
|
|
||||||
|
// 步骤6: 候选太多时按谷深排序取 top
|
||||||
|
let candidates = if centers.len() > expected_valleys + 2 {
|
||||||
|
let mut scored: Vec<_> = filtered
|
||||||
|
.iter()
|
||||||
|
.map(|&(s, e)| {
|
||||||
|
let avg = smoothed[s..=e].iter().sum::<f32>() / (e - s + 1) as f32;
|
||||||
|
let depth = peak - avg;
|
||||||
|
(depth, (s + e) / 2)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
scored.truncate(expected_valleys + 2);
|
||||||
|
let mut c: Vec<u32> = scored.into_iter().map(|(_, center)| center as u32).collect();
|
||||||
|
c.sort();
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
centers
|
||||||
|
};
|
||||||
|
|
||||||
|
// 步骤7: 滑窗选最优 expected_valleys 个
|
||||||
|
let selected = select_spaced_valleys(&candidates, expected_cell_size, expected_valleys);
|
||||||
|
|
||||||
|
// 步骤8: 校验间距合理性
|
||||||
|
let min_spacing = (expected_cell_size * 0.5) as u32;
|
||||||
|
let max_spacing = (expected_cell_size * 1.8) as u32;
|
||||||
|
for i in 1..selected.len() {
|
||||||
|
let gap = selected[i] - selected[i - 1];
|
||||||
|
if gap < min_spacing || gap > max_spacing {
|
||||||
|
return Err("谷间距异常");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 首尾不能太靠边
|
||||||
|
let min_edge = (expected_cell_size / 3.0) as u32;
|
||||||
|
if selected[0] < min_edge || total_length - selected[selected.len() - 1] < min_edge {
|
||||||
|
return Err("谷太靠近边界");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 谷检测完整流程:计算 density → 谷检测 → 组装边界
|
||||||
|
pub(crate) fn detect_cell_grid_valley(
|
||||||
|
pixels: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
) -> Result<AdaptiveCellGrid, &'static str> {
|
||||||
|
let row_density = compute_row_density(pixels, width, height);
|
||||||
|
let col_density = compute_col_density(pixels, width, height);
|
||||||
|
|
||||||
|
let cell_height = (height / rows).max(1) as f32;
|
||||||
|
let cell_width = (width / cols).max(1) as f32;
|
||||||
|
|
||||||
|
let row_splits = refine_boundaries_valley(&row_density, rows, cell_height, height)?;
|
||||||
|
let col_splits = refine_boundaries_valley(&col_density, cols, cell_width, width)?;
|
||||||
|
|
||||||
|
let mut row_boundaries = vec![0u32];
|
||||||
|
row_boundaries.extend(row_splits);
|
||||||
|
row_boundaries.push(height);
|
||||||
|
|
||||||
|
let mut col_boundaries = vec![0u32];
|
||||||
|
col_boundaries.extend(col_splits);
|
||||||
|
col_boundaries.push(width);
|
||||||
|
|
||||||
|
Ok(AdaptiveCellGrid {
|
||||||
|
row_boundaries,
|
||||||
|
col_boundaries,
|
||||||
|
algorithm: AtlasSliceAlgorithm::ValleyDetection,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 主入口:自适应切片
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 使用自适应算法对洋红去背后的图集进行切片。
|
||||||
|
///
|
||||||
|
/// * `image` - 洋红去背后的图集图片
|
||||||
|
/// * `rows` - cell 行数(默认 6)
|
||||||
|
/// * `cols` - cell 列数(默认 3)
|
||||||
|
/// * `algorithm` - 自适应算法
|
||||||
|
pub(crate) fn slice_tile_atlas_adaptive(
|
||||||
|
image: &DownloadedOpenAiImage,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
algorithm: AtlasSliceAlgorithm,
|
||||||
|
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||||||
|
let source = image::load_from_memory(image.bytes.as_slice())
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("跳一跳地板贴图图集解码失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?
|
||||||
|
.to_rgba8();
|
||||||
|
let width = source.width();
|
||||||
|
let height = source.height();
|
||||||
|
let pixels = source.as_raw();
|
||||||
|
|
||||||
|
// 自适应检测 cell 网格
|
||||||
|
let grid = match algorithm {
|
||||||
|
AtlasSliceAlgorithm::SeedRefinement => {
|
||||||
|
detect_cell_grid_seed(pixels, width, height, rows, cols)
|
||||||
|
}
|
||||||
|
AtlasSliceAlgorithm::ValleyDetection => {
|
||||||
|
detect_cell_grid_valley(pixels, width, height, rows, cols)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
// 谷检测失败时回退到种子点精修
|
||||||
|
detect_cell_grid_seed(pixels, width, height, rows, cols)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if grid.row_boundaries.len() != (rows + 1) as usize
|
||||||
|
|| grid.col_boundaries.len() != (cols + 1) as usize
|
||||||
|
{
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!(
|
||||||
|
"自适应网格检测结果异常:期望 {}×{},实际 {}×{}",
|
||||||
|
rows + 1,
|
||||||
|
cols + 1,
|
||||||
|
grid.row_boundaries.len(),
|
||||||
|
grid.col_boundaries.len(),
|
||||||
|
),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tile_count = (rows * cols) as usize;
|
||||||
|
let mut slices = Vec::with_capacity(tile_count);
|
||||||
|
let mut index = 0usize;
|
||||||
|
|
||||||
|
for row in 0..rows {
|
||||||
|
for col in 0..cols {
|
||||||
|
let x0 = grid.col_boundaries[col as usize];
|
||||||
|
let x1 = grid.col_boundaries[col as usize + 1];
|
||||||
|
let y0 = grid.row_boundaries[row as usize];
|
||||||
|
let y1 = grid.row_boundaries[row as usize + 1];
|
||||||
|
let tile_width = x1.saturating_sub(x0).max(1);
|
||||||
|
let tile_height = y1.saturating_sub(y0).max(1);
|
||||||
|
|
||||||
|
let faces = slice_jump_hop_tile_uv_faces_blob(
|
||||||
|
&source,
|
||||||
|
x0,
|
||||||
|
y0,
|
||||||
|
tile_width,
|
||||||
|
tile_height,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
slices.push(JumpHopTileAtlasSlice {
|
||||||
|
tile_type: jump_hop_tile_type_by_index(index),
|
||||||
|
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||||||
|
faces,
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(slices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cell 内 UV 面提取(与固定网格逻辑相同,接收 cell 边界参数)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn slice_jump_hop_tile_uv_faces_adaptive(
|
||||||
|
source: &image::RgbaImage,
|
||||||
|
tile_x: u32,
|
||||||
|
tile_y: u32,
|
||||||
|
tile_width: u32,
|
||||||
|
tile_height: u32,
|
||||||
|
atlas_row: u32,
|
||||||
|
atlas_col: u32,
|
||||||
|
) -> Result<JumpHopTileFaceSlices, AppError> {
|
||||||
|
let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS)
|
||||||
|
.min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS)
|
||||||
|
.max(1);
|
||||||
|
let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS);
|
||||||
|
let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS);
|
||||||
|
let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2);
|
||||||
|
let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2);
|
||||||
|
|
||||||
|
Ok(JumpHopTileFaceSlices {
|
||||||
|
top: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
|
||||||
|
)?,
|
||||||
|
front: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
|
||||||
|
)?,
|
||||||
|
right: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
|
||||||
|
)?,
|
||||||
|
back: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
|
||||||
|
)?,
|
||||||
|
left: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
|
||||||
|
)?,
|
||||||
|
bottom: slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn slice_jump_hop_tile_uv_face_adaptive(
|
||||||
|
source: &image::RgbaImage,
|
||||||
|
uv_x: u32,
|
||||||
|
uv_y: u32,
|
||||||
|
face_side: u32,
|
||||||
|
atlas_row: u32,
|
||||||
|
atlas_col: u32,
|
||||||
|
face: JumpHopTileFaceKey,
|
||||||
|
face_col: u32,
|
||||||
|
face_row: u32,
|
||||||
|
) -> Result<JumpHopTileFaceSlice, AppError> {
|
||||||
|
let cleaned = crop_jump_hop_tile_texture_cell(
|
||||||
|
source,
|
||||||
|
uv_x.saturating_add(face_col.saturating_mul(face_side)),
|
||||||
|
uv_y.saturating_add(face_row.saturating_mul(face_side)),
|
||||||
|
face_side,
|
||||||
|
face_side,
|
||||||
|
);
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
cleaned
|
||||||
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("跳一跳地板 UV 面贴图切割失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let face_label = jump_hop_tile_face_key_label(&face);
|
||||||
|
|
||||||
|
Ok(JumpHopTileFaceSlice {
|
||||||
|
face,
|
||||||
|
source_atlas_cell: format!(
|
||||||
|
"row-{}-col-{}/{}",
|
||||||
|
atlas_row + 1,
|
||||||
|
atlas_col + 1,
|
||||||
|
face_label
|
||||||
|
),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Blob + Gradient 驱动 UV 面切分
|
||||||
|
//
|
||||||
|
// 1. BFS 找主 blob,构造仅含 blob 的 cleaned 图像
|
||||||
|
// 2. 行/列 density → 平滑 → gradient → 8 边界
|
||||||
|
// 3. 3×3 block → 5 有效块 → Block(1,2) 拆分 → 6 块
|
||||||
|
// 4. 每块 max opaque rectangle → 缩放
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const BLOB_ALPHA: u8 = 48;
|
||||||
|
const MIN_BLOB_AREA: usize = 64;
|
||||||
|
const GRAD_SMOOTH: usize = 3;
|
||||||
|
|
||||||
|
// ---- 1. BFS 主 blob + 构造 cleaned 图像 ----
|
||||||
|
|
||||||
|
fn build_cleaned_tile(
|
||||||
|
source: &image::RgbaImage,
|
||||||
|
tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32,
|
||||||
|
) -> Option<image::RgbaImage> {
|
||||||
|
let pixels = source.as_raw();
|
||||||
|
let sw = source.width() as usize;
|
||||||
|
let stride = sw * 4;
|
||||||
|
let total = (tile_w * tile_h) as usize;
|
||||||
|
let mut visited = vec![false; total];
|
||||||
|
let mut queue = Vec::<usize>::new();
|
||||||
|
let mut best_comp = Vec::<usize>::new();
|
||||||
|
|
||||||
|
let idx = |lx: u32, ly: u32| (ly * tile_w + lx) as usize;
|
||||||
|
|
||||||
|
for sy in 0..tile_h {
|
||||||
|
for sx in 0..tile_w {
|
||||||
|
let si = idx(sx, sy);
|
||||||
|
if visited[si] { continue; }
|
||||||
|
let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4;
|
||||||
|
if pixels[go + 3] < BLOB_ALPHA { continue; }
|
||||||
|
|
||||||
|
queue.clear();
|
||||||
|
queue.push(si);
|
||||||
|
visited[si] = true;
|
||||||
|
let mut qi = 0;
|
||||||
|
while qi < queue.len() {
|
||||||
|
let cur = queue[qi]; qi += 1;
|
||||||
|
let cx = cur as u32 % tile_w;
|
||||||
|
let cy = cur as u32 / tile_w;
|
||||||
|
for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] {
|
||||||
|
let nx = cx as i32 + dx;
|
||||||
|
let ny = cy as i32 + dy;
|
||||||
|
if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; }
|
||||||
|
let ni = idx(nx as u32, ny as u32);
|
||||||
|
if visited[ni] { continue; }
|
||||||
|
let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4;
|
||||||
|
if pixels[ngo + 3] >= BLOB_ALPHA {
|
||||||
|
visited[ni] = true;
|
||||||
|
queue.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let area = queue.len();
|
||||||
|
if area < MIN_BLOB_AREA { continue; }
|
||||||
|
if area > best_comp.len() {
|
||||||
|
best_comp = queue.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_comp.is_empty() { return None; }
|
||||||
|
|
||||||
|
// 构造 cleaned 图像:仅含主 blob
|
||||||
|
let mut cleaned = image::RgbaImage::new(tile_w, tile_h);
|
||||||
|
for &pi in &best_comp {
|
||||||
|
let lx = pi as u32 % tile_w;
|
||||||
|
let ly = pi as u32 / tile_w;
|
||||||
|
let gx = tile_x + lx;
|
||||||
|
let gy = tile_y + ly;
|
||||||
|
let go = (gy as usize * sw + gx as usize) * 4;
|
||||||
|
cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]]));
|
||||||
|
}
|
||||||
|
Some(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. density + gradient → 边界 ----
|
||||||
|
|
||||||
|
fn smooth_1d(signal: &[f32], window: usize) -> Vec<f32> {
|
||||||
|
if signal.len() <= window { return signal.to_vec(); }
|
||||||
|
let hw = window / 2;
|
||||||
|
(0..signal.len()).map(|i| {
|
||||||
|
let lo = i.saturating_sub(hw);
|
||||||
|
let hi = (i + hw).min(signal.len() - 1);
|
||||||
|
let sum: f32 = signal[lo..=hi].iter().sum();
|
||||||
|
sum / (hi - lo + 1) as f32
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gradient(signal: &[f32]) -> Vec<f32> {
|
||||||
|
if signal.len() < 2 { return vec![]; }
|
||||||
|
(0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。
|
||||||
|
/// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。
|
||||||
|
struct GradPeak { idx: usize, val: f32 }
|
||||||
|
fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec<GradPeak> {
|
||||||
|
if grad.len() < 2 { return vec![]; }
|
||||||
|
// 取绝对值后找局部极大
|
||||||
|
let abs_grad: Vec<f32> = grad.iter().map(|&g| g.abs()).collect();
|
||||||
|
let mut peaks: Vec<GradPeak> = (1..abs_grad.len()-1)
|
||||||
|
.filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0)
|
||||||
|
.map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号
|
||||||
|
.collect();
|
||||||
|
peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
// 去重:间距小于 min_sep 的只保留最大者
|
||||||
|
let mut chosen = Vec::new();
|
||||||
|
for p in peaks {
|
||||||
|
if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) {
|
||||||
|
chosen.push(p);
|
||||||
|
if chosen.len() >= count { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行 density → gradient → y₀,y₁,y₂,y₃
|
||||||
|
fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> {
|
||||||
|
// 行 density
|
||||||
|
let mut row_density = Vec::with_capacity(tile_h as usize);
|
||||||
|
for y in 0..tile_h {
|
||||||
|
let mut cnt = 0u32;
|
||||||
|
for x in 0..tile_w {
|
||||||
|
if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; }
|
||||||
|
}
|
||||||
|
row_density.push(cnt as f32 / tile_w as f32);
|
||||||
|
}
|
||||||
|
let smooth = smooth_1d(&row_density, GRAD_SMOOTH);
|
||||||
|
let grad = gradient(&smooth);
|
||||||
|
let peaks = find_gradient_peaks(&grad, 4, 4);
|
||||||
|
if peaks.len() < 2 { return None; }
|
||||||
|
|
||||||
|
// 分离正负
|
||||||
|
let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect();
|
||||||
|
let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect();
|
||||||
|
if pos.len() < 1 || neg.len() < 1 { return None; }
|
||||||
|
|
||||||
|
// y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄)
|
||||||
|
let y1 = pos[0].idx as u32;
|
||||||
|
let y2 = neg[0].idx as u32;
|
||||||
|
let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32;
|
||||||
|
let y3 = (row_density.len() as u32).saturating_sub(
|
||||||
|
1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32
|
||||||
|
) + 1;
|
||||||
|
|
||||||
|
if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; }
|
||||||
|
Some((y0, y1, y2, y3))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列高度 profile(每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃
|
||||||
|
fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> {
|
||||||
|
// 每列的 blob 高度
|
||||||
|
let mut col_height = Vec::with_capacity(tile_w as usize);
|
||||||
|
for x in 0..tile_w {
|
||||||
|
let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA);
|
||||||
|
let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA);
|
||||||
|
col_height.push(
|
||||||
|
first.map_or(0.0, |f| {
|
||||||
|
let l = last.unwrap_or(f);
|
||||||
|
(l - f + 1) as f32 / (y3 - y0).max(1) as f32
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let smooth = smooth_1d(&col_height, GRAD_SMOOTH);
|
||||||
|
let grad = gradient(&smooth);
|
||||||
|
let peaks = find_gradient_peaks(&grad, 4, 4);
|
||||||
|
if peaks.len() < 2 { return None; }
|
||||||
|
|
||||||
|
let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect();
|
||||||
|
let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect();
|
||||||
|
if pos.len() < 1 || neg.len() < 1 { return None; }
|
||||||
|
|
||||||
|
let x1 = pos[0].idx as u32;
|
||||||
|
let x2 = neg[0].idx as u32;
|
||||||
|
let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32;
|
||||||
|
let x3 = (tile_w as usize).saturating_sub(
|
||||||
|
1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0)
|
||||||
|
) as u32 + 1;
|
||||||
|
|
||||||
|
if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; }
|
||||||
|
Some((x0, x1, x2, x3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. max opaque rectangle per block ----
|
||||||
|
|
||||||
|
/// 在 block 范围内基于 histogram 找最大全不透明矩形。
|
||||||
|
fn max_opaque_rect(
|
||||||
|
cleaned: &image::RgbaImage,
|
||||||
|
bx0: u32, by0: u32, bw: u32, bh: u32,
|
||||||
|
) -> Option<(u32, u32, u32, u32)> {
|
||||||
|
let mut heights = vec![0u32; bw as usize];
|
||||||
|
let mut best_area = 0u32;
|
||||||
|
let mut best = (0u32, 0u32, 1u32, 1u32);
|
||||||
|
|
||||||
|
for ly in 0..bh {
|
||||||
|
for lx in 0..bw {
|
||||||
|
if cleaned.get_pixel(bx0 + lx, by0 + ly).0[3] >= BLOB_ALPHA {
|
||||||
|
heights[lx as usize] += 1;
|
||||||
|
} else {
|
||||||
|
heights[lx as usize] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// histogram max rect
|
||||||
|
let mut stack: Vec<(u32, u32)> = Vec::new(); // (start_x, height)
|
||||||
|
for (x, &h) in heights.iter().enumerate() {
|
||||||
|
let x = x as u32;
|
||||||
|
let mut start = x;
|
||||||
|
while stack.last().map_or(false, |&(_, sh)| sh > h) {
|
||||||
|
let (sx, sh) = stack.pop().unwrap();
|
||||||
|
let area = sh * (x - sx);
|
||||||
|
if area > best_area {
|
||||||
|
best_area = area;
|
||||||
|
best = (bx0 + sx, by0 + ly - sh + 1, x - sx, sh);
|
||||||
|
}
|
||||||
|
start = sx;
|
||||||
|
}
|
||||||
|
if h > 0 && stack.last().map_or(true, |&(_, sh)| h > sh) {
|
||||||
|
stack.push((start, h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let x = bw;
|
||||||
|
while let Some((sx, sh)) = stack.pop() {
|
||||||
|
let area = sh * (x - sx);
|
||||||
|
if area > best_area {
|
||||||
|
best_area = area;
|
||||||
|
best = (bx0 + sx, by0 + ly as u32 - sh + 1, x - sx, sh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best_area == 0 { None } else { Some(best) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. 主编排 ----
|
||||||
|
|
||||||
|
fn slice_jump_hop_tile_uv_faces_blob(
|
||||||
|
source: &image::RgbaImage,
|
||||||
|
tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32,
|
||||||
|
atlas_row: u32, atlas_col: u32,
|
||||||
|
) -> Result<JumpHopTileFaceSlices, AppError> {
|
||||||
|
let fallback = || {
|
||||||
|
slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: BFS 主 blob → cleaned 图像
|
||||||
|
let cleaned = match build_cleaned_tile(source, tile_x, tile_y, tile_w, tile_h) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return fallback(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: gradient 边界检测
|
||||||
|
let (y0, y1, y2, y3) = match detect_row_boundaries(&cleaned, tile_w, tile_h) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return fallback(),
|
||||||
|
};
|
||||||
|
let (x0, x1, x2, x3) = match detect_col_boundaries(&cleaned, tile_w, tile_h, y0, y3) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return fallback(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块
|
||||||
|
// blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom
|
||||||
|
let blocks: [(u32,u32,u32,u32); 5] = [
|
||||||
|
(x1, y0, x2 - x1, y1 - y0), // Top
|
||||||
|
(x0, y1, x1 - x0, y2 - y1), // Left
|
||||||
|
(x1, y1, x2 - x1, y2 - y1), // Front
|
||||||
|
(x2, y1, x3 - x2, y2 - y1), // Right+Back
|
||||||
|
(x1, y2, x2 - x1, y3 - y2), // Bottom
|
||||||
|
];
|
||||||
|
|
||||||
|
// Step 4: max opaque rectangle per block
|
||||||
|
let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> {
|
||||||
|
max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("blob gradient: {name} 面无有效内容"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let top = rect(blocks[0], "Top")?;
|
||||||
|
let left = rect(blocks[1], "Left")?;
|
||||||
|
let front = rect(blocks[2], "Front")?;
|
||||||
|
// Right+Back → 从中点拆分
|
||||||
|
let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3];
|
||||||
|
let mid = rb_x0 + rb_w / 2;
|
||||||
|
let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?;
|
||||||
|
let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?;
|
||||||
|
let bottom = rect(blocks[4], "Bottom")?;
|
||||||
|
|
||||||
|
// Step 5: crop (tile_local → global)
|
||||||
|
let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3);
|
||||||
|
|
||||||
|
let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result<JumpHopTileFaceSlice, AppError> {
|
||||||
|
let (gx, gy, gw, gh) = global(r);
|
||||||
|
let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh);
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||||
|
"message": format!("跳一跳地板 UV 面贴图切割失败:{e}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let label = jump_hop_tile_face_key_label(&face);
|
||||||
|
Ok(JumpHopTileFaceSlice {
|
||||||
|
face,
|
||||||
|
source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, label),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(JumpHopTileFaceSlices {
|
||||||
|
top: mk(top, JumpHopTileFaceKey::Top)?,
|
||||||
|
left: mk(left, JumpHopTileFaceKey::Left)?,
|
||||||
|
front: mk(front, JumpHopTileFaceKey::Front)?,
|
||||||
|
right: mk(right_rect, JumpHopTileFaceKey::Right)?,
|
||||||
|
back: mk(back_rect, JumpHopTileFaceKey::Back)?,
|
||||||
|
bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -40,15 +40,13 @@ mod edutainment_baby_drawing;
|
|||||||
mod edutainment_baby_object;
|
mod edutainment_baby_object;
|
||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod external_api_audit;
|
mod external_api_audit;
|
||||||
mod external_generation;
|
|
||||||
mod external_generation_worker;
|
|
||||||
mod external_generation_worker_controller;
|
|
||||||
pub(crate) mod generated_asset_sheets;
|
pub(crate) mod generated_asset_sheets;
|
||||||
mod generated_image_assets;
|
mod generated_image_assets;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
mod hyper3d_generation;
|
mod hyper3d_generation;
|
||||||
mod jump_hop;
|
mod jump_hop;
|
||||||
|
mod jump_hop_atlas_slicing;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod llm_model_routing;
|
mod llm_model_routing;
|
||||||
mod login_options;
|
mod login_options;
|
||||||
@@ -92,7 +90,6 @@ mod tracking_outbox;
|
|||||||
mod vector_engine_audio_generation;
|
mod vector_engine_audio_generation;
|
||||||
mod visual_novel;
|
mod visual_novel;
|
||||||
mod volcengine_speech;
|
mod volcengine_speech;
|
||||||
mod wallet_refund_outbox;
|
|
||||||
mod wechat;
|
mod wechat;
|
||||||
mod wooden_fish;
|
mod wooden_fish;
|
||||||
mod work_author;
|
mod work_author;
|
||||||
@@ -117,11 +114,8 @@ use tracing::{error, info, warn};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::{build_router, build_spacetime_unavailable_router},
|
app::{build_router, build_spacetime_unavailable_router},
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
external_generation_worker::run_external_generation_worker,
|
|
||||||
external_generation_worker_controller::run_external_generation_worker_controller,
|
|
||||||
state::{AppState, AppStateInitError},
|
state::{AppState, AppStateInitError},
|
||||||
tracking_outbox::TrackingOutbox,
|
tracking_outbox::TrackingOutbox,
|
||||||
wallet_refund_outbox::WalletRefundOutbox,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
@@ -132,7 +126,6 @@ const AUTH_STORE_STARTUP_RETRY_INTERVAL: Duration = Duration::from_secs(5);
|
|||||||
struct ShutdownContext {
|
struct ShutdownContext {
|
||||||
app_state: Option<AppState>,
|
app_state: Option<AppState>,
|
||||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||||
wallet_refund_outbox: Option<Arc<WalletRefundOutbox>>,
|
|
||||||
outbox_flush_timeout: Duration,
|
outbox_flush_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,66 +165,27 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
|||||||
process_metrics::register_process_metrics();
|
process_metrics::register_process_metrics();
|
||||||
telemetry::register_http_runtime_metrics();
|
telemetry::register_http_runtime_metrics();
|
||||||
|
|
||||||
if !config.process_role.runs_http() {
|
|
||||||
return run_worker_only(config).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
run_http_role(config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
|
|
||||||
let process_role = config.process_role;
|
|
||||||
let state = restore_app_state_for_startup(config)
|
|
||||||
.await
|
|
||||||
.map_err(|error| {
|
|
||||||
io::Error::other(format!(
|
|
||||||
"初始化 external generation worker 状态失败:{error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
spawn_app_state_background_workers(&state);
|
|
||||||
info!(
|
|
||||||
process_role = process_role.as_str(),
|
|
||||||
"api-server 以非 HTTP 角色启动"
|
|
||||||
);
|
|
||||||
if process_role.runs_external_generation_worker() {
|
|
||||||
run_external_generation_worker(state).await
|
|
||||||
} else if process_role.runs_external_generation_controller() {
|
|
||||||
run_external_generation_worker_controller(state).await
|
|
||||||
} else {
|
|
||||||
Err(io::Error::other(format!(
|
|
||||||
"不支持的非 HTTP 进程角色:{}",
|
|
||||||
process_role.as_str()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
|
||||||
let bind_address = config.bind_socket_addr();
|
let bind_address = config.bind_socket_addr();
|
||||||
let listen_backlog = config.listen_backlog;
|
let listen_backlog = config.listen_backlog;
|
||||||
let worker_threads = config.worker_threads;
|
let worker_threads = config.worker_threads;
|
||||||
let otel_enabled = config.otel_enabled;
|
let otel_enabled = config.otel_enabled;
|
||||||
let process_role = config.process_role;
|
|
||||||
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
|
let outbox_flush_timeout = config.shutdown_outbox_flush_timeout;
|
||||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||||
|
|
||||||
let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await
|
let (router, shutdown_context) = match restore_app_state_for_startup(config).await {
|
||||||
{
|
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
spawn_app_state_background_workers(&state);
|
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||||
let tracking_outbox = state.tracking_outbox();
|
let tracking_outbox = state.tracking_outbox();
|
||||||
let wallet_refund_outbox = state.wallet_refund_outbox();
|
if let Some(outbox) = tracking_outbox.clone() {
|
||||||
let worker_state = process_role
|
outbox.spawn_worker();
|
||||||
.runs_external_generation_worker()
|
}
|
||||||
.then(|| state.clone());
|
|
||||||
(
|
(
|
||||||
build_router(state.clone()),
|
build_router(state.clone()),
|
||||||
ShutdownContext {
|
ShutdownContext {
|
||||||
app_state: Some(state),
|
app_state: Some(state),
|
||||||
tracking_outbox,
|
tracking_outbox,
|
||||||
wallet_refund_outbox,
|
|
||||||
outbox_flush_timeout,
|
outbox_flush_timeout,
|
||||||
},
|
},
|
||||||
worker_state,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Err(AppStateInitError::DependencyUnavailable(message)) => (
|
Err(AppStateInitError::DependencyUnavailable(message)) => (
|
||||||
@@ -239,10 +193,8 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
|||||||
ShutdownContext {
|
ShutdownContext {
|
||||||
app_state: None,
|
app_state: None,
|
||||||
tracking_outbox: None,
|
tracking_outbox: None,
|
||||||
wallet_refund_outbox: None,
|
|
||||||
outbox_flush_timeout,
|
outbox_flush_timeout,
|
||||||
},
|
},
|
||||||
None,
|
|
||||||
),
|
),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return Err(std::io::Error::other(format!(
|
return Err(std::io::Error::other(format!(
|
||||||
@@ -256,20 +208,12 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
|||||||
listen_backlog,
|
listen_backlog,
|
||||||
worker_threads = worker_threads.unwrap_or(0),
|
worker_threads = worker_threads.unwrap_or(0),
|
||||||
otel_enabled,
|
otel_enabled,
|
||||||
process_role = process_role.as_str(),
|
|
||||||
"api-server 已完成 tracing 初始化并开始监听"
|
"api-server 已完成 tracing 初始化并开始监听"
|
||||||
);
|
);
|
||||||
|
|
||||||
let http_server = axum::serve(listener, router)
|
let result = axum::serve(listener, router)
|
||||||
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()));
|
.with_graceful_shutdown(shutdown_signal(shutdown_context.clone()))
|
||||||
let result = if let Some(worker_state) = worker_state {
|
.await;
|
||||||
tokio::select! {
|
|
||||||
result = http_server => result,
|
|
||||||
result = run_external_generation_worker(worker_state) => result,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
http_server.await
|
|
||||||
};
|
|
||||||
finalize_shutdown(shutdown_context).await;
|
finalize_shutdown(shutdown_context).await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -328,8 +272,12 @@ async fn finalize_shutdown(context: ShutdownContext) {
|
|||||||
state.mark_not_ready();
|
state.mark_not_ready();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Some(outbox) = context.tracking_outbox else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
if context.outbox_flush_timeout.is_zero() {
|
if context.outbox_flush_timeout.is_zero() {
|
||||||
warn!("api-server 退出时 outbox flush timeout 为 0,跳过主动 flush");
|
warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,56 +285,23 @@ async fn finalize_shutdown(context: ShutdownContext) {
|
|||||||
.outbox_flush_timeout
|
.outbox_flush_timeout
|
||||||
.as_millis()
|
.as_millis()
|
||||||
.min(u128::from(u64::MAX)) as u64;
|
.min(u128::from(u64::MAX)) as u64;
|
||||||
if let Some(outbox) = context.tracking_outbox {
|
info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox");
|
||||||
info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox");
|
match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
|
||||||
match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
|
Ok(Ok(())) => {
|
||||||
Ok(Ok(())) => {
|
info!("api-server 退出前 tracking outbox flush 完成");
|
||||||
info!("api-server 退出前 tracking outbox flush 完成");
|
|
||||||
}
|
|
||||||
Ok(Err(error)) => {
|
|
||||||
warn!(
|
|
||||||
error = %error,
|
|
||||||
"api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
timeout_ms,
|
|
||||||
"api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Ok(Err(error)) => {
|
||||||
|
warn!(
|
||||||
if let Some(outbox) = context.wallet_refund_outbox {
|
error = %error,
|
||||||
info!(timeout_ms, "api-server 退出前 flush wallet refund outbox");
|
"api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试"
|
||||||
match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await {
|
);
|
||||||
Ok(Ok(())) => {
|
}
|
||||||
info!("api-server 退出前 wallet refund outbox flush 完成");
|
Err(_) => {
|
||||||
}
|
warn!(
|
||||||
Ok(Err(error)) => {
|
timeout_ms,
|
||||||
warn!(
|
"api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试"
|
||||||
error = %error,
|
);
|
||||||
"api-server 退出前 wallet refund outbox flush 未完成,已保留本地文件等待下次启动重试"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
warn!(
|
|
||||||
timeout_ms,
|
|
||||||
"api-server 退出前 wallet refund outbox flush 超时,已保留本地文件等待下次启动重试"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_app_state_background_workers(state: &AppState) {
|
|
||||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
|
||||||
if let Some(outbox) = state.tracking_outbox() {
|
|
||||||
outbox.spawn_worker();
|
|
||||||
}
|
|
||||||
if let Some(outbox) = state.wallet_refund_outbox() {
|
|
||||||
outbox.spawn_worker();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
convert::Infallible,
|
convert::Infallible,
|
||||||
future::Future,
|
future::Future,
|
||||||
@@ -65,8 +65,11 @@ use spacetime_client::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body,
|
api_response::json_success_body,
|
||||||
asset_billing::{execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error},
|
asset_billing::{
|
||||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
execute_billable_asset_operation_with_cost, map_asset_operation_wallet_error,
|
||||||
|
should_skip_asset_operation_billing_for_connectivity,
|
||||||
|
},
|
||||||
|
auth::AuthenticatedAccessToken,
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
@@ -351,6 +354,13 @@ impl Match3DItemAssetsGenerationPlan {
|
|||||||
Self::Replace(plan) => plan.requested_item_names.len(),
|
Self::Replace(plan) => plan.requested_item_names.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn billing_fingerprint_source(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Append(plan) => format!("append:{}", plan.requested_item_names.join("|")),
|
||||||
|
Self::Replace(plan) => format!("replace:{}", plan.requested_item_names.join("|")),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||||
|
|||||||
@@ -162,12 +162,7 @@ pub(super) async fn compile_match3d_draft_for_session(
|
|||||||
let initial_tags = requested_tags
|
let initial_tags = requested_tags
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||||
let billing_asset_id = format!(
|
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||||
"{}:{}:{}",
|
|
||||||
session_id,
|
|
||||||
profile_id,
|
|
||||||
request_context.request_id()
|
|
||||||
);
|
|
||||||
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
let points_cost = crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||||
state,
|
state,
|
||||||
"match3d",
|
"match3d",
|
||||||
@@ -519,6 +514,15 @@ async fn consume_match3d_draft_generation_points(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
|
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
owner_user_id,
|
||||||
|
billing_asset_id,
|
||||||
|
error = %error,
|
||||||
|
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
Err(error) => Err(match3d_error_response(
|
Err(error) => Err(match3d_error_response(
|
||||||
request_context,
|
request_context,
|
||||||
MATCH3D_AGENT_PROVIDER,
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user