diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index ad00a2d8..68132d82 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -1,229 +1,151 @@ --- name: spacetimedb-cli -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 +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. --- # SpacetimeDB CLI -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. +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. -## Quick Reference +## Genarrative Rules -### Project Initialization & Development +- Do not rely on the default SpacetimeDB cloud target. Pass `--server` or `--server-url` explicitly in scripts, docs, smoke tests, and manual troubleshooting. +- 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 -# Initialize new project -spacetime init my-project --lang rust|csharp|typescript|cpp -spacetime init my-project --template - # Build module -spacetime build # release build -spacetime build --debug # faster iteration, slower runtime +spacetime build +spacetime build --debug -# Dev mode (auto-rebuild, auto-publish, generates bindings) -spacetime dev -spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings +# Publish to an explicit server +spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients -# Generate client bindings +# Destructive publish only when explicitly intended +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 ``` -### Publishing & Deployment +## Genarrative Local Workflow ```bash -# Publish to an explicit server -spacetime publish my-database --server http://127.0.0.1:3101 --yes +# Prefer project wrappers +npm run dev:spacetime +npm run dev:api-server +npm run spacetime:generate -# Publish to local server -spacetime publish my-database --server local --yes +# Query local database +spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players" -# Clear database and republish -spacetime publish my-database --clear-database --yes +# Logs +spacetime logs my-db --server http://127.0.0.1:3101 -f ``` -### Database Interaction +## Database Interaction ```bash -# SQL queries -spacetime sql my-database "SELECT * FROM users" -spacetime sql my-database --interactive # REPL mode +# SQL / describe +spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM users" +spacetime describe my-db --server http://127.0.0.1:3101 --json +spacetime describe my-db table users --server http://127.0.0.1:3101 --json -# Call reducers -spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}' +# Reducer/procedure calls. Arguments are positional JSON values. +spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123' -# Subscribe to changes -spacetime subscribe my-database "SELECT * FROM users" --num-updates 10 +# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax. +spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123... -# View logs -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 +# Subscribe from CLI +spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101 ``` -### Database Management +## Server & Auth ```bash -# List databases -spacetime list - -# Delete database -spacetime delete my-database - -# Rename database -spacetime rename --to new-name -``` - -### Server Management - -```bash -# List configured servers spacetime server list - -# Add server spacetime server add local --url http://localhost:3000 --default -spacetime server add myserver --url https://my-spacetime.example.com +spacetime server add genarrative-dev --url http://127.0.0.1:3101 +spacetime server ping genarrative-dev -# 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 - -# Login with token spacetime login --token - -# Show login status spacetime login show - -# Logout spacetime logout ``` -## 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 +## Version & Runtime Verification ```bash -# 1. Login -spacetime login +# CLI resolution can be misleading; compare all candidates when diagnosing. +type -a spacetime +spacetime --version +spacetime version list -# 2. Create project -spacetime init my-game --lang rust -cd my-game - -# 3. Start dev mode (auto-rebuilds and publishes) -spacetime dev +# Verify a systemd service binary actually changed. +pid="$(systemctl show spacetimedb.service -p MainPID --value)" +readlink -f "/proc/${pid}/exe" +"/proc/${pid}/exe" --version +curl -fsS http://127.0.0.1:3101/v1/ping ``` -### Local Development +## Flags -```bash -# Start local server (in separate terminal) -spacetime start - -# Publish to local -spacetime publish my-db --server local --clear-database --yes - -# Query local database -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 | +| Flag | Description | +|------|-------------| +| `--server`, `-s` | Target server nickname, host, or URL | +| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values | +| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` | +| `--module-path`, `-p` | Module project path | +| `--bin-path`, `-b` | Publish/generate from compiled wasm | +| `--no-config` | Ignore `spacetime.json` | +| `--env` | Select config file layering environment | ## Troubleshooting -### "Not logged in" +### Not Logged In + ```bash spacetime login -# Or use --anonymous for public operations ``` -### "Server not responding" +### Server Not Responding + ```bash spacetime server ping -# For local: ensure spacetime start is running +curl -fsS http://127.0.0.1:3101/v1/ping ``` -### "Schema conflict" +For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`. + +### Schema Conflict + ```bash -# 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 +spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate ``` -### "Build failed" +Use `--delete-data=always` only with explicit approval. + +### Version Mismatch + ```bash -# Check Rust/C# toolchain -rustup show -# For Rust modules, ensure wasm32-unknown-unknown target -rustup target add wasm32-unknown-unknown +rg -n 'spacetimedb' server-rs/Cargo.toml +spacetime --version +spacetime version list +pid="$(systemctl show spacetimedb.service -p MainPID --value)" +"/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 -- Many commands are marked UNSTABLE and may change -- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default -- Use `--yes` flag in scripts to avoid interactive prompts -- Dev mode watches files and auto-rebuilds on changes +- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes. +- 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 CLI defaults. diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md index 42cfcc6d..abb665f9 100644 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ b/.codex/skills/spacetimedb-concepts/SKILL.md @@ -1,345 +1,105 @@ --- name: spacetimedb-concepts -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" +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. --- # SpacetimeDB Core Concepts -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. +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. ---- +## Genarrative Boundaries -## Critical Rules (Read First) +- Domain rules live in `module-*`. +- 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. -These five rules prevent the most common SpacetimeDB mistakes: +## Critical Rules -1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data. -2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables. -3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries. -4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns. -5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization. - ---- - -## Feature Implementation Checklist - -When implementing a feature that spans backend and client: - -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) - -**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. - ---- - -## Debugging Checklist - -When things are not working: - -1. Is SpacetimeDB server running? (`spacetime start`) -2. Is the module published? (`spacetime publish`) -3. Are client bindings generated? (`spacetime generate`) -4. Check server logs for errors (`spacetime logs `) -5. **Is the reducer actually being called from the client?** - ---- - -## CLI Commands - -```bash -spacetime start -spacetime publish --module-path -spacetime publish --clear-database -y --module-path -spacetime generate --lang --out-dir --module-path -spacetime logs -``` - ---- - -## 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. +1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints. +2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables. +3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`. +4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument. +5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering. +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 -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. +- Private tables are the default; only reducers/procedures and database owners can access them. +- 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 -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. +Reducers are deterministic transactional functions. They are the primary client-invoked mutation path. -### Key Properties +- No global mutable state. +- 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. -- **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 +## Procedures -### Critical Reducer Rules +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`). -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 +Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure. -### Defining Reducers +Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes. -**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(()) -} -``` +## Views -**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 }); -} -``` +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. -### ReducerContext +## Event Tables -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 broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`. -## Event Tables (2.0) +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 tables are the preferred way to broadcast reducer-specific data to clients. +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. -```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()`. +Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables. ## Subscriptions -Subscriptions replicate database rows to clients in real-time. +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. -### How Subscriptions Work +Best practices: -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`) +- 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. -### Subscription Best Practices +## 2.2.0 to 2.5.0 Delta -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 +Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: -## Modules +- **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. -Modules are WebAssembly bundles containing application logic that runs inside the database. +## Debugging Checklist -### 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); -} -``` - ---- +1. Is the Genarrative SpacetimeDB server running? Use `npm run dev:spacetime` locally or host-local `systemctl`. +2. Is the module published to the same server the API uses? +3. Are generated bindings current? Use `npm run spacetime:generate`. +4. Is `api-server` using the same database and token? +5. Is the reducer/procedure actually called? +6. Did `/healthz` / `/readyz` pass while business SpacetimeDB calls still timeout? Inspect API logs and public route behavior. ## Editing Behavior -When modifying SpacetimeDB code: - -- Make the smallest change necessary -- Do NOT touch unrelated files, configs, or dependencies -- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Make the smallest change necessary. +- 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. +- After schema edits, run `npm run spacetime:generate` and `npm run check:spacetime-schema`. diff --git a/.codex/skills/spacetimedb-csharp/SKILL.md b/.codex/skills/spacetimedb-csharp/SKILL.md deleted file mode 100644 index 2dc4ce5d..00000000 --- a/.codex/skills/spacetimedb-csharp/SKILL.md +++ /dev/null @@ -1,646 +0,0 @@ ---- -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 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) - // 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! -``` - -### 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 -_conn.Reducers.ProcessItems(new List { 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` -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` 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 // 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 - - - net8.0 - wasi-wasm - Exe - enable - enable - - - - - -``` - -### 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.SubscribeApplied: - break; // Initial subscription data - case Event.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-path -spacetime publish --clear-database -y --module-path -spacetime generate --lang csharp --out-dir /SpacetimeDB --module-path -spacetime logs -``` diff --git a/.codex/skills/spacetimedb-rust/SKILL.md b/.codex/skills/spacetimedb-rust/SKILL.md index 346247b5..889226eb 100644 --- a/.codex/skills/spacetimedb-rust/SKILL.md +++ b/.codex/skills/spacetimedb-rust/SKILL.md @@ -1,312 +1,170 @@ --- name: spacetimedb-rust -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" +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. --- # SpacetimeDB Rust Module Development -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. +Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work. -> **Tested with:** SpacetimeDB 2.0+ APIs +## Genarrative Rules ---- +- 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 - -**These APIs/patterns are incorrect. LLMs frequently hallucinate them.** - -Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`. +## Hallucinated APIs: Do Not Use ```rust -#[derive(Table)] // Tables use #[table] attribute, not derive -#[derive(Reducer)] // Reducers use #[reducer] attribute +#[derive(Table)] // Tables use #[table], not derive +#[derive(Reducer)] // Reducers use #[reducer], not derive +#[derive(SpacetimeType)] // Do not derive this on #[table] structs -// WRONG — SpacetimeType on tables -#[derive(SpacetimeType)] // DO NOT use on #[table] structs! -#[table(accessor = my_table)] -pub struct MyTable { ... } +pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext -// WRONG — mutable context -pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext +ctx.db.player // Use ctx.db.player() +ctx.db.player.find(id) // Use ctx.db.player().id().find(&id) +ctx.sender // Use ctx.sender() +ctx.db.user().name().update(..) // Update by primary key only -// 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) +spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5 ``` -### CORRECT PATTERNS: +## Required Patterns ```rust -use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; -use spacetimedb::SpacetimeType; // Only for custom types, NOT tables +use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp}; +use spacetimedb::SpacetimeType; // Custom types only, not tables -// CORRECT TABLE — accessor, not name; no SpacetimeType derive! #[table(accessor = player, public)] -pub struct Player { - #[primary_key] - pub id: u64, - pub name: String, -} - -// CORRECT REDUCER — immutable context, sender() is a method -#[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, + pub id: u64, + pub owner: Identity, + pub name: String, + pub created_at: Timestamp, } -``` -### 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`, `Option`, `Result` - -**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]` - ---- - -## Reducers - -```rust -#[spacetimedb::reducer] +#[reducer] pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> { - if name.is_empty() { - return Err("Name cannot be empty".to_string()); + if name.trim().is_empty() { + return Err("name required".to_string()); } - ctx.db.player().insert(Player { id: 0, name, score: 0 }); + ctx.db.player().try_insert(Player { + id: 0, + owner: ctx.sender(), + name, + created_at: ctx.timestamp, + })?; Ok(()) } ``` -### Reducer Rules +Hard requirements: -1. First parameter must be `&ReducerContext` -2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display` -3. All changes roll back on panic or `Err` return -4. Must import `Table` trait: `use spacetimedb::Table;` +- Import `Table` for table operations. +- Use `accessor = identifier`, not string literals. +- Use `ctx.sender()` for authorization. +- Use `ctx.rng()` / `ctx.random()` / `ctx.new_uuid_*()` for deterministic randomness and UUIDs. +- 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. -### ReducerContext +## Tables ```rust -ctx.db // Database access -ctx.sender() // Identity of the caller (method, not field!) -ctx.connection_id() // Option (None for scheduled/system reducers) -ctx.timestamp // Invocation timestamp -ctx.identity() // Module's own identity -ctx.rng() // Deterministic RNG (method, not field!) +#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))] +pub struct GameTickSchedule { + #[primary_key] + #[auto_inc] + pub scheduled_id: u64, + pub scheduled_at: ScheduleAt, +} ``` ---- +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 -### Insert - ```rust -// Insert returns the row with auto_inc values populated -let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 }); -log::info!("Created player with id: {}", player.id); +let row = ctx.db.player().insert(Player { id: 0, owner, name, created_at }); +ctx.db.player().try_insert(row)?; + +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); ``` -### 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 = ctx.db.player().name().filter(&"Alice".to_string()) - .map(|p| p.id) - .collect(); -for id in to_remove { - ctx.db.player().id().delete(&id); -} -``` - ---- +For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation. ## Indexes ```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( - accessor = score, public, + accessor = score, + public, index(accessor = by_player_level, btree(columns = [player_id, level])) )] pub struct Score { - player_id: u32, - level: u32, - points: i64, + pub player_id: u32, + pub level: u32, + pub points: i64, } -// Multi-column index querying: prefix match (first column only) -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); -} +for row in ctx.db.score().by_player_level().filter(&(42,)) {} +for row in ctx.db.score().by_player_level().filter(&(42, 5)) {} ``` ---- - -## Event Tables (2.0) - -Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead. +## Event Tables ```rust #[table(accessor = damage_event, public, event)] @@ -321,182 +179,65 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { } ``` -Client subscribes and uses `on_insert`: +Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`. + +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 -conn.db.damage_event().on_insert(|ctx, event| { - play_damage_animation(event.target, event.amount); -}); +#[spacetimedb::view(accessor = my_players, public, primary_key = id)] +pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec { + ctx.db.player().owner().filter(&ctx.sender()).collect() +} ``` -Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`. +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. ---- - -## Lifecycle Reducers +## Lifecycle & Scheduled Reducers ```rust #[spacetimedb::reducer(init)] -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(()) -} +pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } #[spacetimedb::reducer(client_connected)] -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(()) -} +pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) } #[spacetimedb::reducer(client_disconnected)] -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(()) -} -``` +pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { 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 { scheduled_id: 0, - scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()), + scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()), }); -// Schedule at specific time -let run_at = ctx.timestamp + Duration::from_secs(delay_secs); -ctx.db.reminder_schedule().insert(ReminderSchedule { +let run_at = ctx.timestamp + std::time::Duration::from_secs(60); +ctx.db.game_tick_schedule().insert(GameTickSchedule { scheduled_id: 0, scheduled_at: ScheduleAt::Time(run_at), }); ``` ---- +For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer should only be system-triggered. -## Identity and Authentication +## Procedures -```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"] }` +Procedures are stable in 2.5 and no longer require the `unstable` feature. ```rust use spacetimedb::{procedure, ProcedureContext}; #[procedure] fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { - let data = fetch_from_url(&url)?; + let body = ctx.http.get(url).send()?.text()?; ctx.try_with_tx(|tx| { - tx.db.external_data().insert(ExternalData { id: 0, content: data }); + tx.db.external_data().insert(ExternalData { id: 0, content: body }); Ok(()) })?; Ok(()) @@ -505,52 +246,35 @@ fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), Str | Reducers | Procedures | |----------|------------| -| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | -| Direct `ctx.db` access | Must use `ctx.with_tx()` | -| No HTTP/network | HTTP allowed | -| No return values | Can return data | +| `&ReducerContext` | `&mut ProcedureContext` | +| Direct `ctx.db` access | Use `with_tx()` / `try_with_tx()` | +| No HTTP/network | Outgoing HTTP via `ctx.http` | +| Deterministic transaction path | Side-effect-capable workflow path | ---- +In Genarrative, keep external provider protocols in `platform-*` by default unless the architecture explicitly moves that workflow into the module. -## Custom Types +## Identity & Auth ```rust -use spacetimedb::SpacetimeType; - -#[derive(SpacetimeType)] -pub enum PlayerStatus { Active, Idle, Away } - -#[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, +fn require_owner(ctx: &ReducerContext, owner: &Identity) -> Result<(), String> { + if ctx.sender() != *owner { + return Err("Not authorized".to_string()); + } + Ok(()) } ``` ---- +`ReducerContext::identity` is deprecated since 2.3; use the current database/module identity API when needed, and use `ctx.sender()` for caller identity. ## Commands ```bash spacetime build -spacetime publish my_database --module-path . -spacetime publish my_database --clear-database --module-path . -spacetime logs my_database -spacetime call my_database create_player "Alice" -spacetime sql my_database "SELECT * FROM player" -spacetime generate --lang rust --out-dir /src/module_bindings --module-path +spacetime publish my_database --server http://127.0.0.1:3101 --module-path . --yes=migrate +spacetime publish my_database --server http://127.0.0.1:3101 --delete-data=on-conflict --module-path . --yes=migrate +spacetime logs my_database --server http://127.0.0.1:3101 +spacetime call --server http://127.0.0.1:3101 my_database create_player '"Alice"' +spacetime sql my_database --server http://127.0.0.1:3101 "SELECT * FROM player" +npm run spacetime:generate +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 diff --git a/.codex/skills/spacetimedb-typescript/SKILL.md b/.codex/skills/spacetimedb-typescript/SKILL.md deleted file mode 100644 index 3e2f38f5..00000000 --- a/.codex/skills/spacetimedb-typescript/SKILL.md +++ /dev/null @@ -1,489 +0,0 @@ ---- -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 | -| `` | `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 ( - - - - ); -} - -function PlayerList() { - const [players, isReady] = useTable(tables.player); - if (!isReady) return
Loading...
; - return
    {players.map(p =>
  • {p.name}
  • )}
; -} -``` - ---- - -## 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-path -spacetime publish --clear-database -y --module-path -spacetime generate --lang typescript --out-dir /src/module_bindings --module-path -spacetime logs -``` diff --git a/.codex/skills/spacetimedb-unity/SKILL.md b/.codex/skills/spacetimedb-unity/SKILL.md deleted file mode 100644 index 56260f9f..00000000 --- a/.codex/skills/spacetimedb-unity/SKILL.md +++ /dev/null @@ -1,292 +0,0 @@ ---- -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-path -spacetime publish --clear-database -y --module-path -spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path -spacetime logs -``` diff --git a/.gitignore b/.gitignore index 9a953a92..e61911d5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,8 +37,10 @@ temp*build*/ /.app/ /target/ /logs +/.codegraph/ /server-rs/crates/*/logs/ .worktrees/ +.rag/ .env.secrets.local spacetime.local.json deploy/container/api-server.env diff --git a/.hermes/README.md b/.hermes/README.md index d502790b..e8b760a1 100644 --- a/.hermes/README.md +++ b/.hermes/README.md @@ -1,30 +1,21 @@ -# Genarrative 团队 Hermes 共享记忆 +# Genarrative Hermes 工具目录 -本目录用于在仓库内共享团队级 Hermes 上下文,供 3 名开发人员在各自本地 Hermes 中读取、更新和同步。 +本目录只保留 Hermes 专用的仓库级工具资源,例如 Hermes skills、plugins 和启用说明。项目知识本体、长期记忆、计划和 TODO 不再放在 `.hermes/`,统一迁移到 `docs/project-memory/`。 ## 使用原则 -- `.hermes/` 中只保存可以进入 Git 的团队共享内容。 +- `.hermes/` 中只保存 Hermes 工具运行或加载所需内容。 +- 项目长期知识、架构约定、排障经验、协作规则、计划和 TODO 统一放在 `docs/project-memory/`。 - 不提交个人配置、API Key、会话转录、模型密钥、本地路径密钥等敏感内容。 - 个人 Hermes 的 `~/.hermes/config.yaml`、`~/.hermes/.env`、`~/.hermes/sessions/` 不应复制到本仓库。 -- 开发前先阅读本目录下与任务相关的记忆文件;开发后如产生稳定知识,更新对应文档。 - 后续新增的 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`,便于团队跨目录检索。 -- 若本目录内容与 `docs/` 或代码事实冲突,以当前代码和最新 `docs/` 为准,并同步修正过期记忆。 -- 阶段性计划、一次性 TODO 和已关闭实验不再长期沉淀为仓库文档;仍有效内容应合并进 `docs/` 当前融合文档或 `.hermes/shared-memory/`。 +- 若 `.hermes/` 中的工具说明与代码或 `docs/` 冲突,以当前代码和最新 `docs/` 为准。 ## 目录结构 ```text .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 # 任务交接模板 +├─ README.md # Hermes 工具目录说明 ├─ skills/ # 仓库级 Hermes skills └─ plugins/ # 仓库级 Hermes plugins(需显式启用项目 plugin) ``` @@ -71,22 +62,5 @@ HERMES_ENABLE_PROJECT_PLUGINS=1 HERMES_PLUGINS_DEBUG=1 hermes chat -q "请读取 在本仓库中开始复杂任务时,可以先对 Hermes 说: ```text -请先读取 AGENTS.md 以及 .hermes/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 .hermes/shared-memory/ 对应文件。 +请先读取 AGENTS.md 以及 docs/project-memory/shared-memory/ 下与本任务相关的团队共享记忆,再开始分析。若任务完成后产生稳定项目知识,请更新 docs/project-memory/shared-memory/ 对应文件。 ``` - -## 需要沉淀到这里的内容 - -- 长期有效的架构约定 -- 反复会用到的本地开发/测试流程 -- 已确认的接口契约或模块边界 -- 重要技术决策及原因 -- 踩坑、排障方式、验证命令 -- 团队协作规则和任务交接规范 - -## 不应沉淀到这里的内容 - -- API Key、Token、Cookie、私有密钥 -- 个人账号、个人本地绝对路径、个人隐私信息 -- 大段临时聊天记录 -- 尚未确认的一次性猜测 -- 构建产物、日志、缓存、数据库 dump diff --git a/.hermes/skills/behavior-driven-development/SKILL.md b/.hermes/skills/behavior-driven-development/SKILL.md index 51548c90..5b3e4e67 100644 --- a/.hermes/skills/behavior-driven-development/SKILL.md +++ b/.hermes/skills/behavior-driven-development/SKILL.md @@ -284,7 +284,7 @@ fn anonymous_user_cannot_publish_generated_draft() { | 正式产品验收 / PRD 场景 | 当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】<功能名>BDD场景-YYYY-MM-DD.md` | 产品、测试、开发都需要长期参考的验收标准、用户故事、功能边界。 | | 技术/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 目录。 | -| 稳定流程或团队经验 | `.hermes/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 | +| 稳定流程或团队经验 | `docs/project-memory/shared-memory/` 或 `.hermes/skills/` | 不是某个功能验收,而是长期可复用的团队流程、坑点、执行规范。 | 默认规则: @@ -311,7 +311,7 @@ e2e/features/invite-code.feature - 实施计划:当前任务上下文或 `.tmp/.md` - 产品/验收文档:当前 `docs/` 融合文档,必要时新增 `docs/【产品验收】中文标题-YYYY-MM-DD.md` - 技术设计:当前 `docs/` 融合文档,必要时新增 `docs/【技术方案】中文标题-YYYY-MM-DD.md` -- 共享经验或稳定流程:`.hermes/shared-memory/` 或 `.hermes/skills/` +- 共享经验或稳定流程:`docs/project-memory/shared-memory/` 或 `.hermes/skills/` BDD 文档建议包含: @@ -389,4 +389,4 @@ npm run test -- --run <相关测试文件> ``` - [ ] 若涉及后端 Rust/API,按相关 DDD/SpacetimeDB 文档运行对应 cargo/npm/API smoke 验证。 -- [ ] 若产生长期有效经验,已同步到 `.hermes/shared-memory/` 或合适的仓库级 skill。 +- [ ] 若产生长期有效经验,已同步到 `docs/project-memory/shared-memory/` 或合适的仓库级 skill。 diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index f2262353..18c2ebe0 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -21,7 +21,7 @@ metadata: - 处理 `3000`、`3101`、`3102`、`8082` 等端口被占用导致本地开发栈启动失败。 - 排查 Vite 代理仍指向旧 api-server 端口、前端打开了旧 dev server、后台代理错配。 - 调整 SpacetimeDB standalone、publish、Rust `api-server`、主站 Vite、后台 Vite 的启动顺序。 -- 修改本地联调文档或 `.hermes/shared-memory/pitfalls.md` 中的 dev 启动口径。 +- 修改本地联调文档或 `docs/project-memory/shared-memory/pitfalls.md` 中的 dev 启动口径。 ## 当前端口职责 @@ -75,7 +75,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` - `scripts/dev.mjs` - `scripts/dev-utils.mjs` - `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md` - - `.hermes/shared-memory/pitfalls.md` + - `docs/project-memory/shared-memory/pitfalls.md` 2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。 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。 @@ -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:web` 在主站端口不可用时能切换到可用端口。 - [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 -- [ ] 长期踩坑同步更新 `.hermes/shared-memory/pitfalls.md`。 +- [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。 - [ ] 修改中文文件后运行 `npm run check:encoding`。 diff --git a/.hermes/skills/genarrative-play-type-integration/SKILL.md b/.hermes/skills/genarrative-play-type-integration/SKILL.md index 61256e7b..f34f1558 100644 --- a/.hermes/skills/genarrative-play-type-integration/SKILL.md +++ b/.hermes/skills/genarrative-play-type-integration/SKILL.md @@ -47,13 +47,13 @@ description: 在 Genarrative 新增、开放或重构玩法创作工具时,按 先读: - `AGENTS.md` -- `.hermes/shared-memory/` +- `docs/project-memory/shared-memory/` - `CONTEXT.md` - `docs/README.md` - `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 相关玩法 PRD 或设计文档 -如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `.hermes/shared-memory/`。 +如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `docs/project-memory/shared-memory/`。 ### 2. 定玩法边界 diff --git a/.hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md b/.hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md index 60c9d06b..68dcbf1d 100644 --- a/.hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md +++ b/.hermes/skills/genarrative-play-type-integration/references/genarrative-analytics-tracking-runtime.md @@ -28,7 +28,7 @@ 11. 接 `api-server`: - `src/runtime_profile.rs`:Query params / parser / handler / response builder。 - `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint;选择路径前确认产品定位。 -12. 最后更新当前 `docs/` 文档和必要的 `.hermes/shared-memory/` 摘要,并确认 diff 不只是生成物。 +12. 最后更新当前 `docs/` 文档和必要的 `docs/project-memory/shared-memory/` 摘要,并确认 diff 不只是生成物。 ## 验证命令示例 diff --git a/AGENTS.md b/AGENTS.md index 49289da2..65362603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,24 @@ # AGENTS.md -## 团队 Hermes 共享记忆 -- 本仓库的团队级 Hermes 共享内容位于 [`.hermes/`](.hermes/),用于在 3 名开发人员各自本地 Hermes 之间同步长期项目记忆。 +## 项目共享记忆 +- 本仓库的团队级项目记忆位于 [`docs/project-memory/`](docs/project-memory/),用于在 3 名开发人员和各自本地 Agent 之间同步长期项目知识。 +- [`.hermes/`](.hermes/) 只保存 Hermes 专用的仓库级工具资源,例如 skills、plugins 和启用说明,不作为项目知识库。 - 开始复杂开发任务前,除阅读本文件外,还应优先读取: - [`.hermes/README.md`](.hermes/README.md) - - [`.hermes/shared-memory/project-overview.md`](.hermes/shared-memory/project-overview.md) - - [`.hermes/shared-memory/team-conventions.md`](.hermes/shared-memory/team-conventions.md) - - [`.hermes/shared-memory/development-workflow.md`](.hermes/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) -- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `.hermes/shared-memory/` 中对应文件。 -- 仓库 `.hermes/` 只保存可进入 Git 的团队共享内容;禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 -- 若 `.hermes/shared-memory/` 与当前代码或 `docs/` 最新文档冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。 + - [`docs/project-memory/README.md`](docs/project-memory/README.md) + - [`docs/project-memory/shared-memory/project-overview.md`](docs/project-memory/shared-memory/project-overview.md) + - [`docs/project-memory/shared-memory/team-conventions.md`](docs/project-memory/shared-memory/team-conventions.md) + - [`docs/project-memory/shared-memory/development-workflow.md`](docs/project-memory/shared-memory/development-workflow.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) +- 如果本次任务产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则,应同步更新 `docs/project-memory/shared-memory/` 中对应文件。 +- 禁止提交个人 `~/.hermes` 配置、`.env`、API Key、Token、会话记录、认证文件和本地私密路径。 +- 若 `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 @@ -67,11 +75,10 @@ Single-context layout: read root `CONTEXT.md` when present. Current architecture - [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md) - [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md) - [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md) - - [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md) - 涉及 `spacetime` CLI、发布、绑定生成、本地联调时,按 `spacetimedb-cli` 执行。 - 涉及 `npm run dev` / `npm run dev:rust` / `npm run dev:web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。 - 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。 -- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。 +- 涉及前端或 Node 侧的 SpacetimeDB 订阅、绑定使用时,按当前生成绑定、项目代码和官方文档核对;本仓库不再维护单独 TypeScript / C# / Unity SpacetimeDB skill。 - 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。 - 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。 - 数据库表结构更改后,需要对齐migration.rs diff --git a/docs/README.md b/docs/README.md index 45aec037..51a5a57f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -89,7 +89,7 @@ RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/compon 平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、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/PlatformModalCloseButton.tsx`,底层继续复用 `UnifiedModal`;普通提示、确认 / 取消、危险确认、复制状态机、短代码复制 chip、复制按钮表现、白底 / 个人中心 / 认证入口 token 状态条、运行态状态 toast、无操作空态、主动作按钮、白底子面板、白底交互列表卡片、普通输入字段、字段标题、图片源 / fallback / 固定比例媒体预览、单个状态 / 标签 chip 和圆形关闭按钮优先使用公共 Module,规则见 [【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md](./technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.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)。 diff --git a/docs/planning/README.md b/docs/planning/README.md index 81a119cc..489f4e02 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -9,5 +9,5 @@ ## 维护规则 - 计划文档只记录可执行阶段、负责人切分、验收门禁和当前状态。 -- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `.hermes/shared-memory/`。 +- 已经稳定为长期约定的内容,应同步沉淀到 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 或 `docs/project-memory/shared-memory/`。 - 若代码事实与计划冲突,以代码和当前融合文档为准,并回写更新本目录。 diff --git a/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md b/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md index 58a8132b..a30c95c7 100644 --- a/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md +++ b/docs/planning/【玩法创作】创作流程统一总计划-2026-05-30.md @@ -27,7 +27,7 @@ | 阶段 | 状态 | 说明 | | --- | --- | --- | -| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md`、`docs/README.md` 和 `.hermes/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 | +| Phase 0 总计划与门禁 | 已完成 | 本文档、`docs/planning/README.md`、`docs/README.md` 和 `docs/project-memory/shared-memory/document-map.md` 已补齐入口;后续按 phase 扩展门禁。 | | Phase 1 首批统一壳 | 已收口 | `puzzle`、`match3d`、`jump-hop`、`wooden-fish` 已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,竖屏滚动和字段契约已回归。 | | Phase 1 补充统一壳 | 已收口 | `jump-hop` 也已接入 `UnifiedCreationPage` / `UnifiedGenerationPage`,统一创作页现在接管拼图、抓大鹅、跳一跳和敲木鱼四条入口的可见外壳与滚动。 | | Phase 2 契约与配置治理 | 已完成 | `creationTypes[].unifiedCreationSpec`、前端 fallback、后台配置校验和文档门禁已按现有测试与 schema 检查收口。 | @@ -60,7 +60,7 @@ 状态:已完成。 -- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md`、`.hermes/shared-memory/document-map.md` 中补上规划入口。 +- 新增本计划文档和 `docs/planning/README.md`,并在 `docs/README.md`、`docs/project-memory/shared-memory/document-map.md` 中补上规划入口。 - 补齐 `当前进度`、`执行轮次` 和可并行任务表,后续每个 phase 完成后更新本文档的状态、验收命令和风险。 退出条件: diff --git a/docs/project-memory/README.md b/docs/project-memory/README.md new file mode 100644 index 00000000..f95953f8 --- /dev/null +++ b/docs/project-memory/README.md @@ -0,0 +1,32 @@ +# 项目记忆目录 + +本目录保存可以进入 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。 diff --git a/.hermes/plans/架构优化计划书.md b/docs/project-memory/plans/架构优化计划书.md similarity index 100% rename from .hermes/plans/架构优化计划书.md rename to docs/project-memory/plans/架构优化计划书.md diff --git a/.hermes/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md similarity index 98% rename from .hermes/shared-memory/decision-log.md rename to docs/project-memory/shared-memory/decision-log.md index 5ccebe0e..c8b22699 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,30 @@ --- +## 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-13 `/editor/agent` AI Web 工程编辑器采用静态沙箱预览 MVP - 背景:`/editor/agent` 需要承载浏览器内类似 IDE 的 AI Web 工程编辑和实时预览能力,但 AI 生成工程的构建和运行不能进入 Genarrative 主站 JS 上下文、当前仓库源码目录或 api-server 进程。 @@ -986,7 +1010,7 @@ - 决策:平台壳在生成失败时必须同时标记草稿 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`、玩法链路文档和失败态交互测试。 - 验证方式:`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`、`.hermes/shared-memory/pitfalls.md`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 ## 2026-05-23 所有玩法生成页统一圆环主视觉 @@ -1139,7 +1163,7 @@ - 追加决策:`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 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。 -- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 ## 2026-05-19 tracking outbox 改为 rotate 后异步 flush @@ -1241,7 +1265,7 @@ - 决策:发布正式世界时,`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 投影。 - 验证方式:`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`、`.hermes/shared-memory/pitfalls.md`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/project-memory/shared-memory/pitfalls.md`。 ## 2026-05-19 系列素材 n\*n 图集抽为 api-server 通用模块 @@ -1589,7 +1613,7 @@ - 背景:项目已经全面移除 Maincloud 运行口径,但历史脚本、测试名和文档仍可能让后续开发误用 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`。 - 决策:`maincloud` / `Maincloud` / `MAINCLOUD` 相关代码、脚本、测试、环境变量、命令和文档要求全部视为历史残留,后续禁止新增、运行或引用;后端 API smoke 统一使用 `npm run dev:api-server` 并检查 `/healthz`。 -- 影响范围:`AGENTS.md`、`docs/technical/`、`.hermes/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。 +- 影响范围:`AGENTS.md`、`docs/technical/`、`docs/project-memory/shared-memory/`、后端启动脚本、测试支撑和所有后续工程文档。 - 验证方式:新增或修改后端相关文档时,检查不得要求 `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`。 @@ -1652,7 +1676,7 @@ ## 2026-05-07 视觉小说 VN-11 负向扫描门禁 - 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。 -- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/`、`packages/shared/src/`、`server-rs/crates/`、`docs/` 与 `.hermes/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。 +- 决策:新增 `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 / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。 - 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。 - 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。 - 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`。 @@ -1669,7 +1693,7 @@ - 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。 - 决策:视觉小说后续维护的正式入口固定为 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`、`SPACETIMEDB_TABLE_CATALOG.md`、`VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md`、`VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md`、`VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md` 和 `VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;旧 TXT 迁移文档仅保留历史参考地位。 -- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。 +- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、项目共享记忆和后续维护阅读顺序。 - 验证方式:打开上述文档即可获得当前实现边界、表目录、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`。 @@ -1773,9 +1797,9 @@ - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 - 决策:不共享个人 `~/.hermes`,先在 Genarrative 仓库内使用 `.hermes/` 保存可 Git 同步的团队共享记忆、计划和未来 skills。 -- 影响范围:`AGENTS.md`、`.hermes/README.md`、`.hermes/shared-memory/`。 -- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `.hermes/shared-memory/` 文件。 -- 关联文档:`.hermes/README.md`、`.hermes/shared-memory/team-conventions.md`。 +- 影响范围:`AGENTS.md`、`.hermes/README.md`、`docs/project-memory/shared-memory/`。 +- 验证方式:任一开发者拉取仓库后,在项目根目录启动 Hermes,均可读取同一套 `docs/project-memory/shared-memory/` 文件。 +- 关联文档:`.hermes/README.md`、`docs/project-memory/shared-memory/team-conventions.md`。 ## 2026-04-25 后端唯一落地口径固定为 Rust / SpacetimeDB diff --git a/.hermes/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md similarity index 88% rename from .hermes/shared-memory/development-workflow.md rename to docs/project-memory/shared-memory/development-workflow.md index ca86734c..af677416 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -1,16 +1,16 @@ # 开发工作流 -> 用途:给本地 Hermes 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。 +> 用途:给本地 Agent 和开发人员提供统一的开发、测试、提交流程。具体命令以 `package.json`、`server-rs/Cargo.toml`、`AGENTS.md` 和相关 `docs/` 最新文档为准。 ## 标准任务流程 ```text -同步代码 → 读取 AGENTS.md → 读取 .hermes/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交 +同步代码 → 读取 AGENTS.md → 读取 docs/project-memory/shared-memory → 查找/完善 docs → 制定计划 → 小步实现 → 本地验证 → 更新文档/记忆 → 提交 ``` ## 建议启动方式 -在项目根目录启动 Hermes: +在项目根目录启动本地 Agent: ```bash cd /path/to/Genarrative @@ -30,7 +30,7 @@ hermes - [ ] 当前分支是否正确 - [ ] 是否已拉取最新代码 - [ ] 是否阅读 `AGENTS.md` -- [ ] 是否阅读 `.hermes/shared-memory/` 相关文件 +- [ ] 是否阅读 `docs/project-memory/shared-memory/` 相关文件 - [ ] 是否阅读 `README.md` 中的运行和检查命令 - [ ] 是否阅读 `docs/README.md` 及任务相关分类 README - [ ] 是否存在足够具体的 PRD / 设计 / 技术文档 @@ -161,6 +161,15 @@ Codex 项目级 hook 保存在 `.codex/config.toml` 与 `.codex/hooks/`: 个人 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`。 @@ -276,17 +285,17 @@ npm run check:server-rs-ddd - 工程修改要同步更新对应文档。 - 如果没有现成文档,新文档统一放入 `docs/` 下合适分类。 -- `.hermes/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。 -- 如果 `.hermes/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。 +- `docs/project-memory/shared-memory/` 只记录高频、长期、团队共享的摘要和索引,不替代完整 PRD/技术文档。 +- 如果 `docs/project-memory/shared-memory/` 与代码或 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正共享记忆。 -## 提交前建议让 Hermes 执行 +## 提交前建议让 Agent 执行 涉及拼图、抓大鹅、敲木鱼统一创作 / 生成链路、Phase 2 之后的跨玩法回归或本地 dev 栈时,先按 `quality-gates/README.md`、`quality-gates/【玩法创作】跨玩法回归与冒烟门禁-2026-05-30.md` 和对应单项门禁文档执行自动脚本与体验检查。 ```text 请检查当前 git diff,指出: -1. 是否违反 AGENTS.md 或 .hermes/shared-memory 约定; +1. 是否违反 AGENTS.md 或 docs/project-memory/shared-memory 约定; 2. 是否需要补充 docs; -3. 是否有长期知识需要写入 .hermes/shared-memory; +3. 是否有长期知识需要写入 docs/project-memory/shared-memory; 4. 建议的测试命令和提交信息。 ``` diff --git a/.hermes/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md similarity index 92% rename from .hermes/shared-memory/document-map.md rename to docs/project-memory/shared-memory/document-map.md index a2091763..fc5a6d69 100644 --- a/.hermes/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -6,7 +6,7 @@ | 场景 | 优先阅读 | | --- | --- | -| 建立项目背景 | `README.md`、`AGENTS.md`、`.hermes/shared-memory/project-overview.md` | +| 建立项目背景 | `README.md`、`AGENTS.md`、`docs/project-memory/shared-memory/project-overview.md` | | 找当前文档 | `docs/README.md` | | 产品、命名、UI、协作和废弃路线 | `docs/【项目基线】当前产品与工程约束-2026-05-15.md` | | 后端、DDD、API、SpacetimeDB schema 和表目录 | `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` | @@ -21,7 +21,7 @@ 通用复杂任务: 1. `AGENTS.md` -2. `.hermes/shared-memory/` +2. `docs/project-memory/shared-memory/` 3. `docs/README.md` 4. 与任务匹配的当前融合文档 @@ -50,5 +50,5 @@ - 新增工程实现时,如果已有对应当前文档,必须同步更新。 - 如果没有合适位置,新文档文件名必须使用 `【标签名】中文标题-YYYY-MM-DD.md`。 - 阶段性流水账、一次性修复记录和已关闭实验不要再新增为长期文档。 -- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `.hermes/shared-memory/`。 +- 阶段性计划和一次性 TODO 不再作为长期文档目录;需要保留的决策、流程和坑点应进入 `docs/` 当前文档或 `docs/project-memory/shared-memory/`。 - 如果文档与代码冲突,先确认代码事实,再更新过期文档和共享记忆。 diff --git a/.hermes/shared-memory/handoff-template.md b/docs/project-memory/shared-memory/handoff-template.md similarity index 90% rename from .hermes/shared-memory/handoff-template.md rename to docs/project-memory/shared-memory/handoff-template.md index 29c17505..e7f0681a 100644 --- a/.hermes/shared-memory/handoff-template.md +++ b/docs/project-memory/shared-memory/handoff-template.md @@ -50,4 +50,4 @@ ## 是否需要更新团队记忆 - [ ] 不需要 -- [ ] 需要,建议更新:`.hermes/shared-memory/...` +- [ ] 需要,建议更新:`docs/project-memory/shared-memory/...` diff --git a/.hermes/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md similarity index 99% rename from .hermes/shared-memory/pitfalls.md rename to docs/project-memory/shared-memory/pitfalls.md index 0be1a973..aeee454b 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -159,6 +159,14 @@ - 验证:`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`。 +## 拼图公开推荐不要只按 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-*` 敲木鱼作品点赞时,平台错误弹窗显示 `custom_world 已发布作品不存在,无法点赞`。 @@ -187,8 +195,8 @@ ## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态 - 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。 -- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。 -- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。 +- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。若认证成功后把 `daily_login` 当普通埋点写入,或历史 `profile_task_config` 仍保留旧 `profile.login.daily` 事件键,新业务日也可能写了登录事件却查不到任务进度。 +- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。后端认证成功统一走 `SpacetimeClient::record_daily_login_tracking_event(...)` 与 SpacetimeDB 专用 `record_daily_login_tracking_event_and_return`,默认每日登录任务读取时会把结算字段自愈到 canonical `daily_login`。 - 验证:`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`。 @@ -304,6 +312,14 @@ - 验证:聚焦测试断言关卡缩略图使用 `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(...)` 里直接炸掉。 @@ -2054,7 +2070,7 @@ - 原因:旧 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`。 - 验证:`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`、`.hermes/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 关联:`scripts/dev.mjs`、`docs/project-memory/shared-memory/pitfalls.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 微信历史孤儿作品不要让新注册账号顶替 diff --git a/.hermes/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md similarity index 100% rename from .hermes/shared-memory/project-overview.md rename to docs/project-memory/shared-memory/project-overview.md diff --git a/.hermes/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md similarity index 79% rename from .hermes/shared-memory/team-conventions.md rename to docs/project-memory/shared-memory/team-conventions.md index c4e19275..81f3a5ba 100644 --- a/.hermes/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -1,22 +1,22 @@ # 团队协作约定 -> 用途:约定 3 名开发人员在各自本地 Hermes 中协作开发、共享项目记忆的方式。 +> 用途:约定 3 名开发人员在各自本地开发环境和 Agent 中协作开发、共享项目记忆的方式。 ## 基本模式 -- 每位开发人员在自己的电脑上使用本地 Hermes。 +- 每位开发人员在自己的电脑上使用本地 Agent。 - 每位开发人员本地拉取同一个项目仓库,独立修改代码、运行测试、提交分支。 -- 团队共享内容优先放在本仓库 `.hermes/` 与 `docs/` 中,通过 Git 同步。 +- 团队共享内容优先放在本仓库 `docs/project-memory/` 与 `docs/` 中,通过 Git 同步。 - 不共享个人 `~/.hermes` 目录。 ## 共享与禁止共享 推荐共享: -- `.hermes/shared-memory/` 团队级长期记忆 -- `.hermes/plans/` 阶段性实施计划 -- `.hermes/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划 -- `.hermes/skills/` 未来可复用仓库级 skills +- `docs/project-memory/shared-memory/` 团队级长期记忆 +- `docs/project-memory/plans/` 阶段性实施计划 +- `docs/project-memory/todos/` 已确定需要执行、但尚未进入实施的共享 TODO 计划 +- `.hermes/skills/` Hermes 专用仓库级 skills - `docs/` 中 PRD、设计、技术、经验、审计、查询手册 - `AGENTS.md` 项目级 Agent 约束 @@ -33,7 +33,7 @@ 1. 拉取最新代码。 2. 阅读 `AGENTS.md`。 -3. 阅读 `.hermes/shared-memory/` 中与任务相关的文件。 +3. 阅读 `docs/project-memory/shared-memory/` 中与任务相关的文件。 4. 阅读 `docs/README.md` 和任务相关分类 README。 5. 阅读对应 PRD、设计、技术、经验或审计文档。 6. 如果文档不足以指导编码,先补充或修正文档。 @@ -54,8 +54,8 @@ 1. 运行与修改范围匹配的测试或验证命令。 2. 更新相关 `docs/` 文档。 3. 新增或沉淀 Markdown 文档时,确认文件名已使用 `【标签名】` 前缀。 -4. 若产生长期有效知识,更新 `.hermes/shared-memory/`。 -5. 若形成可复用流程,考虑沉淀到 `.hermes/skills/`。 +4. 若产生长期有效知识,更新 `docs/project-memory/shared-memory/`。 +5. 若形成 Hermes 专用可复用流程,考虑沉淀到 `.hermes/skills/`。 6. 提交代码时,提交标题使用中文;标题后逐行写明本次提交修改了什么,每条变更单独一行。 ## 文档阅读顺序 @@ -64,7 +64,7 @@ 1. `README.md` 2. `AGENTS.md` -3. `.hermes/shared-memory/` +3. `docs/project-memory/shared-memory/` 4. `docs/README.md` 5. `docs/experience/README.md` 6. `docs/audits/README.md` diff --git a/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md b/docs/project-memory/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md similarity index 100% rename from .hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md rename to docs/project-memory/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md diff --git a/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md index 6301b5b1..3d9666a4 100644 --- a/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md +++ b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md @@ -4,7 +4,7 @@ `PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。 -`.hermes/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。 +`docs/project-memory/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。 本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index a38545a4..9e7d27f4 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -277,7 +277,7 @@ 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` 里内嵌的 white tool modal 继续并回 `UnifiedModal` 体系:参考图预览与主图预览都改成直接复用 `src/components/common/UnifiedModal.tsx`,继续保留各自 `max-w` / `max-h` 节奏、点击遮罩关闭与紧凑 header;移除图片确认改成复用 `src/components/common/UnifiedConfirmDialog.tsx`,不再在 panel 内手写 `platform-modal-backdrop + platform-modal-shell + 两列按钮`。这次没有新增 `PlatformImagePreviewModal`,因为当前预览弹窗差异还只在尺寸和文案层,继续直接组合 `UnifiedModal` 更深、更稳。后续 `common` 级图片面板若出现同类“预览大图 + 单标题栏 + 关闭按钮”弹窗,优先先复用 `UnifiedModal` 并把尺寸/文案留在调用方;只有当至少两到三个调用点开始重复同一套 preview body/header adapter 时,再考虑补新的薄壳。验证命令:`npx vitest run src/components/common/CreativeImageInputPanel.test.tsx src/components/common/UnifiedModal.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`。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 90eb4a19..d5d6b2b2 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -35,10 +35,10 @@ 队列状态对前端只通过 `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/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` 原样传给前端。 +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页 / 进度页只展示当前玩法业务进度;用户可见队列概览放在 `我的` 页签,必要时再用单 job 状态补充排障信息,并继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。 ## 任务表 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 6ba95baf..6d6df13e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -710,6 +710,8 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 说明:拼图作品 profile 表,保存草稿 / 已发布作品的标题、作者、关卡、封面、发布状态、可见性、基础游玩数、点赞数、改造数和积分激励领取状态。 +- 字段变更:`visible` 控制是否进入公开列表 / 详情、通关后的推荐下一作品候选、公开点赞 / Remix 和正式公开 runtime;默认 `true`。后台隐藏后作品可保留 `publication_status = Published`,但公开消费路径必须按 `Published + visible=true` 判断。 ### `puzzle_clear_agent_session` @@ -755,14 +757,14 @@ npm run check:server-rs-ddd - Rust view:`puzzle_gallery_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 +- 说明:拼图广场公开详情 source / 兼容投影,只暴露 `publication_status = Published` 且 `visible = true` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。 ### SpacetimeDB view:`puzzle_gallery_card_view` - Rust view:`puzzle_gallery_card_view` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` -- 说明:拼图公开列表 source 投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。 +- 说明:拼图公开列表 source 投影,只暴露 `publication_status = Published` 且 `visible = true` 的公开字段,不携带 levels / anchor_pack 等详情级载荷;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle/gallery` 保留旧 HTTP shape,并从统一 public cache 映射回 `PuzzleGalleryResponse`。 ### 拼图公开列表 HTTP 窗口缓存 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 86218a51..fc9c274f 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -5,7 +5,7 @@ ## 标准开发流程 ```text -同步代码 -> 读 AGENTS.md / .hermes 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / .hermes -> 提交 +同步代码 -> 读 AGENTS.md / docs/project-memory 共享记忆 -> 查当前 docs -> 小步实现 -> 本地验证 -> 更新 docs / project-memory -> 提交 ``` 如果当前文档不足以指导编码,先补文档再落地工程修改。 @@ -57,7 +57,7 @@ Windows 本地 `npm run dev` / `npm run dev:api-server` 会用空的 `RUSTC_WRAP 本地排查外部内容生成 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 暴露到前端。 +`我的` 页签或排障面板展示队列等待时,只读取 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 接口确认业务状态同步完成。 @@ -143,6 +143,8 @@ npm run spacetime:generate 项目已安装 `@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 @@ -424,7 +426,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms - `profile_task_reward_claim` - `profile_wallet_ledger` -个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 +个人任务首版 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`。 外部 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 请求日志,入口拿不到上下文时允许为空。常用查询: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 0af3f4a1..ac3104e6 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -36,7 +36,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 平台入口、生成页、结果页、作品详情、作品架和运行态的跨流程错误统一收口到 `PlatformErrorDialog`。弹窗必须带明确错误来源,例如某个草稿、某次生成、作品详情或某个游玩实例,并提供复制按钮复制“错误来源 + 错误内容”。页面内不再重复渲染裸错误 banner;表单校验、发布确认弹窗里的局部业务错误可以保留在原弹窗内。生成任务在用户离开生成页后异步失败时,也必须通过同一弹窗通知用户,并把失败消息写入该 session 的草稿 notice,供草稿页和失败重试页恢复使用。 -生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。 +生成任务在用户离开生成页后异步完成时,平台壳层必须弹出 `PlatformTaskCompletionDialog`。完成弹窗同样要带来源,例如某个草稿或生成会话,并提供复制按钮复制“来源 + 状态”;如果用户仍停留在生成页并被自动带入结果页或试玩页,生成页 / 结果页本身即为完成反馈,不再额外叠加完成弹窗。外部生成队列的用户可见概览统一放在移动端一级 `我的` 页签,生成页 / 进度页只展示当前玩法的阶段、步骤、总进度、错误和重试动作;用户离开生成页后仍可在 `我的` 页查看当前账号可见的排队与生成数量。队列概览只作为等待状态补充,草稿 ready / failed 与作品结果仍以后端玩法 session/detail 回读为准。 入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 884383dd..310adb1d 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -121,9 +121,9 @@ server-rs + Axum + SpacetimeDB - 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`。 -- 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `.hermes/shared-memory/decision-log.md` 的最新稳定摘要为准。 +- 根 `CONTEXT.md` 是当前领域语言入口;架构决策以本文档和 `docs/project-memory/shared-memory/decision-log.md` 的最新稳定摘要为准。 - `.hermes/` 只保存可进入 Git 的团队共享记忆、计划和可公开 skill,不提交个人 Hermes 配置、会话、密钥、Token 或本地私密路径。 -- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `.hermes/shared-memory/`。 +- 每次工程修改都应同步更新本目录当前文档;如果产生长期有效知识,再同步 `docs/project-memory/shared-memory/`。 ## 当前文档策略 diff --git a/package.json b/package.json index 126f396f..3a014de6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,8 @@ "codegraph:index": "codegraph index .", "codegraph:sync": "codegraph sync .", "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" }, "dependencies": { diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index f5514932..cfa89536 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -1,4 +1,4 @@ -export const API_VERSION = '2026-04-08'; +export const API_VERSION = '2026-06-16'; export const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; export const API_RESPONSE_ENVELOPE_VERSION = 'v1'; diff --git a/scripts/check-visual-novel-vn11-negative-scan.mjs b/scripts/check-visual-novel-vn11-negative-scan.mjs index d2c6ff84..d26f3265 100644 --- a/scripts/check-visual-novel-vn11-negative-scan.mjs +++ b/scripts/check-visual-novel-vn11-negative-scan.mjs @@ -7,7 +7,7 @@ const reportPath = join(repoRoot, '.tmp', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07. const documentTargets = [ 'docs', - '.hermes/shared-memory', + 'docs/project-memory/shared-memory', ]; const visualNovelImplementationTargets = [ @@ -202,7 +202,7 @@ const reportLines = [ '## 扫描范围', '', '- 视觉小说工程代码:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径', - '- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`', + '- 文档与共享记忆:`docs/`、`docs/project-memory/shared-memory/`', '- 外部平台误入复核:视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径', '', '## 扫描结论', diff --git a/scripts/rag/README.md b/scripts/rag/README.md new file mode 100644 index 00000000..cbdfeea6 --- /dev/null +++ b/scripts/rag/README.md @@ -0,0 +1,83 @@ +# 本地 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 工具目录,不作为项目知识库索引源。 diff --git a/scripts/rag/index-docs.mjs b/scripts/rag/index-docs.mjs new file mode 100644 index 00000000..ad04ccbc --- /dev/null +++ b/scripts/rag/index-docs.mjs @@ -0,0 +1,68 @@ +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}`, +); diff --git a/scripts/rag/rag-config.json b/scripts/rag/rag-config.json new file mode 100644 index 00000000..0d33855d --- /dev/null +++ b/scripts/rag/rag-config.json @@ -0,0 +1,46 @@ +{ + "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/" + ] +} diff --git a/scripts/rag/rag-utils.mjs b/scripts/rag/rag-utils.mjs new file mode 100644 index 00000000..e7ad6d66 --- /dev/null +++ b/scripts/rag/rag-utils.mjs @@ -0,0 +1,221 @@ +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); +} diff --git a/scripts/rag/search-docs.mjs b/scripts/rag/search-docs.mjs new file mode 100644 index 00000000..0acba005 --- /dev/null +++ b/scripts/rag/search-docs.mjs @@ -0,0 +1,195 @@ +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'), + ); + } +} diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 86b8cb6f..2a1bba8c 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -122,7 +122,7 @@ 当前基础响应头约定: 1. 所有响应都会回写 `x-request-id`。 -2. 所有响应都会回写固定的 `x-api-version`,当前值与 body `meta.apiVersion` 保持一致。 +2. 所有响应都会回写固定的 `x-api-version`,值来自 `shared_contracts::api::API_VERSION`,当前为 `2026-06-16`,并与 body `meta.apiVersion` 保持一致。 3. 所有响应都会回写 `x-route-version`,当前阶段默认与 `x-api-version` 保持一致,后续再按路由粒度细分。 4. 所有响应都会回写 `x-response-time-ms`,值来源于 `RequestContext` 内记录的请求开始时间。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index b70568e9..573747d0 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -487,14 +487,14 @@ mod tests { .headers() .get("x-api-version") .and_then(|value| value.to_str().ok()), - Some("2026-04-08") + Some("2026-06-16") ); assert_eq!( response .headers() .get("x-route-version") .and_then(|value| value.to_str().ok()), - Some("2026-04-08") + Some("2026-06-16") ); assert!(response.headers().contains_key("x-response-time-ms")); diff --git a/server-rs/crates/shared-contracts/src/api.rs b/server-rs/crates/shared-contracts/src/api.rs index c6c3b433..ebd7d90f 100644 --- a/server-rs/crates/shared-contracts/src/api.rs +++ b/server-rs/crates/shared-contracts/src/api.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -pub const API_VERSION: &str = "2026-04-08"; +pub const API_VERSION: &str = "2026-06-16"; pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; pub const X_REQUEST_ID_HEADER: &str = "x-request-id"; pub const API_VERSION_HEADER: &str = "x-api-version"; diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 6fded8c1..8b253e7e 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -153,7 +153,7 @@ pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec .puzzle_work_profile() .by_puzzle_work_publication_status() .filter(PuzzlePublicationStatus::Published) - .filter(|row| row.visible) + .filter(is_public_visible_puzzle_work) .filter_map( |row| match build_puzzle_work_profile_from_row_without_recent_count(&row) { Ok(profile) => Some(profile), @@ -183,7 +183,7 @@ pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec Some(item), Err(error) => { @@ -2069,6 +2069,7 @@ fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, Str .puzzle_work_profile() .by_puzzle_work_publication_status() .filter(PuzzlePublicationStatus::Published) + .filter(is_public_visible_puzzle_work) .collect::>(); let profile_ids = rows .iter() @@ -2094,8 +2095,8 @@ fn get_puzzle_gallery_detail_tx( .profile_id() .find(&input.profile_id) .ok_or_else(|| "拼图作品不存在".to_string())?; - if row.publication_status != PuzzlePublicationStatus::Published { - return Err("拼图作品尚未发布".to_string()); + if !is_public_visible_puzzle_work(&row) { + return Err("拼图作品不可公开访问".to_string()); } build_puzzle_work_profile_from_row_with_recent_count( ctx, @@ -2118,7 +2119,7 @@ fn record_puzzle_work_like_tx( .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) - .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .filter(is_public_visible_puzzle_work) .ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?; let inserted_like = record_public_work_like( ctx, @@ -2214,7 +2215,7 @@ fn remix_puzzle_work_tx( .puzzle_work_profile() .profile_id() .find(&source_profile_id.to_string()) - .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .filter(is_public_visible_puzzle_work) .ok_or_else(|| "拼图已发布源作品不存在".to_string())?; let source_profile = build_puzzle_work_profile_from_row(&source)?; let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros); @@ -2355,6 +2356,11 @@ fn start_puzzle_run_tx( { return Err("入口拼图作品未发布".to_string()); } + if entry_profile_row.publication_status == PuzzlePublicationStatus::Published + && !entry_profile_row.visible + { + return Err("入口拼图作品不可公开访问".to_string()); + } let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; if entry_profile.cover_image_src.is_none() { return Err("入口拼图作品缺少正式图片".to_string()); @@ -2387,7 +2393,7 @@ fn start_puzzle_run_tx( ); refresh_next_level_handoff(ctx, &mut run)?; - if entry_profile_row.publication_status == PuzzlePublicationStatus::Published { + if is_public_visible_puzzle_work(&entry_profile_row) { record_public_work_play( ctx, PublicWorkPlayRecordInput { @@ -2595,6 +2601,7 @@ fn advance_puzzle_next_level_tx( .puzzle_work_profile() .profile_id() .find(&next_profile.profile_id) + .filter(is_public_visible_puzzle_work) { record_public_work_play( ctx, @@ -2822,7 +2829,7 @@ fn submit_puzzle_leaderboard_entry_tx( if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) { return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); } - if current_profile_row.publication_status != PuzzlePublicationStatus::Published { + if !is_public_visible_puzzle_work(¤t_profile_row) { hydrate_puzzle_leaderboard_entries( ctx, &mut run, @@ -3832,10 +3839,15 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result bool { + row.publication_status == PuzzlePublicationStatus::Published && row.visible +} + fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) { run.recommended_next_profile_id = None; run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(); @@ -4250,6 +4262,29 @@ mod tests { ); } + #[test] + fn hidden_published_puzzle_work_is_not_public_visible_candidate() { + let visible_published = puzzle_work_profile_row_for_visibility( + "visible-published", + PuzzlePublicationStatus::Published, + true, + ); + let hidden_published = puzzle_work_profile_row_for_visibility( + "hidden-published", + PuzzlePublicationStatus::Published, + false, + ); + let visible_draft = puzzle_work_profile_row_for_visibility( + "visible-draft", + PuzzlePublicationStatus::Draft, + true, + ); + + assert!(is_public_visible_puzzle_work(&visible_published)); + assert!(!is_public_visible_puzzle_work(&hidden_published)); + assert!(!is_public_visible_puzzle_work(&visible_draft)); + } + #[test] fn level_generation_failure_only_marks_target_level_failed() { let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); @@ -4371,4 +4406,39 @@ mod tests { assert_eq!(entries[1].nickname, "玩家 B"); assert_eq!(entries[1].rank, 2); } + + fn puzzle_work_profile_row_for_visibility( + profile_id: &str, + publication_status: PuzzlePublicationStatus, + visible: bool, + ) -> PuzzleWorkProfileRow { + let timestamp = Timestamp::from_micros_since_unix_epoch(1); + PuzzleWorkProfileRow { + profile_id: profile_id.to_string(), + work_id: format!("work-{profile_id}"), + owner_user_id: "owner".to_string(), + source_session_id: None, + author_display_name: "作者".to_string(), + work_title: "作品".to_string(), + work_description: String::new(), + level_name: "第一关".to_string(), + summary: "摘要".to_string(), + theme_tags_json: "[]".to_string(), + cover_image_src: Some("/cover.png".to_string()), + cover_asset_id: Some("asset-cover".to_string()), + levels_json: "[]".to_string(), + publication_status, + play_count: 0, + anchor_pack_json: serialize_json(&empty_anchor_pack()), + publish_ready: true, + created_at: timestamp, + updated_at: timestamp, + published_at: Some(timestamp), + remix_count: 0, + like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + visible, + } + } } diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 886172b4..9c786a9c 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -31,16 +31,8 @@ interface CustomWorldGenerationViewProps { idleBadgeLabel?: string; structuredEmptyText?: string; hideBatchModule?: boolean; - queueStatus?: ExternalGenerationQueueStatus | null; } -export type ExternalGenerationQueueStatus = { - currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null; - currentProgress?: number | null; - pendingCount?: number | null; - runningCount?: number | null; -}; - function formatDuration(ms: number) { const safeMs = Math.max(0, Math.round(ms)); const totalSeconds = Math.ceil(safeMs / 1000); @@ -93,49 +85,6 @@ function getStepStatusLabel(step: { status: string }) { return '待处理'; } -function resolveQueueStatusLabel( - status: ExternalGenerationQueueStatus['currentStatus'], -) { - if (status === 'queued') { - return '排队中'; - } - - if (status === 'running') { - return '生成中'; - } - - if (status === 'failed') { - return '生成失败'; - } - - if (status === 'completed') { - return '已完成'; - } - - return null; -} - -function hasQueueStatus(status: ExternalGenerationQueueStatus | null | undefined) { - return Boolean( - status && - (status.currentStatus || - typeof status.pendingCount === 'number' || - typeof status.runningCount === 'number'), - ); -} - -function formatQueueCount(value: number | null | undefined) { - return Math.max(0, Math.round(value ?? 0)).toString(); -} - -function formatQueueProgress(value: number | null | undefined) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return null; - } - - return `${Math.max(0, Math.min(100, Math.round(value)))}%`; -} - function resolveCurrentGenerationStep( progress: CustomWorldGenerationProgress | null, ) { @@ -162,7 +111,6 @@ export function CustomWorldGenerationView({ activeBadgeLabel = '世界建设中', idleBadgeLabel = '等待操作', hideBatchModule = false, - queueStatus = null, }: CustomWorldGenerationViewProps) { void hideBatchModule; const progressValue = getProgressPercentage(progress); @@ -183,11 +131,6 @@ export function CustomWorldGenerationView({ : '校准中'; const elapsedText = progress != null ? formatDuration(progress.elapsedMs) : '启动中'; - const queueStatusLabel = resolveQueueStatusLabel( - queueStatus?.currentStatus ?? null, - ); - const queueProgressText = formatQueueProgress(queueStatus?.currentProgress); - const shouldShowQueueStatus = hasQueueStatus(queueStatus); return (
@@ -224,21 +167,6 @@ export function CustomWorldGenerationView({ />
- {shouldShowQueueStatus ? ( -
- {queueStatusLabel ? ( - - {queueProgressText - ? `${queueStatusLabel} ${queueProgressText}` - : queueStatusLabel} - - ) : null} - 排队 {formatQueueCount(queueStatus?.pendingCount)} - - 生成 {formatQueueCount(queueStatus?.runningCount)} -
- ) : null} -
{!isGenerating ? ( expect(onMainImageRemove).toHaveBeenCalledTimes(1); }); -test('creative image input panel closes reference preview on backdrop click', () => { +test('creative image input panel closes reference preview with close button', () => { render( { +test('creative image input panel closes main image preview with close button', () => { render( ) : null} - setPreviewReferenceImage(null)} + imageSrc={previewReferenceImage?.imageSrc ?? null} + imageAlt={labels.promptReferencePreviewAlt} closeLabel={labels.closePromptReferencePreview} - closeVariant="profileCompact" - size="lg" zIndexClassName="z-[80]" - overlayClassName="px-4 py-6" - panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]" - headerClassName="mb-3 items-center border-b-0 px-1 py-0" - titleClassName="text-sm font-black" - bodyClassName="px-0 py-0" - > - {previewReferenceImage ? ( -
- -
- ) : null} -
+ onClose={() => setPreviewReferenceImage(null)} + /> - setIsMainImagePreviewOpen(false)} + imageSrc={uploadedImageSrc} + imageAlt={uploadedImageAlt} + refreshKey={uploadedImageRefreshKey} closeLabel={ labels.closeMainImagePreview ?? labels.closePromptReferencePreview } - closeVariant="profileCompact" - size="xl" zIndexClassName={mainImagePreviewZIndexClassName} - overlayClassName="px-4 py-6" - panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]" - headerClassName="mb-3 items-center border-b-0 px-1 py-0" - titleClassName="text-sm font-black" - bodyClassName="px-0 py-0" - > - {uploadedImageSrc ? ( -
- -
- ) : null} -
+ onClose={() => setIsMainImagePreviewOpen(false)} + /> ({ + ResolvedAssetImage: ({ + src, + alt, + className, + style, + draggable, + onLoad, + }: { + src?: string | null; + alt?: string; + className?: string; + style?: React.CSSProperties; + draggable?: boolean; + onLoad?: React.ReactEventHandler; + }) => + src ? ( + {alt} + ) : null, +})); + +test('clamps full-screen image preview zoom and drag within image bounds', () => { + expect( + clampPlatformImagePreviewTransform( + { + scale: 8, + offsetX: 999, + offsetY: -999, + }, + { width: 400, height: 800 }, + { width: 400, height: 400 }, + ), + ).toEqual({ + scale: 4, + offsetX: 600, + offsetY: -400, + }); + + expect( + clampPlatformImagePreviewTransform( + { + scale: 4, + offsetX: -999, + offsetY: 999, + }, + { width: 400, height: 800 }, + { width: 400, height: 400 }, + ), + ).toEqual({ + scale: 4, + offsetX: -600, + offsetY: 400, + }); + + expect( + clampPlatformImagePreviewTransform( + { + scale: 0.25, + offsetX: 80, + offsetY: 80, + }, + { width: 400, height: 800 }, + { width: 400, height: 400 }, + ), + ).toEqual({ + scale: 1, + offsetX: 0, + offsetY: 0, + }); +}); + +test('renders full-screen image preview with zoom controls and dark backdrop', () => { + render( + {}} + />, + ); + + const dialog = screen.getByRole('dialog', { name: '查看关卡图片' }); + expect(dialog.className).toContain('platform-image-preview-modal'); + expect(dialog.className).toContain('bg-black'); + expect( + dialog.parentElement?.className.includes('!bg-black'), + ).toBe(true); + expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy(); + expect(screen.getByAltText('拼图关卡图').getAttribute('draggable')).toBe( + 'false', + ); +}); + +test('zooms to at most four times and clamps dragged image position', async () => { + render( + {}} + />, + ); + + const stage = screen.getByTestId('platform-image-preview-stage'); + Object.defineProperty(stage, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + width: 400, + height: 800, + top: 0, + right: 400, + bottom: 800, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }); + + const image = screen.getByAltText('拼图关卡图') as HTMLImageElement; + Object.defineProperties(image, { + naturalWidth: { configurable: true, value: 400 }, + naturalHeight: { configurable: true, value: 400 }, + }); + + await act(async () => { + window.dispatchEvent(new Event('resize')); + fireEvent.load(image); + }); + + await waitFor(() => { + expect(image.style.width).toBe('400px'); + expect(image.style.height).toBe('400px'); + }); + + for (let index = 0; index < 8; index += 1) { + fireEvent.click(screen.getByRole('button', { name: '放大图片' })); + } + + await waitFor(() => { + expect(image.style.transform).toContain( + 'translate3d(0px, 0px, 0) scale(4)', + ); + }); + + fireEvent.pointerDown(stage, { pointerId: 1, clientX: 200, clientY: 400 }); + fireEvent.pointerMove(stage, { pointerId: 1, clientX: 1200, clientY: 1200 }); + fireEvent.pointerUp(stage, { pointerId: 1, clientX: 1200, clientY: 1200 }); + + await waitFor(() => { + const offsetMatch = image.style.transform.match( + /translate3d\((-?\d+)px, (-?\d+)px, 0\) scale\(4\)/u, + ); + + expect(offsetMatch).not.toBeNull(); + expect(Math.abs(Number(offsetMatch?.[1]))).toBe(600); + expect(Math.abs(Number(offsetMatch?.[2]))).toBe(400); + }); +}); diff --git a/src/components/common/PlatformImagePreviewModal.tsx b/src/components/common/PlatformImagePreviewModal.tsx new file mode 100644 index 00000000..8b9c2cf5 --- /dev/null +++ b/src/components/common/PlatformImagePreviewModal.tsx @@ -0,0 +1,332 @@ +import { Minus, Plus, RotateCcw } from 'lucide-react'; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type WheelEvent as ReactWheelEvent, +} from 'react'; + +import { ResolvedAssetImage } from '../ResolvedAssetImage'; +import { PlatformIconButton } from './PlatformIconButton'; +import { + clampPlatformImagePreviewTransform, + DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM, + MAX_PLATFORM_IMAGE_PREVIEW_SCALE, + MIN_PLATFORM_IMAGE_PREVIEW_SCALE, + PLATFORM_IMAGE_PREVIEW_SCALE_STEP, + type PlatformImagePreviewSize, + type PlatformImagePreviewTransform, + resolvePlatformImagePreviewContainedSize, + samePlatformImagePreviewTransform, +} from './platformImagePreviewModel'; +import { PlatformModalCloseButton } from './PlatformModalCloseButton'; +import { UnifiedModal } from './UnifiedModal'; + +type PlatformImagePreviewModalProps = { + open: boolean; + title: string; + imageSrc: string | null; + imageAlt: string; + refreshKey?: string | number | null; + closeLabel: string; + zIndexClassName?: string; + onClose: () => void; +}; + +type DragState = { + pointerId: number; + startClientX: number; + startClientY: number; + startOffsetX: number; + startOffsetY: number; + scale: number; +}; + +/** + * 全屏图片查看器。 + * 1x 保持完整图片可见;放大后拖拽会按图片边界夹住,避免拖出空背景。 + */ +export function PlatformImagePreviewModal({ + open, + title, + imageSrc, + imageAlt, + refreshKey = null, + closeLabel, + zIndexClassName = 'z-[110]', + onClose, +}: PlatformImagePreviewModalProps) { + const stageRef = useRef(null); + const dragStateRef = useRef(null); + const [viewportSize, setViewportSize] = useState({ + width: 1, + height: 1, + }); + const [naturalSize, setNaturalSize] = + useState(null); + const [transform, setTransform] = useState( + DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM, + ); + const [isDragging, setIsDragging] = useState(false); + + const containedImageSize = useMemo( + () => resolvePlatformImagePreviewContainedSize(viewportSize, naturalSize), + [naturalSize, viewportSize], + ); + + const clampTransform = useCallback( + (nextTransform: PlatformImagePreviewTransform) => + clampPlatformImagePreviewTransform( + nextTransform, + viewportSize, + naturalSize, + ), + [naturalSize, viewportSize], + ); + + const updateViewportSize = useCallback(() => { + const rect = stageRef.current?.getBoundingClientRect(); + setViewportSize({ + width: rect?.width || window.innerWidth || 1, + height: rect?.height || window.innerHeight || 1, + }); + }, []); + + const updateTransform = useCallback( + ( + updater: ( + current: PlatformImagePreviewTransform, + ) => PlatformImagePreviewTransform, + ) => { + setTransform((current) => { + const nextTransform = clampTransform(updater(current)); + return samePlatformImagePreviewTransform(current, nextTransform) + ? current + : nextTransform; + }); + }, + [clampTransform], + ); + + useEffect(() => { + if (!open) { + return; + } + + setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM); + setNaturalSize(null); + dragStateRef.current = null; + setIsDragging(false); + }, [imageSrc, open, refreshKey]); + + useEffect(() => { + if (!open) { + return; + } + + updateViewportSize(); + const frameId = window.requestAnimationFrame(updateViewportSize); + window.addEventListener('resize', updateViewportSize); + return () => { + window.cancelAnimationFrame(frameId); + window.removeEventListener('resize', updateViewportSize); + }; + }, [open, updateViewportSize]); + + useEffect(() => { + setTransform((current) => { + const nextTransform = clampTransform(current); + return samePlatformImagePreviewTransform(current, nextTransform) + ? current + : nextTransform; + }); + }, [clampTransform]); + + const zoomBy = useCallback( + (scaleDelta: number) => { + updateTransform((current) => ({ + ...current, + scale: current.scale + scaleDelta, + })); + }, + [updateTransform], + ); + + const resetTransform = useCallback(() => { + setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM); + }, []); + + const handleWheel = useCallback( + (event: ReactWheelEvent) => { + event.preventDefault(); + zoomBy( + event.deltaY < 0 + ? PLATFORM_IMAGE_PREVIEW_SCALE_STEP + : -PLATFORM_IMAGE_PREVIEW_SCALE_STEP, + ); + }, + [zoomBy], + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (transform.scale <= MIN_PLATFORM_IMAGE_PREVIEW_SCALE) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture?.(event.pointerId); + dragStateRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startOffsetX: transform.offsetX, + startOffsetY: transform.offsetY, + scale: transform.scale, + }; + setIsDragging(true); + }, + [transform], + ); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + event.preventDefault(); + const deltaX = event.clientX - dragState.startClientX; + const deltaY = event.clientY - dragState.startClientY; + updateTransform(() => ({ + scale: dragState.scale, + offsetX: dragState.startOffsetX + deltaX, + offsetY: dragState.startOffsetY + deltaY, + })); + }, + [updateTransform], + ); + + const finishPointerDrag = useCallback( + (event: ReactPointerEvent) => { + if (dragStateRef.current?.pointerId !== event.pointerId) { + return; + } + + event.currentTarget.releasePointerCapture?.(event.pointerId); + dragStateRef.current = null; + setIsDragging(false); + }, + [], + ); + + const imageStyle = { + width: `${containedImageSize.width}px`, + height: `${containedImageSize.height}px`, + transform: `translate3d(${transform.offsetX}px, ${transform.offsetY}px, 0) scale(${transform.scale})`, + }; + const canZoomOut = transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE; + const canZoomIn = transform.scale < MAX_PLATFORM_IMAGE_PREVIEW_SCALE; + + return ( + +
+
+ } + onClick={() => zoomBy(-PLATFORM_IMAGE_PREVIEW_SCALE_STEP)} + className="h-9 w-9" + /> + } + onClick={resetTransform} + className="h-9 w-9" + /> + } + onClick={() => zoomBy(PLATFORM_IMAGE_PREVIEW_SCALE_STEP)} + className="h-9 w-9" + /> +
+ +
+ +
MIN_PLATFORM_IMAGE_PREVIEW_SCALE + ? isDragging + ? 'cursor-grabbing' + : 'cursor-grab' + : 'cursor-default', + ] + .filter(Boolean) + .join(' ')} + style={{ touchAction: 'none' }} + onWheel={handleWheel} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={finishPointerDrag} + onPointerCancel={finishPointerDrag} + > + {imageSrc ? ( + { + const image = event.currentTarget; + setNaturalSize({ + width: image.naturalWidth || image.clientWidth || 1, + height: image.naturalHeight || image.clientHeight || 1, + }); + }} + className={`max-w-none select-none object-contain transition-transform ease-out ${ + isDragging ? 'duration-0' : 'duration-150' + }`} + style={imageStyle} + /> + ) : null} +
+
+ ); +} diff --git a/src/components/common/platformImagePreviewModel.ts b/src/components/common/platformImagePreviewModel.ts new file mode 100644 index 00000000..1da70423 --- /dev/null +++ b/src/components/common/platformImagePreviewModel.ts @@ -0,0 +1,96 @@ +export type PlatformImagePreviewSize = { + width: number; + height: number; +}; + +export type PlatformImagePreviewTransform = { + scale: number; + offsetX: number; + offsetY: number; +}; + +export const MIN_PLATFORM_IMAGE_PREVIEW_SCALE = 1; +export const MAX_PLATFORM_IMAGE_PREVIEW_SCALE = 4; +export const PLATFORM_IMAGE_PREVIEW_SCALE_STEP = 0.5; +export const DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM: PlatformImagePreviewTransform = + { + scale: MIN_PLATFORM_IMAGE_PREVIEW_SCALE, + offsetX: 0, + offsetY: 0, + }; + +function clampNumber(value: number, min: number, max: number) { + if (!Number.isFinite(value)) { + return min; + } + + return Math.max(min, Math.min(max, value)); +} + +function normalizePreviewSize(size: PlatformImagePreviewSize | null) { + if (!size || size.width <= 0 || size.height <= 0) { + return { width: 1, height: 1 }; + } + + return size; +} + +export function resolvePlatformImagePreviewContainedSize( + viewportSize: PlatformImagePreviewSize, + naturalSize: PlatformImagePreviewSize | null, +) { + const viewport = normalizePreviewSize(viewportSize); + const natural = normalizePreviewSize(naturalSize); + const imageRatio = natural.width / natural.height; + const viewportRatio = viewport.width / viewport.height; + + if (imageRatio > viewportRatio) { + return { + width: viewport.width, + height: viewport.width / imageRatio, + }; + } + + return { + width: viewport.height * imageRatio, + height: viewport.height, + }; +} + +export function samePlatformImagePreviewTransform( + left: PlatformImagePreviewTransform, + right: PlatformImagePreviewTransform, +) { + return ( + left.scale === right.scale && + left.offsetX === right.offsetX && + left.offsetY === right.offsetY + ); +} + +export function clampPlatformImagePreviewTransform( + transform: PlatformImagePreviewTransform, + viewportSize: PlatformImagePreviewSize, + naturalSize: PlatformImagePreviewSize | null, +): PlatformImagePreviewTransform { + const viewport = normalizePreviewSize(viewportSize); + const containedSize = resolvePlatformImagePreviewContainedSize( + viewport, + naturalSize, + ); + const scale = clampNumber( + transform.scale, + MIN_PLATFORM_IMAGE_PREVIEW_SCALE, + MAX_PLATFORM_IMAGE_PREVIEW_SCALE, + ); + const scaledWidth = containedSize.width * scale; + const scaledHeight = containedSize.height * scale; + const panLimitX = Math.max(0, (scaledWidth - viewport.width) / 2); + const panLimitY = Math.max(0, (scaledHeight - viewport.height) / 2); + + return { + scale, + offsetX: clampNumber(transform.offsetX, -panLimitX, panLimitX), + offsetY: clampNumber(transform.offsetY, -panLimitY, panLimitY), + }; +} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts index c364441d..76fe4b7b 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts @@ -5,7 +5,10 @@ import { resolveMiniGameGenerationProgressTickState, resolveMiniGameGenerationViewBusy, } from './PlatformEntryFlowShellImpl'; -import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel'; +import { + buildExternalGenerationQueuePresentation, + buildExternalGenerationQueueStatus, +} from './platformExternalGenerationQueueStatusModel'; import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel'; describe('resolveMiniGameGenerationProgressTickState', () => { @@ -88,4 +91,36 @@ describe('buildExternalGenerationQueueStatus', () => { test('没有队列或任务信息时不显示状态条', () => { expect(buildExternalGenerationQueueStatus(null, null)).toBeNull(); }); + + test('构造我的页生成队列展示状态', () => { + expect( + buildExternalGenerationQueuePresentation({ + currentStatus: 'running', + currentProgress: 42.4, + pendingCount: 2, + runningCount: 1, + }), + ).toEqual({ + statusLabel: '生成中', + progressLabel: '42%', + pendingLabel: '2', + runningLabel: '1', + pendingCount: 2, + runningCount: 1, + progress: 42, + shouldShow: true, + }); + + expect(buildExternalGenerationQueuePresentation(null).shouldShow).toBe( + false, + ); + expect( + buildExternalGenerationQueuePresentation({ + currentStatus: 'completed', + currentProgress: 100, + pendingCount: 0, + runningCount: 0, + }).shouldShow, + ).toBe(false); + }); }); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b811ee70..4e433785 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -4798,13 +4798,18 @@ export function PlatformEntryFlowShellImpl({ woodenFishGenerationState, ); const shouldShowExternalGenerationQueueStatus = - isExternalGenerationQueueStage(selectionStage); + isExternalGenerationQueueStage(selectionStage) || + (selectionStage === 'platform' && + platformBootstrap.platformTab === 'profile' && + platformBootstrap.canReadProtectedData); useEffect(() => { if (!shouldShowExternalGenerationQueueStatus) { setExternalGenerationQueueOverview(null); return; } + setExternalGenerationQueueOverview(null); + let disposed = false; let controller: AbortController | null = null; @@ -4832,47 +4837,44 @@ export function PlatformEntryFlowShellImpl({ controller?.abort(); window.clearInterval(intervalId); }; - }, [shouldShowExternalGenerationQueueStatus]); - const puzzleExternalGenerationQueueStatus = useMemo( - () => - buildExternalGenerationQueueStatus( - externalGenerationQueueOverview, - puzzleOperation?.queueState ?? null, - ), - [externalGenerationQueueOverview, puzzleOperation], - ); - const jumpHopExternalGenerationQueueStatus = useMemo( - () => - buildExternalGenerationQueueStatus( - externalGenerationQueueOverview, - jumpHopQueueState, - ), - [externalGenerationQueueOverview, jumpHopQueueState], - ); - const puzzleClearExternalGenerationQueueStatus = useMemo( - () => - buildExternalGenerationQueueStatus( - externalGenerationQueueOverview, - puzzleClearQueueState, - ), - [externalGenerationQueueOverview, puzzleClearQueueState], - ); - const woodenFishExternalGenerationQueueStatus = useMemo( - () => - buildExternalGenerationQueueStatus( - externalGenerationQueueOverview, - woodenFishQueueState, - ), - [externalGenerationQueueOverview, woodenFishQueueState], - ); + }, [authUi?.user?.id, shouldShowExternalGenerationQueueStatus]); + const activeExternalGenerationJobState = useMemo(() => { + const candidates = [ + puzzleOperation?.queueState ?? null, + jumpHopQueueState, + puzzleClearQueueState, + woodenFishQueueState, + ].filter((candidate): candidate is ExternalGenerationJobStatusRecord => + Boolean(candidate), + ); + + return ( + candidates.find( + (candidate) => + candidate.status === 'queued' || candidate.status === 'running', + ) ?? + candidates[0] ?? + null + ); + }, [ + jumpHopQueueState, + puzzleClearQueueState, + puzzleOperation?.queueState, + woodenFishQueueState, + ]); const externalGenerationQueueStatus = useMemo( () => buildExternalGenerationQueueStatus( externalGenerationQueueOverview, - null, + activeExternalGenerationJobState, ), - [externalGenerationQueueOverview], + [activeExternalGenerationJobState, externalGenerationQueueOverview], ); + const profileExternalGenerationQueueStatus = + platformBootstrap.canReadProtectedData && + platformBootstrap.platformTab === 'profile' + ? externalGenerationQueueStatus + : null; const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage( platformBootstrap.platformError, ) @@ -15213,6 +15215,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} profileTaskRefreshKey={profileTaskRefreshKey} + profileGenerationQueueStatus={profileExternalGenerationQueueStatus} isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ @@ -15649,7 +15652,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" - queueStatus={jumpHopExternalGenerationQueueStatus} /> @@ -15794,7 +15796,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('match3d-agent-workspace'); }} onRetry={retryMatch3DDraftGeneration} - queueStatus={puzzleClearExternalGenerationQueueStatus} hideBatchModule /> @@ -16065,7 +16066,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" - queueStatus={woodenFishExternalGenerationQueueStatus} /> @@ -16266,7 +16266,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="图片生成中" pausedBadgeLabel="图片生成已暂停" idleBadgeLabel="等待返回结果页" - queueStatus={externalGenerationQueueStatus} /> @@ -16471,7 +16470,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('jump-hop-workspace'); }} onRetry={retryJumpHopDraftGeneration} - queueStatus={externalGenerationQueueStatus} /> @@ -16620,7 +16618,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="素材生成中" pausedBadgeLabel="素材生成已暂停" idleBadgeLabel="等待返回工作区" - queueStatus={externalGenerationQueueStatus} /> @@ -16750,7 +16747,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('wooden-fish-workspace'); }} onRetry={retryWoodenFishDraftGeneration} - queueStatus={externalGenerationQueueStatus} /> @@ -16944,7 +16940,6 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('puzzle-agent-workspace'); }} onRetry={retryPuzzleDraftGeneration} - queueStatus={puzzleExternalGenerationQueueStatus} hideBatchModule /> @@ -17071,7 +17066,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" - queueStatus={externalGenerationQueueStatus} /> @@ -17315,7 +17309,6 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿编译中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" - queueStatus={externalGenerationQueueStatus} /> diff --git a/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx new file mode 100644 index 00000000..fc6e7cae --- /dev/null +++ b/src/components/platform-entry/PlatformProfileGenerationQueueCard.tsx @@ -0,0 +1,84 @@ +import { Loader2 } from 'lucide-react'; + +import { PlatformPillBadge } from '../common/PlatformPillBadge'; +import { PlatformProgressBar } from '../common/PlatformProgressBar'; +import { + buildExternalGenerationQueuePresentation, + type ExternalGenerationQueueStatus, +} from './platformExternalGenerationQueueStatusModel'; + +type PlatformProfileGenerationQueueCardProps = { + queueStatus: ExternalGenerationQueueStatus | null; +}; + +/** + * “我的”页里的后台生成状态卡。 + * 只展示当前账号可见的队列概览,业务完成仍以各玩法草稿 / 作品回读为准。 + */ +export function PlatformProfileGenerationQueueCard({ + queueStatus, +}: PlatformProfileGenerationQueueCardProps) { + const presentation = buildExternalGenerationQueuePresentation(queueStatus); + + if (!presentation.shouldShow) { + return null; + } + + const progressValue = presentation.progress ?? 0; + + return ( +
+
+
+
+ + + + + 生成队列 + +
+
+ {presentation.statusLabel ?? '等待生成任务'} +
+
+ {presentation.statusLabel ? ( + + {presentation.progressLabel + ? `${presentation.statusLabel} ${presentation.progressLabel}` + : presentation.statusLabel} + + ) : null} +
+ + + +
+
+ 排队 + {presentation.pendingLabel} +
+
+ 生成 + {presentation.runningLabel} +
+
+
+ ); +} diff --git a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts index 7238377b..180dbc61 100644 --- a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts +++ b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts @@ -2,7 +2,24 @@ import type { ExternalGenerationJobStatusRecord, ExternalGenerationQueueOverview, } from '../../../packages/shared/src/contracts/externalGeneration'; -import type { ExternalGenerationQueueStatus } from '../CustomWorldGenerationView'; + +export type ExternalGenerationQueueStatus = { + currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null; + currentProgress?: number | null; + pendingCount?: number | null; + runningCount?: number | null; +}; + +export type ExternalGenerationQueuePresentation = { + statusLabel: string | null; + progressLabel: string | null; + pendingLabel: string; + runningLabel: string; + pendingCount: number; + runningCount: number; + progress: number | null; + shouldShow: boolean; +}; export function buildExternalGenerationQueueStatus( overview: ExternalGenerationQueueOverview | null, @@ -19,3 +36,97 @@ export function buildExternalGenerationQueueStatus( runningCount: overview?.runningCount ?? null, }; } + +export function resolveExternalGenerationQueueStatusLabel( + status: ExternalGenerationQueueStatus['currentStatus'], +) { + if (status === 'queued') { + return '排队中'; + } + + if (status === 'running') { + return '生成中'; + } + + if (status === 'failed') { + return '生成失败'; + } + + if (status === 'completed') { + return '已完成'; + } + + return null; +} + +function resolveExternalGenerationQueueOverviewLabel( + pendingCount: number, + runningCount: number, +) { + if (runningCount > 0) { + return '生成中'; + } + + if (pendingCount > 0) { + return '排队中'; + } + + return null; +} + +export function normalizeExternalGenerationQueueCount( + value: number | null | undefined, +) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.round(value)); +} + +export function normalizeExternalGenerationQueueProgress( + value: number | null | undefined, +) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function buildExternalGenerationQueuePresentation( + status: ExternalGenerationQueueStatus | null | undefined, +): ExternalGenerationQueuePresentation { + const pendingCount = normalizeExternalGenerationQueueCount( + status?.pendingCount, + ); + const runningCount = normalizeExternalGenerationQueueCount( + status?.runningCount, + ); + const progress = normalizeExternalGenerationQueueProgress( + status?.currentProgress, + ); + const isCompletedCurrentJob = status?.currentStatus === 'completed'; + const currentStatusLabel = resolveExternalGenerationQueueStatusLabel( + status?.currentStatus ?? null, + ); + const overviewStatusLabel = resolveExternalGenerationQueueOverviewLabel( + pendingCount, + runningCount, + ); + const statusLabel = isCompletedCurrentJob + ? overviewStatusLabel + : (currentStatusLabel ?? overviewStatusLabel); + const progressValue = isCompletedCurrentJob ? null : progress; + + return { + statusLabel, + progressLabel: progressValue == null ? null : `${progressValue}%`, + pendingLabel: pendingCount.toString(), + runningLabel: runningCount.toString(), + pendingCount, + runningCount, + progress: progressValue, + shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0), + }; +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index e91bc908..226fc288 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -200,6 +200,7 @@ const authServiceMocks = vi.hoisted(() => ({ token: 'runtime-guest-token', expiresAt: '2099-01-01T00:00:00.000Z', })), + isWechatMiniProgramWebViewRuntime: vi.fn(() => false), getPublicAuthUserByCode: vi.fn( async (publicUserCode: string): Promise => ({ id: `public-user-${publicUserCode}`, @@ -222,6 +223,8 @@ const authServiceMocks = vi.hoisted(() => ({ vi.mock('../../services/authService', () => ({ ensureRuntimeGuestToken: authServiceMocks.ensureRuntimeGuestToken, + isWechatMiniProgramWebViewRuntime: + authServiceMocks.isWechatMiniProgramWebViewRuntime, getPublicAuthUserByCode: authServiceMocks.getPublicAuthUserByCode, getPublicAuthUserById: authServiceMocks.getPublicAuthUserById, })); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index e34e3510..587d7733 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -779,6 +779,7 @@ function ProfileHomeViewHarness({ userOverrides = {}, activeTab = 'profile', profileTaskRefreshKey = 0, + profileGenerationQueueStatus = null, profilePlayStats = null, isProfilePlayStatsOpen = false, }: { @@ -789,6 +790,7 @@ function ProfileHomeViewHarness({ userOverrides?: Partial; activeTab?: RpgEntryHomeViewProps['activeTab']; profileTaskRefreshKey?: number; + profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus']; profilePlayStats?: ProfilePlayStatsResponse | null; isProfilePlayStatsOpen?: boolean; }) { @@ -856,6 +858,7 @@ function ProfileHomeViewHarness({ onSearchPublicCode={vi.fn()} onRechargeSuccess={onRechargeSuccess} profileTaskRefreshKey={profileTaskRefreshKey} + profileGenerationQueueStatus={profileGenerationQueueStatus} /> ); @@ -871,6 +874,7 @@ function renderProfileView( profileStatsOptions: { profilePlayStats?: ProfilePlayStatsResponse | null; isProfilePlayStatsOpen?: boolean; + profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus']; } = {}, ) { return render( @@ -879,6 +883,9 @@ function renderProfileView( profileDashboardOverrides={profileDashboardOverrides} userOverrides={userOverrides} profileTaskRefreshKey={profileTaskRefreshKey} + profileGenerationQueueStatus={ + profileStatsOptions.profileGenerationQueueStatus + } profilePlayStats={profileStatsOptions.profilePlayStats} isProfilePlayStatsOpen={profileStatsOptions.isProfilePlayStatsOpen} />, @@ -2596,7 +2603,11 @@ test('profile daily task shortcut reflects task progress and claim updates', asy await user.click(screen.getByRole('button', { name: /每日任务/u })); const taskTitle = await screen.findByText('每日登录'); - const taskPanel = taskTitle.closest('.platform-subpanel') as HTMLElement; + const taskPanel = screen + .getByRole('button', { name: '领取' }) + .closest('.rounded-\\[1rem\\]') as HTMLElement; + expect(taskTitle).toBeTruthy(); + expect(taskPanel).toBeTruthy(); expect(taskPanel.className).toContain('rounded-[1rem]'); expect(taskPanel.className).toContain('p-4'); expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1); @@ -2892,6 +2903,46 @@ test('profile stats cards are centered without update timestamp', async () => { await screen.findByText('1 / 1'); }); +test('profile page shows external generation queue status', async () => { + renderProfileView( + vi.fn(), + {}, + {}, + 0, + { + profileGenerationQueueStatus: { + currentStatus: 'queued', + currentProgress: 18, + pendingCount: 6, + runningCount: 2, + }, + }, + ); + + await screen.findByText('1 / 1'); + const queueRegion = screen.getByRole('region', { name: '生成队列' }); + expect(queueRegion.className).toContain( + 'platform-profile-generation-queue-card', + ); + expect(within(queueRegion).getByText('排队中')).toBeTruthy(); + expect(within(queueRegion).getByText('排队中 18%')).toBeTruthy(); + expect(within(queueRegion).getByText('排队')).toBeTruthy(); + expect(within(queueRegion).getByText('6')).toBeTruthy(); + expect(within(queueRegion).getByText('生成')).toBeTruthy(); + expect(within(queueRegion).getByText('2')).toBeTruthy(); + const progressbar = within(queueRegion).getByRole('progressbar', { + name: '生成队列进度', + }); + expect(progressbar.getAttribute('aria-valuenow')).toBe('18'); +}); + +test('profile page hides external generation queue card without queue state', async () => { + renderProfileView(); + + await screen.findByText('1 / 1'); + expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull(); +}); + test('mobile profile page matches the reference layout sections', async () => { mockNarrowMobileLayout(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b480ca09..cd7aaba0 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -126,6 +126,7 @@ import { ProfileStatCardSkeleton, } from '../platform-entry/PlatformProfilePrimitives'; import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell'; +import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard'; import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal'; import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal'; import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal'; @@ -133,6 +134,7 @@ import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileR import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal'; import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal'; import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal'; +import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { type RechargePaymentResult, @@ -276,6 +278,7 @@ export interface RpgEntryHomeViewProps { onOpenProjects?: () => void; onRechargeSuccess?: () => void | Promise; profileTaskRefreshKey?: number; + profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null; createTabContent?: ReactNode; draftTabContent?: ReactNode; hasUnreadDraftUpdate?: boolean; @@ -2571,6 +2574,7 @@ export function RpgEntryHomeView({ onOpenProjects, onRechargeSuccess, profileTaskRefreshKey = 0, + profileGenerationQueueStatus = null, createTabContent, draftTabContent, hasUnreadDraftUpdate = false, @@ -3424,6 +3428,8 @@ export function RpgEntryHomeView({ const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragCommitDirection, setRecommendDragCommitDirection] = useState(null); + const [isRecommendDragResetting, setIsRecommendDragResetting] = + useState(false); const activeRecommendEntryKeyForSelection = recommendFeedWindow.activeEntryKey; const recommendCardStageRef = useRef(null); @@ -3438,6 +3444,7 @@ export function RpgEntryHomeView({ return; } + setIsRecommendDragResetting(false); setRecommendDragCommitDirection(direction); const panelHeight = recommendCardStageRef.current?.getBoundingClientRect().height ?? 0; @@ -3454,8 +3461,12 @@ export function RpgEntryHomeView({ } else { onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection); } + setIsRecommendDragResetting(true); setRecommendDragOffsetY(0); setRecommendDragCommitDirection(null); + window.requestAnimationFrame(() => { + setIsRecommendDragResetting(false); + }); }, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS); }, [ @@ -3517,6 +3528,7 @@ export function RpgEntryHomeView({ const deltaY = event.clientY - drag.startY; const commitDirection = resolveRecommendDragCommitDirection(deltaY); if (!commitDirection) { + setIsRecommendDragResetting(false); setRecommendDragOffsetY(0); return; } @@ -3532,6 +3544,7 @@ export function RpgEntryHomeView({ event.currentTarget.releasePointerCapture?.(drag.pointerId); } recommendDragStartRef.current = null; + setIsRecommendDragResetting(false); setRecommendDragOffsetY(0); }, [], @@ -3542,6 +3555,7 @@ export function RpgEntryHomeView({ const recommendRailClassName = buildRecommendSwipeRailClassName({ offsetY: recommendDragOffsetY, commitDirection: recommendDragCommitDirection, + isResetting: isRecommendDragResetting, }); const selectNextRecommendEntry = useCallback(() => { if ( @@ -4338,6 +4352,10 @@ export function RpgEntryHomeView({ /> + +
{ expect( buildRecommendSwipeRailClassName({ offsetY: -320, commitDirection: 1 }), ).toBe('platform-recommend-swipe-rail--committing'); + expect( + buildRecommendSwipeRailClassName({ + offsetY: 0, + commitDirection: null, + isResetting: true, + }), + ).toBe('platform-recommend-swipe-rail--resetting'); expect( shouldAnimateRecommendSwipe({ diff --git a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts index f0db81cc..a51a2c4f 100644 --- a/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts +++ b/src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts @@ -9,6 +9,7 @@ export type RecommendSwipeDirection = 1 | -1; export type RecommendSwipeRailState = { offsetY: number; commitDirection: RecommendSwipeDirection | null; + isResetting?: boolean; }; /** 收口推荐卡纵向滑动的纯判定,页面只保留 pointer 与动画副作用。 */ @@ -47,6 +48,10 @@ export function resolveRecommendCommitOffset( export function buildRecommendSwipeRailClassName( state: RecommendSwipeRailState, ) { + if (state.isResetting) { + return 'platform-recommend-swipe-rail--resetting'; + } + if (state.commitDirection) { return 'platform-recommend-swipe-rail--committing'; } diff --git a/src/components/unified-creation/UnifiedGenerationPage.test.tsx b/src/components/unified-creation/UnifiedGenerationPage.test.tsx index c41c656d..467f9c1c 100644 --- a/src/components/unified-creation/UnifiedGenerationPage.test.tsx +++ b/src/components/unified-creation/UnifiedGenerationPage.test.tsx @@ -71,27 +71,21 @@ describe('UnifiedGenerationPage', () => { expect(screen.queryByText('云端糖果塔')).toBeNull(); }); - test('显示外部生成队列状态', () => { + test('生成页不再显示外部生成队列状态', () => { render( {}} onEditSetting={() => {}} onRetry={() => {}} />, ); - expect(screen.getByText('排队中 18%')).toBeTruthy(); - expect(screen.getByText('排队 6')).toBeTruthy(); - expect(screen.getByText('生成 2')).toBeTruthy(); + expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull(); + expect(screen.queryByText('排队中 18%')).toBeNull(); + expect(screen.queryByText('排队 6')).toBeNull(); }); }); diff --git a/src/components/unified-creation/UnifiedGenerationPage.tsx b/src/components/unified-creation/UnifiedGenerationPage.tsx index 04ddf4f5..b943f549 100644 --- a/src/components/unified-creation/UnifiedGenerationPage.tsx +++ b/src/components/unified-creation/UnifiedGenerationPage.tsx @@ -1,9 +1,6 @@ import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress'; -import { - CustomWorldGenerationView, - type ExternalGenerationQueueStatus, -} from '../CustomWorldGenerationView'; +import { CustomWorldGenerationView } from '../CustomWorldGenerationView'; import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy'; import { getUnifiedGenerationCopy } from './unifiedGenerationCopy'; @@ -18,7 +15,6 @@ type UnifiedGenerationPageProps = { onEditSetting: () => void; onRetry: () => void; hideBatchModule?: boolean; - queueStatus?: ExternalGenerationQueueStatus | null; }; export function UnifiedGenerationPage({ @@ -32,7 +28,6 @@ export function UnifiedGenerationPage({ onEditSetting, onRetry, hideBatchModule = false, - queueStatus = null, }: UnifiedGenerationPageProps) { const copy = getUnifiedGenerationCopy(playId); @@ -56,7 +51,6 @@ export function UnifiedGenerationPage({ pausedBadgeLabel="素材生成已暂停" idleBadgeLabel="等待返回工作区" hideBatchModule={hideBatchModule} - queueStatus={queueStatus} /> ); } diff --git a/src/editor/shared/jsonClient.test.ts b/src/editor/shared/jsonClient.test.ts index 492c924f..43221d04 100644 --- a/src/editor/shared/jsonClient.test.ts +++ b/src/editor/shared/jsonClient.test.ts @@ -18,7 +18,7 @@ describe('parseApiErrorMessage', () => { }, }, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), 'Fallback failure', diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index 9c47d171..27147c8d 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -37,8 +37,8 @@ describe('useResolvedAssetReadUrl', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -86,8 +86,8 @@ describe('useResolvedAssetReadUrl', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -131,8 +131,8 @@ describe('useResolvedAssetReadUrl', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -180,8 +180,8 @@ describe('useResolvedAssetReadUrl', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -235,8 +235,8 @@ describe('useResolvedAssetReadUrl', () => { message: '对象不存在', }, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -274,8 +274,8 @@ describe('useResolvedAssetReadUrl', () => { message: '对象不存在', }, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, diff --git a/src/index.css b/src/index.css index 12106d6e..a1c515d3 100644 --- a/src/index.css +++ b/src/index.css @@ -9338,6 +9338,49 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { margin-bottom: -0.2rem; } +.platform-profile-generation-queue-card { + width: 100%; + min-height: 6.2rem; + padding: 0.9rem 0.95rem; + border: 1px solid rgba(235, 221, 208, 0.82); + border-radius: 1.55rem; + background: rgba(255, 250, 246, 0.92); + box-shadow: 0 10px 28px rgba(112, 57, 30, 0.06); +} + +.platform-profile-generation-queue-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + flex: none; + border-radius: 9999px; + background: rgba(255, 237, 222, 0.95); + color: #bf673b; +} + +.platform-profile-generation-queue-card__metric { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; + padding: 0.55rem 0.65rem; + border-radius: 0.9rem; + background: rgba(255, 244, 235, 0.82); + color: var(--platform-text-base); + font-size: 12px; + font-weight: 600; +} + +.platform-profile-generation-queue-card__metric strong { + color: var(--platform-text-strong); + font-size: 15px; + font-weight: 900; + line-height: 1; +} + .platform-profile-shortcut-panel { padding: 0.78rem 0.68rem 0.82rem; } @@ -9611,6 +9654,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { font-size: 12px; } + .platform-profile-generation-queue-card { + min-height: 5.75rem; + padding: 0.72rem 0.76rem; + border-radius: 1.12rem; + } + + .platform-profile-generation-queue-card__icon { + width: 1.72rem; + height: 1.72rem; + } + + .platform-profile-generation-queue-card__metric { + padding: 0.48rem 0.56rem; + border-radius: 0.78rem; + font-size: 11px; + } + + .platform-profile-generation-queue-card__metric strong { + font-size: 14px; + } + .platform-profile-shortcut-panel { padding: 0.64rem 0.54rem 0.68rem; } diff --git a/src/services/ai.test.ts b/src/services/ai.test.ts index 1a2a001a..78454070 100644 --- a/src/services/ai.test.ts +++ b/src/services/ai.test.ts @@ -423,7 +423,7 @@ function createApiEnvelopeResponse(data: unknown) { data, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), } as Response; @@ -1181,7 +1181,7 @@ describe('ai runtime client orchestration', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), } as Response); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index d57a6dac..d07505ac 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -88,7 +88,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -103,7 +103,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -164,7 +164,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -179,7 +179,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -409,7 +409,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -440,7 +440,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), }), @@ -502,7 +502,7 @@ describe('apiClient', () => { }, error: null, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', }, }), headers: { @@ -572,7 +572,7 @@ describe('apiClient', () => { }, }, meta: { - apiVersion: '2026-04-08', + apiVersion: '2026-06-16', requestId: 'req-body', }, }), diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index e83f5381..0dd11b1a 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -74,8 +74,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -114,8 +114,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -155,8 +155,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -198,8 +198,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -240,8 +240,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -285,8 +285,8 @@ describe('assetReadUrlService', () => { }, error: null, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -323,8 +323,8 @@ describe('assetReadUrlService', () => { message: '对象不存在', }, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', }, @@ -363,8 +363,8 @@ describe('assetReadUrlService', () => { message: '登录状态已失效', }, meta: { - apiVersion: '2026-04-08', - routeVersion: '2026-04-08', + apiVersion: '2026-06-16', + routeVersion: '2026-06-16', latencyMs: 1, timestamp: '2099-01-01T00:00:00Z', },