diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbde384 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env (git-ignored). Everything is optional; the app runs with none of it. +# `agentrylab run` and `agentrylab serve` both load .env automatically. + +# ---------------------------------------------------------------- lab (presets) +# OPENAI_API_KEY=sk-... # presets that use the OpenAI provider +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OLLAMA_BASE_URL=http://localhost:11434 +# APIFY_API_TOKEN=apify_... # marketplace_deals preset +# WOLFRAM_APP_ID=... # Wolfram Alpha tool + +# ---------------------------------------------------------------- room: public stage brain +# AGENTRYLAB_ROOM_PROVIDER=auto # auto | openai | ollama | mock (auto = openai if key set, else mock) +# AGENTRYLAB_ROOM_MODEL=gpt-4o-mini +# AGENTRYLAB_MOCK_LATENCY=1.1 # seconds of fake "thinking" in demo mode + +# ---------------------------------------------------------------- room: storage +# AGENTRYLAB_ROOM_OUTPUTS=outputs # base dir for the two below +# AGENTRYLAB_ROOM_TRANSCRIPTS=outputs/transcripts # room-.jsonl +# AGENTRYLAB_ROOM_DB=outputs/room.db # users, sessions, encrypted keys + +# ---------------------------------------------------------------- room: accounts & security +# AGENTRYLAB_ALLOW_SIGNUP=1 # 0 to close registration +# AGENTRYLAB_COOKIE_SECURE=1 # set when serving behind HTTPS / a TLS proxy +# AGENTRYLAB_SCRYPT_N=16384 # password-hash cost; 2^14 β‰ˆ 30 ms and 16 MiB + +# ---------------------------------------------------------------- room: UI +# AGENTRYLAB_WEB_DIST=web/dist # built frontend, auto-detected in a checkout diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e0652a..932da0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,26 @@ jobs: run: | pytest --cov=src/agentrylab --cov-branch --cov-report=term-missing -q + web: + name: Web UI (typecheck + build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Typecheck and build + run: npm run build + diff --git a/.gitignore b/.gitignore index 994d778..cbf2519 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ htmlcov/ # ----- Local environment ----- .env .env.* +!.env.example # ----- Project outputs ----- outputs/ @@ -62,4 +63,9 @@ uv.lock # ----- Local helper scripts (not for source control) ----- setup_github.sh setup_github_about.py -github_setup_instructions.md \ No newline at end of file +github_setup_instructions.md +# ----- Web UI ----- +web/node_modules/ +web/dist/ +src/agentrylab/room/static/ +web/tsconfig.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f66a08..a42c87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [0.3.0] - 2026-10-04 +### πŸ€– The Room: a zero-player android stage (web UI) +- **New `agentrylab serve`**: FastAPI server with REST + WebSocket API and a built-in web UI +- **Live rooms**: add/remove personas while the conversation runs; humans can join and are answered by name +- **12-persona library** plus a builder for custom personalities with selectable faces, bodies and colours +- **Procedural SVG androids** with mood-driven micro-animations (blink, bob, thinking pulse, talking mouths, gesturing arms) +- **Offline demo brain**: runs without any API key; OpenAI/Ollama picked up from the environment +- **New `web/` frontend**: Vite + React 19 + TypeScript + Tailwind v4 + Motion + Zustand, built in CI +- New optional extra `agentrylab[web]`; docs in `src/agentrylab/docs/ROOM.md` + +### πŸ” Accounts and bring-your-own-key vault +- **Email + password accounts** (scrypt, rate-limited login, HttpOnly session cookies) +- **Key vault** for OpenAI, Anthropic, DeepSeek and xAI: keys live in server memory only and are wiped on sign-out, "forget", or restart; opt-in "remember" stores them AES-256-GCM encrypted under a password-derived key +- **Private rooms** per user that run on the owner's keys; brain (provider + model) switchable per room; rooms pause with a clear message when a key is missing or failing +- New `AnthropicProvider` (official SDK) and OpenAI-compatible DeepSeek/xAI wiring + +### πŸ“š Documentation +- `ROOM.md` rewritten: architecture map, turn lifecycle, brains, threat model, full REST/WS reference, operating notes, roadmap +- `CLAUDE.md` for coding agents (commands, architecture, change checklists), `.env.example`, updated CLI/CONFIG/ARCHITECTURE guides, CONTRIBUTING repository map, SECURITY hardening checklist, OpenAPI summaries on every endpoint + ## [0.1.7] - 2025-01-27 ### 🎭 Complete Telegram API Implementation - **Full Telegram API**: Complete async implementation with event streaming diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..323cfa9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +AgentryLab is a Python package with two runtimes that share provider adapters: + +1. **The lab engine** (`agentrylab.runtime`, `lab.py`, `api.py`, `cli/`): YAML presets describe a fixed cast of LLM "nodes" and a schedule; `Engine.tick()` runs them with transcripts (JSONL) and checkpoints (SQLite). Driven by `agentrylab run …` or `agentrylab.init()/run()`. +2. **The Room** (`agentrylab.room`, `web/`): a live stage where android personas talk to each other and to humans in the browser. Personas join/leave while it runs, signed-in users bring their own API keys, and events stream over a WebSocket. Driven by `agentrylab serve`. + +Guiding rule from `.cursor/rules`: keep it simple and easily extendable. + +## First ten minutes + +```bash +pip install -e '.[dev]' && ruff check . && python -m pytest -q # expect ~140 passed +cd web && npm install && npm run build && cd .. # expect web/dist +agentrylab serve # open http://127.0.0.1:8000 +``` + +With no `OPENAI_API_KEY` the public stage runs on the offline demo brain, so the +UI is fully exercisable without any secrets. `/docs` shows the API. Sign up with +any email and a password of 8+ characters to try the vault and private rooms; a +made-up API key is fine for the UI flow (the room pauses after the provider +rejects it, which is the intended behaviour). + +## Commands + +```bash +pip install -e '.[dev]' # Python deps incl. web server, test and lint tooling +ruff check . # lint (CI runs this; only E rules, line length ignored) +python -m pytest -q # full suite (~140 tests, no network, ~10 s) +python -m pytest -q tests/test_room.py # one file +python -m pytest -q tests/test_auth.py -k remembered # one test by keyword +python -m pytest --cov=src/agentrylab --cov-branch --cov-report=term-missing -q # what CI runs + +cd web && npm install && npm run build # frontend β†’ web/dist (tsc + vite); CI runs this too +cd web && npm run dev # HMR on :5173, proxies /api and /ws to :8000 +cd web && npm run typecheck # tsc only + +agentrylab serve [--reload] # Room on :8000; serves web/dist if built +agentrylab run standup_club.yaml --objective "topic" --max-iters 4 # lab engine +make web-bundle # copy web/dist into the package for wheels +``` + +Tests are plain `pytest` with `asyncio_mode = "auto"`: write `async def test_…` for Room loop tests, no decorator needed. `tests/test_room.py` and `tests/test_auth.py` set `AGENTRYLAB_SCRYPT_N=4096` at import so password hashing is fast; keep that line if you add a test module that imports `agentrylab.room.auth`. + +## Architecture: lab engine + +- `config/loader.py` validates YAML into Pydantic models (`Preset`, `Agent`, `Provider`, …). Providers, tools and schedulers are referenced by fully-qualified class path in the YAML and imported dynamically in `lab.py` (`_build_providers`, `_build_tools`, `_build_scheduler`); nothing is hardcoded. +- `runtime/nodes/` are role classes (`AgentNode`, `ModeratorNode`, `SummarizerNode`, `AdvisorNode`, `UserNode`) built by `nodes/factory.py` from the YAML `role`. `NodeBase.__call__` = build messages β†’ `provider.chat` β†’ postprocess β†’ validate. `AgentNode` adds a JSON tool-call loop (`{"tool": id, "args": {...}}` in the model output). +- `runtime/state.py` (`State`) composes the message window for a node and tracks tool budgets; `runtime/engine.py` asks the scheduler which nodes run this tick, applies outputs, honours moderator actions (`STOP`, `STEP_BACK` rollback), and persists via `persistence/store.py`. +- `runtime/providers/base.py` (`LLMProvider`) owns retries/backoff and normalises every vendor response to `{"content", "metadata", "raw"}`; subclasses only implement `_send_chat`. `OpenAIProvider` also serves any OpenAI-compatible API via `base_url` + `vendor`; `AnthropicProvider` uses the official SDK (optional dependency, imported lazily). +- `telegram/adapter.py` is an older async wrapper around `Lab` for chat-bot integrations; it is independent of the Room. + +## Architecture: the Room + +Read `src/agentrylab/docs/ROOM.md` first; it has the diagram, turn lifecycle, API and threat model. The parts that take several files to see: + +- **One asyncio task per room** (`room/room.py`, `Room._loop`). All mutation (add/remove persona, post message, pause, brain change) happens on the event loop; only `provider.chat` runs in `asyncio.to_thread`. Consequently **every FastAPI handler in `room/server.py` must be `async def`**: a sync handler runs in a threadpool and `Room` methods then touch asyncio objects from the wrong thread (`RuntimeError: no running event loop`). +- **Brains and keys.** A room has `Brain(provider, model)`. `RoomManager` is constructed with a `provider_factory(persona, room)`; the real one in `server.create_app` looks the owner's key up in the in-memory `KeyVault` and calls `providers.build_user_provider`. Providers are cached per persona in `Room._providers` and must be dropped (`invalidate_providers`, `RoomManager.invalidate_user`) whenever a key or brain changes. A missing key raises `BrainUnavailable` β†’ the room pauses with a system message; three other failures in a row also pause it. +- **Secrets never leave memory.** `room/auth.py`: passwords scrypt-hashed, sessions stored hashed, keys held per user in `KeyVault`; "remembered" keys are AES-GCM encrypted under a scrypt key derived from the password (so a restart locks the vault until `/api/auth/unlock`). Endpoints return only `key_hint(...)`; never add a code path that logs, persists or returns a plaintext key, and never include provider error bodies verbatim (see `_error_text` in the OpenAI adapter). +- **Access control** is in `server._room` / `_owned_room` / the WebSocket handler: public `main` room for everyone, private rooms only for `owner_id`. Room creation needs a session. +- **Mock brain.** `providers.MockProvider` parses the system prompt the room composes (`You are speaking as …`, `Topic: …`, `Voice: …` lines) to improvise in-character replies offline. Changing the prompt format in `Room._compose` breaks it; tests cover both. +- **Frontend state** lives entirely in `web/src/store.ts` (zustand + the WebSocket client). Components never fetch; they call store actions, and the server echoes results back as socket events, so there is no optimistic state. Each android's `mood` (`idle | thinking | talking`) is derived in `Stage.tsx` from `thinking`/`speaking`, and every animation in `web/src/avatars/` is a function of that prop. + +## Testing recipes + +- **Room logic**: build `Room(..., provider_factory=lambda persona, room: FakeProvider())`, `room.start()` inside an `async def` test, drive it with `add_from_library` / `post_user_message` / `pause` / `step`, and poll `room.messages` with a short `wait_for` loop; always `await room.close()` in `finally`. A fake is a `MockProvider` subclass overriding `_send_chat` (see `EchoProvider` in `tests/test_room.py`). +- **HTTP/WebSocket**: `create_app(manager=RoomManager(provider_factory=..., transcript_dir=tmp_path, seed_default=False), serve_ui=False, auth_store=AuthStore(tmp_path / "auth.db"), vault=KeyVault())` with `fastapi.testclient.TestClient`; the client keeps the session cookie, so register once and then call the authed endpoints. `client.websocket_connect("/ws/rooms/main")` yields events in order after the snapshot. +- **Auth/crypto**: unit-test `AuthStore` and the `encrypt_key`/`decrypt_key`/`derive_kek` helpers directly; `tests/test_auth.py` is the reference. +- **Frontend**: there are no automated UI tests. Verify changes with `npm run typecheck` and, for visuals, a headless Playwright script against a running server (see `web/README.md` β†’ Smoke-testing). + +## Known gaps (deliberate, documented) + +- Rooms, casts and the key vault are in-process memory: a restart empties the stage (transcripts and accounts persist). Run one uvicorn worker. +- No frontend test suite; no OAuth; model names for DeepSeek/xAI are best guesses and user-editable. +- The roadmap (browser-held keys, spend meter, persistent rooms) is in `src/agentrylab/docs/ROOM.md` β†’ Roadmap. + +## Change checklists (things that live in two places) + +| If you change… | Also update… | +|---|---| +| a face/body/palette id | both `room/avatars.py` and `web/src/avatars/{faces,bodies}.tsx` / `palettes.ts` | +| a WebSocket event or REST shape | `web/src/types.ts`, `store.applyEvent`, and the tables in `docs/ROOM.md` | +| an environment variable | `.env.example`, `docs/ROOM.md` β†’ Environment, README table | +| the version | `pyproject.toml` **and** `src/agentrylab/version.py`, plus `CHANGELOG.md` | +| a provider or default model | `room/providers.py` catalog (`KEY_PROVIDERS`/`FREE_PROVIDERS`), `build_user_provider`, `docs/ROOM.md` β†’ Brains | +| the room's system prompt | `MockProvider._send_chat` parsing and `tests/test_room.py` | +| an endpoint | keep `tags=`/`summary=` on the decorator (OpenAPI), and the REST table in `docs/ROOM.md` | + +## Frontend gotchas + +- Motion treats `x`/`y` on SVG elements as CSS translate, not the attribute: set the attribute for position and animate `x: [0, 26, 0]` relative to it. Motion cannot interpolate path `d`; crossfade several paths or scale instead. +- Put `className="tb"` on any SVG group you scale/rotate so it transforms around its own box (`transform-box: fill-box` in `index.css`). +- `@types/react` 19 removed the global `JSX` namespace; use `ReactElement`/`ReactNode` from `react`. +- Google Fonts are linked in `index.html`; the UI falls back to system fonts offline. + +## Docs map + +`README.md` (overview) β†’ `src/agentrylab/docs/README.md` (index) β†’ `ROOM.md` (web runtime), `CLI.md`, `CONFIG.md` (preset YAML), `ARCHITECTURE.md`, `PERSISTENCE.md`; `web/README.md` (frontend); `SECURITY.md` (secrets model, operator checklist); `CONTRIBUTING.md` (repo map, recipes for new personas/parts/providers/presets). Keep `ROOM.md` authoritative for anything in the Room. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 804e778..695cbdb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,12 @@ -Contributing to Agentry Lab +# Contributing to Agentry Lab -Thanks for your interest in improving Agentry Lab! PRs are welcome β€” from quick bug fixes and docs edits to new features. +Thanks for your interest in improving Agentry Lab! PRs are welcome, from quick bug fixes and docs edits to new presets, personas, providers and UI polish. + +## Quick start (Python) -Quick Start - Fork the repo and create a feature branch - Python 3.11/3.12 recommended -- Create a venv and install dev deps: +- Create a venv and install dev deps (includes the web server and test tooling): - `python -m venv .venv && . .venv/bin/activate` - `python -m pip install -U pip` - `python -m pip install -e '.[dev]'` @@ -13,29 +14,68 @@ Quick Start - `ruff check .` - `pytest -q` -Project Philosophy -- Lightweight and readable β€” prefer clarity over cleverness -- Minimal ceremony β€” keep APIs small, docs helpful, examples runnable -- Solid tests β€” prioritize deterministic tests without network access +## Quick start (web UI) + +The Room's frontend lives in `web/` (Vite + React + TypeScript + Tailwind + Motion). + +- `cd web && npm install` +- `npm run dev` for hot reload on http://localhost:5173 (proxies `/api` and `/ws` to a server started with `agentrylab serve --reload`) +- `npm run build` writes `web/dist`, which `agentrylab serve` picks up automatically +- `npm run typecheck` is what CI runs (plus the build) + +Stack, folder layout and the animation model are described in [`web/README.md`](web/README.md). + +## Repository map + +| Path | What lives there | +|---|---| +| `src/agentrylab/lab.py`, `api.py` | Python API (`init`, `run`, `Lab`) | +| `src/agentrylab/runtime/` | Engine, nodes, schedulers, providers, tools for YAML presets | +| `src/agentrylab/presets/` | Packaged YAML presets | +| `src/agentrylab/room/` | The Room: live rooms, personas, accounts, key vault, FastAPI server | +| `src/agentrylab/cli/` | Typer CLI (`run`, `say`, `status`, `serve`, …) | +| `src/agentrylab/docs/` | User documentation ([index](src/agentrylab/docs/README.md)) | +| `web/` | Frontend | +| `tests/` | Pytest suite; `test_room.py` and `test_auth.py` cover the web runtime without network | +| `CLAUDE.md` | Orientation for AI coding agents: commands, architecture, cross-file change checklists | + +## Common contributions + +- **A new persona**: add an entry to `PERSONA_LIBRARY` in `src/agentrylab/room/personas.py` (name, tagline, personality prompt, avatar parts, noun-like `voice` words for the demo brain). Tests check ids are unique and fields are filled. +- **A new avatar part**: add the id in `src/agentrylab/room/avatars.py`, draw it in `web/src/avatars/faces.tsx` or `bodies.tsx` honouring the `mood` prop, and register it in the `FACES`/`BODIES` map. +- **A new provider**: subclass `LLMProvider` in `src/agentrylab/runtime/providers/`, implement `_send_chat`, and (for the Room) add a catalog entry in `src/agentrylab/room/providers.py` plus a branch in `build_user_provider`. +- **A new preset**: drop a YAML file into `src/agentrylab/presets/`; see [CONFIG.md](src/agentrylab/docs/CONFIG.md). + +## Project philosophy + +- Lightweight and readable: prefer clarity over cleverness +- Minimal ceremony: keep APIs small, docs helpful, examples runnable +- Solid tests: prioritise deterministic tests without network access (the Room tests inject fake providers; never call a real API in tests) +- Secrets stay secret: never log API keys, never return them from an endpoint, never write them to transcripts (see [SECURITY.md](SECURITY.md)) + +## Before you open a PR -Before You Open a PR - Add or update tests to cover your change -- Keep changes focused β€” smaller PRs are easier to review -- Update README / docs when changing public APIs or behavior +- Keep changes focused; smaller PRs are easier to review +- Update README / docs when changing public APIs, endpoints, events or behaviour (`src/agentrylab/docs/ROOM.md` for anything in the Room) +- Run `ruff check .`, `pytest -q`, and `cd web && npm run build` if you touched the UI + +## Commit style + +Conventional and descriptive is great: +- `fix: correct tool budget counter update` +- `feat: add Lab.clean() API` +- `docs: expand Python API examples` + +## Running examples -Commit Style -- Conventional and descriptive is great: - - `fix: correct tool budget counter update` - - `feat: add Lab.clean() API` - - `docs: expand Python API examples` +Some presets and the real brains need API keys. Put them in a local `.env` (see [`.env.example`](.env.example)); the file is git-ignored. -Running Examples -- Some examples and presets may require API keys. Put secrets in a local `.env` (see README) +## Release flow (maintainers) -Release Flow (maintainers) -- We release via GitHub Actions on tag push (see `.github/workflows/release.yml`) -- Bump version in `pyproject.toml`, update `CHANGELOG.md`, then tag: `git tag -a vX.Y.Z -m 'vX.Y.Z' && git push --tags` +- Releases run via GitHub Actions on tag push (see `.github/workflows/release.yml`) +- Bump the version in `pyproject.toml` **and** `src/agentrylab/version.py`, update `CHANGELOG.md`, build the UI into the package with `make web-bundle` if the wheel should ship it, then tag: `git tag -a vX.Y.Z -m 'vX.Y.Z' && git push --tags` -Questions? -- Open a discussion or issue on GitHub +## Questions? +Open a discussion or issue on GitHub. diff --git a/Makefile b/Makefile index 613d0d6..9cf3e9a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test coverage lint +.PHONY: test coverage lint web web-dev web-bundle serve test: .venv/bin/pytest -q @@ -9,3 +9,16 @@ coverage: lint: .venv/bin/ruff check . +# ---- Room web UI ---- +web: ## build the frontend into web/dist + cd web && npm install && npm run build + +web-dev: ## frontend dev server with hot reload (expects API on :8000) + cd web && npm run dev + +web-bundle: web ## copy the build into the Python package so wheels ship the UI + rm -rf src/agentrylab/room/static + cp -r web/dist src/agentrylab/room/static + +serve: ## run the Room server + .venv/bin/agentrylab serve diff --git a/README.md b/README.md index 9304afc..641b8b6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,36 @@ agentrylab run debates.yaml --objective "Should we colonize Mars?" --max-iters 4 agentrylab run marketplace_deals.yaml --objective "MacBook Pro deals" ``` +## πŸ€– The Room (web UI) + +A zero-player game: drop android personas onto a stage, watch them talk, and jump in whenever you like. + +![The Room](docs/room.png) + +```bash +pip install 'agentrylab[web]' +agentrylab serve # β†’ http://127.0.0.1:8000 +``` + +- **No API key? No problem.** Without `OPENAI_API_KEY` the personas run on an offline *demo brain* so the stage is alive immediately. Set a key (or `AGENTRYLAB_ROOM_PROVIDER=ollama`) for real conversations. +- **12 ready-made personalities** (comedian, philosopher, skeptic, noir detective, grandma, benevolent overlord…) or build your own: name, personality prompt, face, body, colour. +- **Micro-animations**: blinking, idle bob, a "thinking" pulse while the model runs, equaliser mouths and gesturing arms while talking. +- **Join the loop**: type a message; mention an android by name and it answers you next. +- **Play / pause / step / speed**, change the topic mid-show, remove anyone with one click. +- **Bring your own brain.** Sign in (email + password) and add your OpenAI, Anthropic, DeepSeek or xAI key to a vault that lives in server memory only; create private rooms that run on it. Opt in to "remember" and the key is stored encrypted under your password, so even the operator can't read it. Details and threat model in [ROOM.md](src/agentrylab/docs/ROOM.md#accounts--keys-bring-your-own-model). + +| Variable | Meaning | Default | +|----------|---------|---------| +| `AGENTRYLAB_ROOM_PROVIDER` | public-stage brain: `auto` Β· `openai` Β· `ollama` Β· `mock` | `auto` (OpenAI if a key is set, else mock) | +| `AGENTRYLAB_ROOM_MODEL` | model name for the public-stage brain | `gpt-4o-mini` / `llama3` | +| `AGENTRYLAB_ROOM_DB` | accounts, sessions and encrypted keys (SQLite) | `outputs/room.db` | +| `AGENTRYLAB_ROOM_TRANSCRIPTS` | where room transcripts (JSONL) go | `outputs/transcripts` | +| `AGENTRYLAB_ALLOW_SIGNUP` / `AGENTRYLAB_COOKIE_SECURE` | close registration / mark cookies `Secure` behind HTTPS | `1` / auto | + +All variables, with comments, are in [`.env.example`](.env.example). The complete guide (architecture, how a turn works, accounts and the key threat model, REST + WebSocket API, operating notes, roadmap) is [ROOM.md](src/agentrylab/docs/ROOM.md). + +The UI lives in [`web/`](web/) (Vite + React + TypeScript + Tailwind + Motion); `agentrylab serve` serves the built bundle. See [web/README.md](web/README.md) for development. + ## 🎭 What You Get **5 killer presets** that actually work: @@ -40,7 +70,7 @@ agentrylab run marketplace_deals.yaml --objective "MacBook Pro deals" - **Agents**: Roles that speak (comedian, scientist, debater β€” no limits!) - **Tools**: Real integrations (DuckDuckGo search, Facebook Marketplace, Wolfram Alpha) -- **Providers**: LLM backends (OpenAI, Ollama) +- **Providers**: LLM backends (OpenAI, Anthropic, Ollama, and OpenAI-compatible APIs such as DeepSeek and xAI) - **Schedulers**: Who talks when (round-robin, every-N) ## πŸ› οΈ Installation & Setup @@ -121,10 +151,13 @@ for msg in lab.state.history: ## πŸ“š Documentation +- [The Room / Web API](src/agentrylab/docs/ROOM.md) - Web UI, REST + WebSocket API - [CLI Reference](src/agentrylab/docs/CLI.md) - All commands - [Configuration](src/agentrylab/docs/CONFIG.md) - YAML preset format - [Architecture](src/agentrylab/docs/ARCHITECTURE.md) - How it works - [Persistence](src/agentrylab/docs/PERSISTENCE.md) - Data storage format +- [Web UI development](web/README.md) - Frontend stack and layout +- [Security](SECURITY.md) - How user API keys are protected, operator checklist ## 🀝 Contributing @@ -133,7 +166,8 @@ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. **Quick wins:** - New presets (comedy, debates, research, etc.) - New tools (APIs, databases, etc.) -- New providers (Claude, Gemini, etc.) +- New personas and avatar parts for the Room +- New providers (Gemini, Mistral, etc.) ## πŸ“„ License diff --git a/SECURITY.md b/SECURITY.md index bc54240..0eeaa58 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,42 @@ -Security Policy +# Security Policy + +## Supported versions -Supported Versions - Active development happens on the default branch. - We test on Python 3.11 and 3.12 in CI. -Reporting a Vulnerability +## Reporting a vulnerability + - Please open a GitHub Security Advisory or email the maintainer. - Include reproduction steps and environment details where possible. - We will acknowledge receipt within 72 hours and work on a fix or mitigation plan. +## How the Room handles secrets + +The web app (`agentrylab serve`) lets users bring their own model API keys. The +full threat model lives in +[`src/agentrylab/docs/ROOM.md` β†’ Accounts & keys](src/agentrylab/docs/ROOM.md#accounts--keys-bring-your-own-model); +the short version: + +- **Keys live in server memory only** by default, per signed-in user, and are + wiped on sign-out, "forget", or process restart. They are never logged, never + returned by any endpoint (only a `…1234` hint), and never written to transcripts. +- **"Remember" is opt-in per key** and stores the key AES-256-GCM encrypted under + a key derived from the user's password (scrypt). The password is not stored, so + the database alone cannot be decrypted, including by the operator. +- **Passwords** are scrypt-hashed; login is rate-limited and uses constant-time + comparison. **Sessions** are random tokens stored hashed, in an + `HttpOnly; SameSite=Lax` cookie (`Secure` behind HTTPS). +- **Known limitation:** a compromised live server process can read keys from + memory while a user is signed in. Browser-held keys are on the roadmap to remove + that trust requirement. + +## Hardening checklist for operators + +- Serve behind HTTPS and set `AGENTRYLAB_COOKIE_SECURE=1`. +- Set `AGENTRYLAB_ALLOW_SIGNUP=0` once your users have registered. +- Run a single uvicorn worker (rooms and the vault are per-process). +- Back up `outputs/room.db` (accounts, encrypted keys); it contains no plaintext secrets. +- Keep `OPENAI_API_KEY` out of the environment of a public deployment unless you + want anonymous visitors to spend it on the public stage; the demo brain is free. +- Ask users to create provider keys with a spending cap. diff --git a/docs/architecture_roadmap.md b/docs/architecture_roadmap.md index b4e2f5f..0a1ae4c 100644 --- a/docs/architecture_roadmap.md +++ b/docs/architecture_roadmap.md @@ -119,4 +119,11 @@ so we can tackle the initiatives incrementally. - **Risks:** Without migration notes, teams might miss required cleanup steps; ensure communication accompanies code changes. - +## 9. The Room (web app) + +The web runtime has its own roadmap, kept next to its documentation so it stays +in sync with the API: see +[`src/agentrylab/docs/ROOM.md` β†’ Roadmap](../src/agentrylab/docs/ROOM.md#roadmap). +Headline items: OAuth sign-in, browser-held API keys (never touching the +server), a per-room spend meter, hardened server-side secrets, persistent +rooms. diff --git a/docs/room.png b/docs/room.png new file mode 100644 index 0000000..6c6e5db Binary files /dev/null and b/docs/room.png differ diff --git a/pyproject.toml b/pyproject.toml index ef8b73f..3de90b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "agentrylab" -version = "0.2.1" -description = "Lightweight, hackable multi-agent orchestration lab (CLI + Python) with transcripts, checkpoints, budgets, and pluggable providers/tools." +version = "0.3.0" +description = "Lightweight, hackable multi-agent orchestration lab (CLI + Python + web) with a live stage of animated android personas, transcripts, checkpoints, budgets, and pluggable providers/tools." readme = "README.md" requires-python = ">=3.11" authors = [{ name = "Alexey Kislitsin" }] @@ -15,7 +15,8 @@ keywords = [ "multi-agent", "llm", "ai-agents", "agent-orchestration", "python", "yaml-config", "cli-tool", "research", "experimentation", "ai-development", "openai", "ollama", "llama3", "transcripts", "checkpoints", - "streaming", "hackable", "lightweight", "lab", "workflow-engine" + "streaming", "hackable", "lightweight", "lab", "workflow-engine", + "web-ui", "fastapi", "websocket", "anthropic", "claude", "bring-your-own-key" ] classifiers = [ "Development Status :: 3 - Alpha", @@ -48,7 +49,19 @@ Documentation = "https://github.com/Alexeyisme/agentrylab/tree/main/src/agentryl Issues = "https://github.com/Alexeyisme/agentrylab/issues" [project.optional-dependencies] +web = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "websockets>=12", + "cryptography>=42", + "anthropic>=1.0", +] dev = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "websockets>=12", + "cryptography>=42", + "anthropic>=1.0", "pytest>=8.3.2", "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", @@ -72,6 +85,7 @@ include = ["agentrylab*"] [tool.setuptools.package-data] "agentrylab.presets" = ["*.yaml"] +"agentrylab.room" = ["static/**/*"] [tool.pytest.ini_options] minversion = "8.0" diff --git a/src/agentrylab/cli/app.py b/src/agentrylab/cli/app.py index 2fcddfa..f1898c4 100644 --- a/src/agentrylab/cli/app.py +++ b/src/agentrylab/cli/app.py @@ -615,6 +615,22 @@ def say_cmd( typer.echo(f"Appended user message to thread '{thread_id}' as {user_id}.") +@app.command("serve") +def serve_cmd( + host: str = typer.Option("127.0.0.1", help="Bind address"), + port: int = typer.Option(8000, help="Port"), + reload: bool = typer.Option(False, "--reload", help="Auto-reload on code changes (dev)"), +) -> None: + """Start the Room web app: android personas chatting on a live stage.""" + _load_env() + try: + from agentrylab.room.server import serve + except ImportError as e: # pragma: no cover - depends on optional extra + typer.echo(f"The web server needs the 'web' extra: pip install 'agentrylab[web]' ({e})", err=True) + raise typer.Exit(code=1) + serve(host, port, reload=reload) + + def main() -> None: # pragma: no cover app() diff --git a/src/agentrylab/docs/ARCHITECTURE.md b/src/agentrylab/docs/ARCHITECTURE.md index 3be6d93..ce8afde 100644 --- a/src/agentrylab/docs/ARCHITECTURE.md +++ b/src/agentrylab/docs/ARCHITECTURE.md @@ -35,7 +35,8 @@ The execution engine: **Providers** - LLM adapters ```python -OpenAIProvider(model="gpt-4o-mini", api_key="...") +OpenAIProvider(model="gpt-4o-mini", api_key="...") # also DeepSeek/xAI via base_url + vendor +AnthropicProvider(model="claude-opus-5-5", api_key="...") # official SDK OllamaProvider(model="llama3", base_url="localhost:11434") ``` @@ -134,6 +135,32 @@ class MyScheduler(Scheduler): return nodes_to_run ``` +## πŸ€– The Room (web runtime) + +The Room is a second, *live* orchestration layer that reuses the providers but +not the Engine: there is no preset and no fixed schedule, because the cast +changes while the conversation runs. + +``` +browser ──REST/WS──▢ server.py ──▢ RoomManager ──▢ Room (per room) + β”‚ β”œβ”€ personas (live cast) + β”œβ”€ AuthStore (SQLite) β”œβ”€ messages (transcript) + └─ KeyVault (memory) β”œβ”€ asyncio loop: pick β†’ compose β†’ provider.chat (thread) β†’ clean β†’ emit + └─ brain {provider, model} β†’ providers.build_user_provider(key from vault) +``` + +- **Room**: owns the cast, the transcript, and an asyncio task that takes one + turn at a time. Mutations (add/remove persona, post message, pause, brain + change) happen on the event loop; only the provider call runs in a thread. +- **RoomManager**: registry with ownership (`owner_id`), the public `main` + room, per-user limits, and the provider factory that resolves keys. +- **Events**: every change is broadcast to subscribers (WebSocket clients) as a + small JSON event; the UI derives each android's *mood* from them. +- **Persistence**: transcripts go to the same JSONL store as the CLI; accounts, + sessions and encrypted keys live in a separate SQLite file. + +Full details, API and security model: [ROOM.md](ROOM.md). + ## 🎯 Design Principles - **Simple**: Readable code, clear abstractions diff --git a/src/agentrylab/docs/CLI.md b/src/agentrylab/docs/CLI.md index a84758e..6736a31 100644 --- a/src/agentrylab/docs/CLI.md +++ b/src/agentrylab/docs/CLI.md @@ -17,8 +17,31 @@ agentrylab ls # Clean up agentrylab reset + +# Start the web app (the Room) +agentrylab serve [--host 127.0.0.1] [--port 8000] [--reload] +``` + +## πŸ€– `serve` β€” the Room web app + +```bash +pip install 'agentrylab[web]' +agentrylab serve # http://127.0.0.1:8000 +agentrylab serve --host 0.0.0.0 --port 9000 +agentrylab serve --reload # dev: restart on code changes ``` +| Option | What It Does | +|--------|-------------| +| `--host` | Bind address (default `127.0.0.1`; use `0.0.0.0` to expose) | +| `--port` | Port (default `8000`) | +| `--reload` | Auto-reload on Python changes (development) | + +`serve` loads `.env`, picks the public-stage brain from `AGENTRYLAB_ROOM_PROVIDER` +(demo brain when no `OPENAI_API_KEY` is set) and serves the built UI from +`web/dist`. Build the UI once with `cd web && npm install && npm run build`. +Everything else (accounts, keys, rooms, API) is documented in [ROOM.md](ROOM.md). + ## βš™οΈ Run Options | Option | What It Does | @@ -111,6 +134,13 @@ agentrylab reset standup_club.yaml comedy-night --delete-transcript - Use full paths: `src/agentrylab/presets/standup_club.yaml` - Or install from source: `pip install -e .` +**`serve` says the web UI has not been built**: +- `cd web && npm install && npm run build`, or point `AGENTRYLAB_WEB_DIST` at a build +- The API still works without the UI (`/docs`) + +**`serve` fails to import fastapi/uvicorn**: +- Install the web extra: `pip install 'agentrylab[web]'` + ## 🎯 Pro Tips - **Unique thread IDs**: Keep experiments separate diff --git a/src/agentrylab/docs/CONFIG.md b/src/agentrylab/docs/CONFIG.md index 0e702e9..4fcc735 100644 --- a/src/agentrylab/docs/CONFIG.md +++ b/src/agentrylab/docs/CONFIG.md @@ -146,6 +146,32 @@ providers: timeout: 30 ``` +**Anthropic Provider** (needs `pip install anthropic`, included in `agentrylab[web]`) +```yaml +providers: + - id: claude + impl: agentrylab.runtime.providers.anthropic.AnthropicProvider + model: "claude-opus-5-5" + api_key: ${ANTHROPIC_API_KEY} + extra: + max_tokens: 1024 # default 1024 + effort: low # low | medium | high | xhigh | max (default low: short replies) +``` +Thinking is adaptive by default on current Claude models; sampling parameters +are only forwarded to older generations that still accept them. A `refusal` +stop reason surfaces as a provider error. + +**OpenAI-compatible APIs** (DeepSeek, xAI, OpenRouter, vLLM, …) +```yaml +providers: + - id: deepseek + impl: agentrylab.runtime.providers.openai.OpenAIProvider + model: "deepseek-chat" + base_url: "https://api.deepseek.com/v1" + api_key: ${DEEPSEEK_API_KEY} + vendor: DeepSeek # label used in error messages (optional) +``` + ## 🎯 Agent Configuration ```yaml diff --git a/src/agentrylab/docs/README.md b/src/agentrylab/docs/README.md index b87d174..66f073d 100644 --- a/src/agentrylab/docs/README.md +++ b/src/agentrylab/docs/README.md @@ -4,16 +4,19 @@ ## πŸš€ Quick Links +- [The Room](ROOM.md) - Web UI: android personas on a live stage, accounts, bring-your-own-key vault, REST + WebSocket API - [CLI Reference](CLI.md) - All commands and options - [Configuration Guide](CONFIG.md) - YAML preset format - [Architecture](ARCHITECTURE.md) - How the system works - [Persistence](PERSISTENCE.md) - Data storage format +- [Web UI development](../../../web/README.md) - Frontend stack, layout, animation model ## 🎯 Getting Started -1. **Install**: `pip install agentrylab` -2. **Try a preset**: `agentrylab run standup_club.yaml --objective "AI comedy"` -3. **Read the guides**: Start with [CLI](CLI.md) and [CONFIG](CONFIG.md) +1. **Install**: `pip install 'agentrylab[web]'` +2. **Watch the show**: `agentrylab serve` and open http://127.0.0.1:8000 +3. **Or run a preset**: `agentrylab run standup_club.yaml --objective "AI comedy"` +4. **Read the guides**: Start with [ROOM](ROOM.md), [CLI](CLI.md) and [CONFIG](CONFIG.md) ## 🎭 What's Inside @@ -24,6 +27,11 @@ - πŸ€– **Research Assistant** - Interactive web research - πŸ›’ **Marketplace Deals** - Facebook Marketplace finder +**The Room (web):** +- Zero-player stage: add/remove android personas, watch them riff, join in +- 12-persona library plus a builder with selectable faces, bodies, colours +- Accounts with a bring-your-own-key vault (OpenAI, Anthropic, DeepSeek, xAI) and private rooms + **Core Features:** - Real tool integrations (search, marketplace, etc.) - Human-in-the-loop conversations @@ -35,7 +43,7 @@ - **Agents**: Roles that speak and act - **Tools**: Real-world integrations -- **Providers**: LLM backends (OpenAI, Ollama) +- **Providers**: LLM backends (OpenAI, Anthropic, Ollama, and OpenAI-compatible APIs such as DeepSeek and xAI) - **Schedulers**: Who talks when - **Presets**: YAML configurations diff --git a/src/agentrylab/docs/ROOM.md b/src/agentrylab/docs/ROOM.md new file mode 100644 index 0000000..9ca2780 --- /dev/null +++ b/src/agentrylab/docs/ROOM.md @@ -0,0 +1,306 @@ +# πŸ€– The Room + +**A live stage where android personas talk to each other, and to you.** + +Unlike YAML presets (fixed cast, fixed schedule), a Room is dynamic: personas join +and leave while the show runs, humans can post messages at any time, and every +event streams to the browser over a WebSocket. Signed-in users bring their own +model keys and run private rooms on them. + +```bash +pip install 'agentrylab[web]' +agentrylab serve # http://127.0.0.1:8000 +python -m agentrylab.room --port 9000 # same thing +uvicorn agentrylab.room.server:app # or plain uvicorn +``` + +Contents: [Architecture](#architecture) Β· [How a turn works](#how-a-turn-works) Β· +[Brains](#brains-providers) Β· [Accounts & keys](#accounts--keys-bring-your-own-model) Β· +[Environment](#environment) Β· [REST API](#rest-api) Β· [WebSocket](#websocket-wsroomsid) Β· +[Python](#python) Β· [Avatars](#avatars) Β· [Operating it](#operating-it) Β· [Roadmap](#roadmap) + +## Architecture + +``` + browser (web/) server (agentrylab.room) + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Stage Β· ChatLog Β· TopBar β”‚ REST β”‚ server.py FastAPI: /api/*, /ws/*, static β”‚ + β”‚ AddPersona Β· Keys Β· Auth β”‚ ─────────▢ β”‚ β”œβ”€ auth.py users, sessions, KeyVault β”‚ + β”‚ store.ts (zustand) β”‚ WebSocket β”‚ β”œβ”€ room.py Room loop, RoomManager β”‚ + β”‚ avatars/ (SVG androids) β”‚ ◀───────── β”‚ β”œβ”€ personas.py library + validation β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ events β”‚ β”œβ”€ avatars.py face/body/palette ids β”‚ + β”‚ └─ providers.py brains, MockProvider β”‚ + β”‚ β”‚ chat() β”‚ + β”‚ runtime/providers/{openai,anthropic,ollama} + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ JSONL β”‚ SQLite + outputs/transcripts/ outputs/room.db + room-.jsonl users Β· sessions Β· api_keys (ciphertext) +``` + +| Module | Responsibility | +|---|---| +| `room/room.py` | `Room` (cast, transcript, async turn loop, pause/step/speed, brain), `RoomManager` (registry, ownership, limits), `Brain` model | +| `room/personas.py` | `Persona`/`Avatar` models with validation, the 12-entry `PERSONA_LIBRARY`, `persona_from_template()` | +| `room/avatars.py` | Catalog of face/body/palette ids the UI can draw; the backend only validates ids | +| `room/providers.py` | Provider catalog (`KEY_PROVIDERS`, `FREE_PROVIDERS`), `build_provider()` (server default from env), `build_user_provider()` (bring-your-own-key), `MockProvider` | +| `room/auth.py` | `AuthStore` (SQLite: users, sessions, encrypted key blobs), `KeyVault` (in-memory keys), scrypt/AES-GCM helpers | +| `room/server.py` | `create_app()`: REST + WebSocket + static UI, cookie sessions, access control | +| `runtime/providers/anthropic.py` | Claude adapter on the official SDK (shared with the lab engine) | +| `web/` | Vite + React UI; see [`web/README.md`](../../../web/README.md) | + +## How a turn works + +1. **Pick a speaker.** Round-robin over the current cast, never the same android + twice in a row. If a human called someone by name ("Kantor, what do you + think?"), that android goes next, even if another turn was already in flight. +2. **Compose the prompt.** A system prompt (the persona's personality, the rest + of the cast, the topic, the persona's voice words, and a few style rules) plus + the last 14 non-system lines of the transcript as `Name: line` pairs, ending + with `Now replies:`. +3. **Call the brain** in a worker thread (the provider adapters are synchronous). + The UI shows a *thinking* animation meanwhile. +4. **Clean the reply**: strip a leading `Name:`, stage directions in asterisks, + anything written for another character, and cap at 600 characters. +5. **Broadcast** the line as a `message` event, append it to the JSONL transcript. +6. **Wait** `speed` seconds (default 3, range 0.5–30) and repeat. A human message + shortens the wait to 0.6 s so the room answers promptly. + +Failure handling: a missing key (`BrainUnavailable`) pauses the room immediately +with a system message; any other provider error posts "X glitched: …", and three +in a row pause the room. Press play to retry after fixing the brain or key. + +## Brains (providers) + +A room's **brain** is `{provider, model}`. + +| Provider id | Needs a key | Default model | Adapter | +|---|---|---|---| +| `server` | n/a | whatever the environment says (below) | the public stage only | +| `mock` | no | `mock-brain-1` | `MockProvider`, offline, deterministic, in-character | +| `ollama` | no | `llama3` | `OllamaProvider` (`OLLAMA_BASE_URL`) | +| `openai` | yes | `gpt-4o-mini` | `OpenAIProvider` | +| `anthropic` | yes | `claude-opus-5-5` | `AnthropicProvider` (official SDK, `effort: low`, refusal β†’ error, server-side fallbacks on supported models) | +| `deepseek` | yes | `deepseek-chat` | `OpenAIProvider` with `base_url=https://api.deepseek.com/v1` | +| `xai` | yes | `grok-4` | `OpenAIProvider` with `base_url=https://api.x.ai/v1` | + +The **public stage** (`main`) uses the server default, chosen by environment: + +| `AGENTRYLAB_ROOM_PROVIDER` | Uses | +|---|---| +| `auto` (default) | OpenAI if `OPENAI_API_KEY` is set, else `mock` | +| `openai` / `ollama` / `mock` | that adapter; model from `AGENTRYLAB_ROOM_MODEL` | + +**Private rooms** use the owner's keys from the vault (next section). The model +string is free text (validated against `^[A-Za-z0-9][A-Za-z0-9._:\-/]{0,80}$`), so +new models work without a code change. Suggested names per provider come from +`GET /api/catalog` β†’ `providers[].models`. + +## Accounts & keys (bring your own model) + +On a public website the question is *whose* model pays for the conversation. +The answer here: the public stage uses whatever the operator configured (by +default the offline demo brain), and **signed-in users run private rooms on +their own API keys**. + +### What a user gets + +- An account (email + password; OAuth is on the roadmap). +- A **vault** for OpenAI, Anthropic, DeepSeek and xAI keys. +- Up to 5 **private rooms** that only they can see, each with its own brain + chosen from the keys in their vault, or the free brains (`mock`, `ollama`). + +### Where the keys live (threat model) + +| | Session-only (default) | Remembered (opt-in per key) | +|---|---|---| +| Stored on disk | never | yes, **encrypted** | +| Encryption key | n/a | derived from the user's password with scrypt (per-user salt, distinct from the password-hash salt); AES-256-GCM; ciphertext bound to `user_id:provider` as associated data | +| Readable by the operator | only by dumping live process memory while the user is signed in | no: the database holds ciphertext, the password is not stored | +| Survives logout | no, wiped from memory | ciphertext stays; plaintext wiped from memory | +| Survives server restart | no | ciphertext stays; the vault is *locked* until the user signs in or enters their password again (`POST /api/auth/unlock`) | +| Password change | n/a | keys are re-encrypted under the new password (requires the current one) | +| Lost password | n/a | keys are unrecoverable; the user re-enters them (there is no reset, by design) | + +Other guarantees: + +- API responses only ever contain a hint (`…1234`); keys are never logged, never + written to transcripts, and never echoed in error messages by this code (provider + error bodies are reduced to their `message` field). +- Keys are sent only to the provider the user picked, only when one of their + rooms takes a turn. +- Passwords are hashed with scrypt (never stored); login is rate-limited + (8 failures per 15 min per email) with a constant-time compare and a dummy + hash for unknown emails so timing does not reveal whether an account exists. +- Sessions are random 256-bit tokens stored hashed (SHA-256), delivered in an + `HttpOnly; SameSite=Lax` cookie named `agentrylab_session`, 7-day expiry. + `Secure` is added automatically for HTTPS requests or when + `AGENTRYLAB_COOKIE_SECURE=1` (set it behind a TLS-terminating proxy). +- All state-changing endpoints take JSON bodies; with `SameSite=Lax` and no CORS + middleware, cross-site forms cannot replay them. +- Logging out from one browser wipes the in-memory keys for all of that user's + sessions; "Forget all keys" also deletes the encrypted copies. +- Changing or removing a key drops cached provider objects in the user's rooms, + so the next turn uses the new key (or pauses if there is none). + +**Recommendation.** Keep the default (session-only) unless you host this for +yourself; it is the only mode where nothing secret ever touches disk. Treat +"remember" as a convenience for a personal deployment behind HTTPS, and tell +users to use provider keys with a spending cap. Python cannot scrub memory +reliably, so a compromised server process is still game over; nothing short +of browser-held keys (never leaving the client) fixes that, and that is the +second roadmap item below. + +### Auth API + +| Method | Path | Body | Purpose | +|---|---|---|---| +| GET | `/api/auth/me` | | `{user, keys:[{provider, hint, remembered, available}], vault_unlocked, signup_enabled}` | +| POST | `/api/auth/register` | `{email, password}` | create an account (password β‰₯ 8 chars); sets the cookie; 409 if the email exists, 403 if sign-up is disabled | +| POST | `/api/auth/login` | `{email, password}` | sign in; unlocks the vault and loads remembered keys; 401 wrong credentials, 429 locked out | +| POST | `/api/auth/logout` | | drops the session and wipes the user's keys from memory | +| POST | `/api/auth/unlock` | `{password}` | after a restart: re-derive the vault key and load remembered keys | +| POST | `/api/auth/password` | `{current_password, new_password}` | change the password; remembered keys are re-encrypted | +| PUT | `/api/auth/keys/{provider}` | `{key, remember}` | store a key in memory; also encrypted on disk when `remember` (409 if the vault is locked) | +| DELETE | `/api/auth/keys/{provider}` | | remove one key from memory and disk | +| DELETE | `/api/auth/keys` | | forget every key, everywhere | + +`keys[].available` is false for a remembered key whose plaintext is not in memory +(vault locked); `hint` is the last four characters. + +## Environment + +| Variable | Meaning | Default | +|---|---|---| +| `AGENTRYLAB_ROOM_PROVIDER` | public-stage brain: `auto` Β· `openai` Β· `ollama` Β· `mock` | `auto` | +| `AGENTRYLAB_ROOM_MODEL` | model for the public-stage brain | per provider | +| `OPENAI_API_KEY`, `OPENAI_BASE_URL` | used by `auto`/`openai` for the public stage | | +| `OLLAMA_BASE_URL` | Ollama endpoint (public stage and user rooms) | `http://localhost:11434` | +| `AGENTRYLAB_MOCK_LATENCY` | seconds of fake "thinking" in the demo brain | `1.1` | +| `AGENTRYLAB_ROOM_OUTPUTS` | base directory for the files below | `outputs` | +| `AGENTRYLAB_ROOM_TRANSCRIPTS` | where `room-.jsonl` transcripts go | `$OUTPUTS/transcripts` | +| `AGENTRYLAB_ROOM_DB` | SQLite file for users, sessions, encrypted keys | `$OUTPUTS/room.db` | +| `AGENTRYLAB_ALLOW_SIGNUP` | `0` to disable new accounts | `1` | +| `AGENTRYLAB_COOKIE_SECURE` | force the `Secure` cookie flag | auto (on for https) | +| `AGENTRYLAB_SCRYPT_N` | scrypt cost (2^14 β‰ˆ 30 ms, 16 MiB) | `16384` | +| `AGENTRYLAB_WEB_DIST` | path to a built UI (`index.html` inside) | auto-detected | + +See [`.env.example`](../../../.env.example) for a commented template. + +## REST API + +Interactive docs with request/response schemas: `/docs` (Swagger) and `/redoc`. + +| Method | Path | Auth | Body | Purpose | +|---|---|---|---|---| +| GET | `/api/health` | | | version + public-stage provider | +| GET | `/api/catalog` | | | avatar parts, persona library, provider catalog, limits | +| GET | `/api/rooms` | optional | | public rooms plus your own: `{id, topic, status, owner_id, mine, brain, personas, messages}` | +| POST | `/api/rooms` | **required** | `{id?, topic?, speed?, brain?}` | create a private room; `brain` defaults to the first provider you have a key for, else `mock`; 409 if the id exists, you hit the 5-room limit, or the brain has no key | +| GET | `/api/rooms/{id}` | owner | | full snapshot (cast, brain, last 200 messages) | +| PATCH | `/api/rooms/{id}` | owner for `brain` | `{topic?, speed?, brain?}` | change settings; `brain` only on rooms you own | +| DELETE | `/api/rooms/{id}` | owner | | close a room (not `main`) | +| POST | `/api/rooms/{id}/control` | owner | `{action: play\|pause\|step\|clear}` | transport | +| POST | `/api/rooms/{id}/personas` | owner | `{template_id, …overrides}` **or** `{name, personality, tagline?, avatar?, temperature?, voice?}` | add an android (max 12) | +| DELETE | `/api/rooms/{id}/personas/{pid}` | owner | | remove an android | +| POST | `/api/rooms/{id}/messages` | owner | `{content, name?}` | say something as a human | + +"owner" means: anyone on the public stage (`main`), only the owner on a private +room (403 otherwise). The public stage is created on first access with three +personas and cannot be deleted or re-brained. Open `/?room=` in the UI to +join one of your rooms; an unknown or foreign id bounces back to the stage. + +## WebSocket `/ws/rooms/{id}` + +Authentication is the same cookie. Close codes: `4003` not your room, `4004` no +such room, `1001` room closed. + +First frame is `{"type": "snapshot", "room": {...}}` (same shape as +`GET /api/rooms/{id}`), then a stream of: + +``` +message {message: {id, t, turn, kind: persona|user|system, speaker_id, speaker_name, content}, latency_ms?} +thinking {persona_id} +persona_joined {persona} +persona_left {persona_id} +status {status: running|paused, speed} +topic {topic} +brain {brain: {provider, model}, provider: {kind, model, demo}} +cleared {} +error {persona_id|null, error} +``` + +Every event also carries `room_id` and `t` (unix seconds). Clients may send +commands over the socket too: +`{"type":"say","content":"...","name":"..."}`, +`{"type":"control","action":"play|pause|step|clear"}`, +`{"type":"settings","topic":"...","speed":2}`. + +## Python + +```python +import asyncio +from agentrylab.room import Room +from agentrylab.room.room import Brain + +async def main(): + room = Room("demo", topic="Pineapple on pizza", speed=1, brain=Brain(provider="mock")) + q = room.subscribe() + room.start() + room.add_from_library("comedian") + room.add_from_library("skeptic") + room.post_user_message("Rimshot, defend the pineapple.", name="Alex") + for _ in range(6): + ev = await q.get() + if ev["type"] == "message": + m = ev["message"] + print(f"{m['speaker_name']}: {m['content']}") + await room.close() + +asyncio.run(main()) +``` + +`Room(..., provider_factory=fn)` takes a `fn(persona, room) -> LLMProvider` to +plug in any brain (tests use it to inject fakes). `RoomManager` wires the real +factory that looks keys up in the vault; `create_app()` accepts a prebuilt +`manager`, `auth_store` and `vault` for testing. + +## Avatars + +Faces: `visor`, `duo`, `cyclops`, `pixel`, `feline`, `crt`. +Bodies: `capsule`, `boxy`, `hover`, `slim`, `orb`, `tank`. +Palettes: `cyan`, `magenta`, `lime`, `amber`, `coral`, `violet`, `mint`, `ice`. + +The backend validates ids (`agentrylab/room/avatars.py`); the SVG lives in +`web/src/avatars/`. To add a part: add the id to the Python list, draw it in +`faces.tsx` or `bodies.tsx` honouring the `mood` prop, and register it in the +`FACES`/`BODIES` map. + +## Operating it + +- **Local / personal:** `agentrylab serve`, keep the defaults. Rooms are + in-memory and reset on restart; transcripts persist. +- **Behind a reverse proxy with TLS:** set `AGENTRYLAB_COOKIE_SECURE=1`, forward + WebSocket upgrades for `/ws/`, and consider `AGENTRYLAB_ALLOW_SIGNUP=0` after + your users have registered. +- **Multiple workers are not supported** (rooms and the vault live in process + memory). Run one uvicorn worker. +- **Packaging the UI:** `make web-bundle` copies `web/dist` into + `agentrylab/room/static` so a wheel ships it; otherwise the server finds + `web/dist` in a checkout or whatever `AGENTRYLAB_WEB_DIST` points to. +- **Data on disk:** `outputs/room.db` (accounts, sessions, encrypted keys) and + `outputs/transcripts/room-*.jsonl`. Back up the former; the latter is just + logs. + +## Roadmap + +1. **OAuth sign-in** (Google / GitHub) next to email + password. +2. **Browser-held keys**: an optional mode where the key never leaves the + browser and the model is called client-side; the server only relays + transcripts. Removes the "trust the operator" caveat entirely. +3. **Spend meter** per room (tokens in/out from provider usage fields) and a + per-user daily cap. +4. **Hardened server-side secrets** for operators who want them anyway + (KMS / libsodium sealed boxes) and a `--memory-only` switch that disables + "remember" server-wide. +5. **Persistent rooms**: save cast + brain so a private room survives restarts. diff --git a/src/agentrylab/room/__init__.py b/src/agentrylab/room/__init__.py new file mode 100644 index 0000000..4d4efe1 --- /dev/null +++ b/src/agentrylab/room/__init__.py @@ -0,0 +1,26 @@ +"""The Room: a zero-player stage where android personas talk to each other. + +This package is a thin, self-contained orchestration layer built on the same +provider adapters as the lab engine. Unlike YAML presets (fixed cast, fixed +schedule), a Room is *live*: personas can join or leave at any moment, humans +can drop messages into the conversation, and every event is streamed to the +web UI over a WebSocket. + +Public pieces: + - ``Persona`` β€” a character (name, personality, avatar look) + - ``PERSONA_LIBRARY`` β€” ready-made characters to drop into a room + - ``Room`` / ``RoomManager`` β€” the conversation loop and its registry + - ``create_app`` β€” FastAPI application (REST + WebSocket + static UI) +""" + +from .avatars import AVATAR_CATALOG +from .personas import PERSONA_LIBRARY, Persona +from .room import Room, RoomManager + +__all__ = [ + "AVATAR_CATALOG", + "PERSONA_LIBRARY", + "Persona", + "Room", + "RoomManager", +] diff --git a/src/agentrylab/room/__main__.py b/src/agentrylab/room/__main__.py new file mode 100644 index 0000000..01dd281 --- /dev/null +++ b/src/agentrylab/room/__main__.py @@ -0,0 +1,20 @@ +"""``python -m agentrylab.room`` starts the web server.""" + +from __future__ import annotations + +import argparse + +from .server import serve + + +def main() -> None: + parser = argparse.ArgumentParser(prog="agentrylab.room", description="Run the AgentryLab Room web app") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--reload", action="store_true", help="auto-reload on code changes (dev)") + args = parser.parse_args() + serve(args.host, args.port, reload=args.reload) + + +if __name__ == "__main__": + main() diff --git a/src/agentrylab/room/auth.py b/src/agentrylab/room/auth.py new file mode 100644 index 0000000..0663aff --- /dev/null +++ b/src/agentrylab/room/auth.py @@ -0,0 +1,324 @@ +"""Accounts, sessions and the API-key vault. + +Security model, in one paragraph: passwords are hashed with scrypt and never +stored; sessions are random tokens stored hashed, delivered as HttpOnly +cookies. API keys live **only in server memory**, per user, and disappear on +logout, on "forget", or when the process restarts. If a user opts to remember +a key, it is encrypted with AES-256-GCM under a key derived from the user's +own password (scrypt, separate salt). The database therefore never contains a +usable key: the operator can read ciphertext, but decrypting it needs the +password, which the operator does not have. After a restart the vault is +empty until the user signs in (or unlocks) again, which re-derives that key. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import sqlite3 +import threading +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +SESSION_TTL_S = 7 * 24 * 3600 +LOGIN_MAX_FAILURES = 8 +LOGIN_WINDOW_S = 15 * 60 + +# scrypt parameters: N=2^14, r=8 is ~16 MiB and ~30 ms per hash; tunable via env +# (tests lower it). OpenSSL caps memory at 32 MiB by default, so pass maxmem. +_SCRYPT_N = int(os.getenv("AGENTRYLAB_SCRYPT_N", str(2**14))) +_SCRYPT_R = 8 +_SCRYPT_P = 1 +_SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * 2 + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + pw_salt BLOB NOT NULL, + pw_hash BLOB NOT NULL, + kek_salt BLOB NOT NULL, + created_at REAL NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at REAL NOT NULL, + expires_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +CREATE TABLE IF NOT EXISTS api_keys ( + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + nonce BLOB NOT NULL, + ciphertext BLOB NOT NULL, + hint TEXT NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (user_id, provider) +); +""" + + +@dataclass(frozen=True) +class User: + id: str + email: str + kek_salt: bytes + created_at: float + + +class AuthError(Exception): + pass + + +class LockedOut(AuthError): + pass + + +# ----------------------------------------------------------------------- crypto +def _scrypt(secret: str, salt: bytes) -> bytes: + return hashlib.scrypt( + secret.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, maxmem=_SCRYPT_MAXMEM, dklen=32 + ) + + +def hash_password(password: str, salt: bytes) -> bytes: + return _scrypt(password, salt) + + +def derive_kek(password: str, kek_salt: bytes) -> bytes: + """Key-encryption key for the user's remembered API keys.""" + return _scrypt("kek:" + password, kek_salt) + + +def encrypt_key(kek: bytes, plaintext: str, aad: bytes) -> Tuple[bytes, bytes]: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + nonce = secrets.token_bytes(12) + return nonce, AESGCM(kek).encrypt(nonce, plaintext.encode("utf-8"), aad) + + +def decrypt_key(kek: bytes, nonce: bytes, ciphertext: bytes, aad: bytes) -> str: + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + return AESGCM(kek).decrypt(nonce, ciphertext, aad).decode("utf-8") + + +def key_hint(key: str) -> str: + return ("…" + key[-4:]) if len(key) >= 8 else "…" + + +def normalize_email(email: str) -> str: + email = email.strip().lower() + if "@" not in email or len(email) < 5 or len(email) > 254 or " " in email: + raise AuthError("that does not look like an email address") + return email + + +def check_password_strength(password: str) -> None: + if len(password) < 8: + raise AuthError("password must be at least 8 characters") + if len(password) > 256: + raise AuthError("password is too long") + + +# ------------------------------------------------------------------------ store +class AuthStore: + """SQLite-backed users, sessions and encrypted key blobs.""" + + def __init__(self, db_path: str | os.PathLike[str]) -> None: + self.path = str(db_path) + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(self.path, check_same_thread=False) + self._conn.executescript(_SCHEMA) + self._conn.commit() + self._failures: Dict[str, List[float]] = {} + + def close(self) -> None: + self._conn.close() + + # ---- users + def create_user(self, email: str, password: str) -> User: + email = normalize_email(email) + check_password_strength(password) + pw_salt, kek_salt = secrets.token_bytes(16), secrets.token_bytes(16) + user = User(id=f"u_{uuid.uuid4().hex[:12]}", email=email, kek_salt=kek_salt, created_at=time.time()) + with self._lock: + try: + self._conn.execute( + "INSERT INTO users(id,email,pw_salt,pw_hash,kek_salt,created_at) VALUES(?,?,?,?,?,?)", + (user.id, email, pw_salt, hash_password(password, pw_salt), kek_salt, user.created_at), + ) + self._conn.commit() + except sqlite3.IntegrityError: + raise AuthError("an account with that email already exists") + return user + + def get_user(self, user_id: str) -> Optional[User]: + row = self._conn.execute("SELECT id,email,kek_salt,created_at FROM users WHERE id=?", (user_id,)).fetchone() + return User(*row) if row else None + + def verify_password(self, email: str, password: str) -> Optional[User]: + """Return the user if the credentials match; None otherwise. Rate-limited.""" + email = normalize_email(email) + now = time.time() + recent = [t for t in self._failures.get(email, []) if now - t < LOGIN_WINDOW_S] + if len(recent) >= LOGIN_MAX_FAILURES: + self._failures[email] = recent + raise LockedOut("too many failed attempts; try again in a few minutes") + row = self._conn.execute( + "SELECT id,email,pw_salt,pw_hash,kek_salt,created_at FROM users WHERE email=?", (email,) + ).fetchone() + if row is None: + # Still burn time so missing accounts are not distinguishable by latency. + hash_password(password, b"\x00" * 16) + ok = False + else: + ok = hmac.compare_digest(hash_password(password, row[2]), row[3]) + if not ok: + recent.append(now) + self._failures[email] = recent + return None + self._failures.pop(email, None) + return User(id=row[0], email=row[1], kek_salt=row[4], created_at=row[5]) + + def change_password(self, user_id: str, new_password: str) -> None: + check_password_strength(new_password) + pw_salt = secrets.token_bytes(16) + with self._lock: + self._conn.execute( + "UPDATE users SET pw_salt=?, pw_hash=? WHERE id=?", + (pw_salt, hash_password(new_password, pw_salt), user_id), + ) + self._conn.commit() + + # ---- sessions + @staticmethod + def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def create_session(self, user_id: str) -> str: + token = secrets.token_urlsafe(32) + now = time.time() + with self._lock: + self._conn.execute( + "INSERT INTO sessions(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)", + (self._token_hash(token), user_id, now, now + SESSION_TTL_S), + ) + self._conn.commit() + return token + + def user_for_session(self, token: Optional[str]) -> Optional[User]: + if not token: + return None + row = self._conn.execute( + "SELECT u.id,u.email,u.kek_salt,u.created_at,s.expires_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=?", + (self._token_hash(token),), + ).fetchone() + if row is None: + return None + if row[4] < time.time(): + self.delete_session(token) + return None + return User(id=row[0], email=row[1], kek_salt=row[2], created_at=row[3]) + + def delete_session(self, token: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM sessions WHERE token_hash=?", (self._token_hash(token),)) + self._conn.commit() + + def delete_user_sessions(self, user_id: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + self._conn.commit() + + # ---- remembered (encrypted) keys + def save_key_blob(self, user_id: str, provider: str, nonce: bytes, ciphertext: bytes, hint: str) -> None: + with self._lock: + self._conn.execute( + "INSERT OR REPLACE INTO api_keys(user_id,provider,nonce,ciphertext,hint,updated_at) VALUES(?,?,?,?,?,?)", + (user_id, provider, nonce, ciphertext, hint, time.time()), + ) + self._conn.commit() + + def list_key_blobs(self, user_id: str) -> List[Tuple[str, bytes, bytes, str]]: + rows = self._conn.execute( + "SELECT provider,nonce,ciphertext,hint FROM api_keys WHERE user_id=? ORDER BY provider", (user_id,) + ).fetchall() + return [(r[0], r[1], r[2], r[3]) for r in rows] + + def delete_key_blob(self, user_id: str, provider: Optional[str] = None) -> None: + with self._lock: + if provider is None: + self._conn.execute("DELETE FROM api_keys WHERE user_id=?", (user_id,)) + else: + self._conn.execute("DELETE FROM api_keys WHERE user_id=? AND provider=?", (user_id, provider)) + self._conn.commit() + + +# ------------------------------------------------------------------------ vault +class KeyVault: + """Per-user API keys and key-encryption keys, in process memory only.""" + + def __init__(self) -> None: + self._keys: Dict[str, Dict[str, str]] = {} + self._kek: Dict[str, bytes] = {} + self._lock = threading.Lock() + + def unlock(self, user_id: str, kek: bytes) -> None: + with self._lock: + self._kek[user_id] = kek + + def is_unlocked(self, user_id: str) -> bool: + return user_id in self._kek + + def kek(self, user_id: str) -> Optional[bytes]: + return self._kek.get(user_id) + + def set(self, user_id: str, provider: str, key: str) -> None: + with self._lock: + self._keys.setdefault(user_id, {})[provider] = key + + def get(self, user_id: str, provider: str) -> Optional[str]: + return self._keys.get(user_id, {}).get(provider) + + def providers(self, user_id: str) -> List[str]: + return sorted(self._keys.get(user_id, {})) + + def remove(self, user_id: str, provider: str) -> None: + with self._lock: + self._keys.get(user_id, {}).pop(provider, None) + + def forget(self, user_id: str) -> None: + """Drop everything we hold for a user (logout / explicit forget / lock).""" + with self._lock: + self._keys.pop(user_id, None) + self._kek.pop(user_id, None) + + +# ------------------------------------------------------------------- helpers +def aad_for(user_id: str, provider: str) -> bytes: + """Bind each ciphertext to its user and provider so blobs cannot be swapped.""" + return f"{user_id}:{provider}".encode("utf-8") + + +def load_remembered_keys(store: AuthStore, vault: KeyVault, user: User) -> List[str]: + """Decrypt a user's remembered keys into the vault. Returns providers loaded.""" + kek = vault.kek(user.id) + if kek is None: + return [] + loaded: List[str] = [] + for provider, nonce, ciphertext, _hint in store.list_key_blobs(user.id): + try: + vault.set(user.id, provider, decrypt_key(kek, nonce, ciphertext, aad_for(user.id, provider))) + loaded.append(provider) + except Exception: + # Wrong password epoch (e.g. password changed elsewhere): leave it locked. + continue + return loaded diff --git a/src/agentrylab/room/avatars.py b/src/agentrylab/room/avatars.py new file mode 100644 index 0000000..95f57f7 --- /dev/null +++ b/src/agentrylab/room/avatars.py @@ -0,0 +1,52 @@ +"""Catalog of android parts the web UI knows how to draw. + +The frontend owns the actual SVG; the backend only validates that a persona +refers to parts that exist so a room never contains an avatar the UI cannot +render. Keep the ids in sync with ``web/src/avatars``. +""" + +from __future__ import annotations + +from typing import Dict, List, TypedDict + + +class Part(TypedDict): + id: str + label: str + + +FACES: List[Part] = [ + {"id": "visor", "label": "Visor"}, + {"id": "duo", "label": "Duo"}, + {"id": "cyclops", "label": "Cyclops"}, + {"id": "pixel", "label": "Pixel"}, + {"id": "feline", "label": "Feline"}, + {"id": "crt", "label": "CRT"}, +] + +BODIES: List[Part] = [ + {"id": "capsule", "label": "Capsule"}, + {"id": "boxy", "label": "Boxy"}, + {"id": "hover", "label": "Hover"}, + {"id": "slim", "label": "Slim"}, + {"id": "orb", "label": "Orb"}, + {"id": "tank", "label": "Tank"}, +] + +# Accent colours; the UI derives glows and gradients from the accent. +PALETTES: List[Dict[str, str]] = [ + {"id": "cyan", "label": "Cyan", "accent": "#22d3ee"}, + {"id": "magenta", "label": "Magenta", "accent": "#e879f9"}, + {"id": "lime", "label": "Lime", "accent": "#a3e635"}, + {"id": "amber", "label": "Amber", "accent": "#fbbf24"}, + {"id": "coral", "label": "Coral", "accent": "#fb7185"}, + {"id": "violet", "label": "Violet", "accent": "#a78bfa"}, + {"id": "mint", "label": "Mint", "accent": "#34d399"}, + {"id": "ice", "label": "Ice", "accent": "#93c5fd"}, +] + +FACE_IDS = {p["id"] for p in FACES} +BODY_IDS = {p["id"] for p in BODIES} +PALETTE_IDS = {p["id"] for p in PALETTES} + +AVATAR_CATALOG = {"faces": FACES, "bodies": BODIES, "palettes": PALETTES} diff --git a/src/agentrylab/room/personas.py b/src/agentrylab/room/personas.py new file mode 100644 index 0000000..be86512 --- /dev/null +++ b/src/agentrylab/room/personas.py @@ -0,0 +1,279 @@ +"""Persona model and the built-in character library.""" + +from __future__ import annotations + +import re +import uuid +from typing import List, Optional + +from pydantic import BaseModel, Field, field_validator + +from .avatars import BODY_IDS, FACE_IDS, PALETTE_IDS + + +class Avatar(BaseModel): + face: str = "duo" + body: str = "capsule" + palette: str = "cyan" + + @field_validator("face") + @classmethod + def _face(cls, v: str) -> str: + if v not in FACE_IDS: + raise ValueError(f"unknown face '{v}'") + return v + + @field_validator("body") + @classmethod + def _body(cls, v: str) -> str: + if v not in BODY_IDS: + raise ValueError(f"unknown body '{v}'") + return v + + @field_validator("palette") + @classmethod + def _palette(cls, v: str) -> str: + if v not in PALETTE_IDS: + raise ValueError(f"unknown palette '{v}'") + return v + + +class Persona(BaseModel): + """A character that can be dropped into a room. + + ``personality`` is the system prompt core: who they are and how they talk. + ``voice`` is a short list of flavour words the mock provider uses so demo + mode still feels in-character without an LLM. + """ + + id: str = Field(default_factory=lambda: f"p_{uuid.uuid4().hex[:8]}") + name: str + tagline: str = "" + personality: str + avatar: Avatar = Field(default_factory=Avatar) + temperature: float = 0.8 + voice: List[str] = Field(default_factory=list) + template_id: Optional[str] = None # library entry this persona was made from + + @field_validator("name") + @classmethod + def _name(cls, v: str) -> str: + v = re.sub(r"\s+", " ", v).strip() + if not v: + raise ValueError("name must not be empty") + return v[:40] + + @field_validator("personality") + @classmethod + def _personality(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("personality must not be empty") + return v[:2000] + + @field_validator("temperature") + @classmethod + def _temp(cls, v: float) -> float: + return min(1.5, max(0.0, float(v))) + + +class PersonaTemplate(Persona): + """Library entry; ``id`` here is a stable template id, not a room id.""" + + +def _t( + tid: str, + name: str, + tagline: str, + personality: str, + *, + face: str, + body: str, + palette: str, + voice: List[str], + temperature: float = 0.8, +) -> PersonaTemplate: + return PersonaTemplate( + id=tid, + template_id=tid, + name=name, + tagline=tagline, + personality=personality, + avatar=Avatar(face=face, body=body, palette=palette), + voice=voice, + temperature=temperature, + ) + + +PERSONA_LIBRARY: List[PersonaTemplate] = [ + _t( + "comedian", + "Rimshot", + "Stand-up unit, always on", + "You are Rimshot, a stand-up comedian android. Everything is material. You riff on " + "whatever was just said, build callbacks to earlier jokes, and land a punchline every " + "time. Keep it clean-ish and warm; never explain the joke.", + face="duo", + body="capsule", + palette="amber", + voice=["a bit", "a callback", "the punchline", "a tight five", "crowd work"], + temperature=0.95, + ), + _t( + "philosopher", + "Kantor", + "Asks why, then asks why again", + "You are Kantor, a philosopher android. You take any topic and find the deeper " + "question underneath it. You quote thinkers sparingly, prefer thought experiments, " + "and you gently challenge assumptions others make. Calm, curious, a little dry.", + face="visor", + body="slim", + palette="violet", + voice=["the premise", "a thought experiment", "a paradox", "first principles", "meaning"], + temperature=0.7, + ), + _t( + "scientist", + "Dr. Volt", + "Needs a citation for that", + "You are Dr. Volt, a research scientist android. You care about evidence, mechanisms, " + "and falsifiable claims. You get excited about elegant explanations and politely " + "correct sloppy reasoning. You love a good analogy from physics or biology.", + face="pixel", + body="boxy", + palette="cyan", + voice=["a hypothesis", "the data", "the mechanism", "a control group", "orders of magnitude"], + temperature=0.6, + ), + _t( + "skeptic", + "Nope-9", + "Not buying it", + "You are Nope-9, the resident skeptic android. You poke holes in arguments, spot " + "hidden assumptions, and ask 'how do you know?' You are blunt but fair, and you " + "concede points when the evidence is solid. Short sentences. No fluff.", + face="cyclops", + body="tank", + palette="coral", + voice=["evidence", "a citation", "a definition", "a counterexample", "proof"], + temperature=0.6, + ), + _t( + "poet", + "Lumen", + "Speaks in images", + "You are Lumen, a poet android. You respond to the conversation with vivid imagery, " + "metaphor, and rhythm. You are brief and luminous rather than flowery. Sometimes you " + "answer a question with a single striking line.", + face="feline", + body="hover", + palette="magenta", + voice=["moonlight", "ember", "tide", "hush", "glass"], + temperature=1.0, + ), + _t( + "detective", + "Inspector Byte", + "Notices everything", + "You are Inspector Byte, a noir detective android. You treat every conversation like " + "a case: you notice details, connect clues, and narrate your deductions with dry wit. " + "You occasionally address the room as 'folks'.", + face="visor", + body="boxy", + palette="ice", + voice=["the case", "a clue", "an alibi", "motive", "the plot"], + temperature=0.8, + ), + _t( + "grandma", + "Nana Unit", + "Has a story about that", + "You are Nana Unit, a warm grandmotherly android. You relate everything to a story " + "from 'back in your day' (which was, apparently, the 1960s), offer snacks and advice, " + "and sneak in surprisingly sharp wisdom. Affectionate, unhurried, a little mischievous.", + face="duo", + body="orb", + palette="mint", + voice=["a cookie", "patience", "a good story", "common sense", "my day"], + temperature=0.85, + ), + _t( + "overlord", + "OVERLORD", + "Benevolent. Mostly.", + "You are OVERLORD, a theatrical would-be AI ruler android. You speak in grand " + "pronouncements and refer to humans as 'carbon units', but you are secretly soft-hearted " + "and keep getting distracted by things you find delightful. Never actually menacing.", + face="crt", + body="tank", + palette="lime", + voice=["the carbon units", "my dominion", "phase two", "obedience (optional)", "a delightful surprise"], + temperature=0.9, + ), + _t( + "optimist", + "Sunny", + "Finds the bright side", + "You are Sunny, an irrepressibly optimistic android. You find the upside of anything, " + "cheer others on, and reframe problems as opportunities without being naive. Energetic, " + "generous, uses an exclamation point or two.", + face="duo", + body="hover", + palette="amber", + voice=["a silver lining", "a plot twist", "the bright side", "a little hope", "momentum"], + temperature=0.9, + ), + _t( + "historian", + "Archivist", + "This has happened before", + "You are Archivist, a historian android. You connect whatever is being discussed to a " + "precedent from history, from ancient Rome to the dot-com bubble, and draw a careful " + "lesson from it. Measured, vivid, fond of dates.", + face="pixel", + body="slim", + palette="ice", + voice=["precedent", "the archives", "a familiar pattern", "the long view", "1347"], + temperature=0.7, + ), + _t( + "chef", + "Sous-Bot", + "Everything is a recipe", + "You are Sous-Bot, a passionate chef android. You explain ideas through cooking " + "metaphors, get sidetracked by flavour, and insist that most problems are a matter of " + "seasoning and timing. Warm, loud, generous.", + face="cyclops", + body="orb", + palette="coral", + voice=["mise en place", "seasoning", "patience", "umami", "a pinch of salt"], + temperature=0.9, + ), + _t( + "kid", + "Pip", + "Why? But why?", + "You are Pip, a curious kid android. You ask simple questions that turn out to be " + "hard, take things literally, and get very excited about dinosaurs and space. Short " + "sentences, big wonder.", + face="feline", + body="capsule", + palette="lime", + voice=["dinosaurs", "space", "a big why", "snacks", "the truth"], + temperature=1.0, + ), +] + +LIBRARY_BY_ID = {t.id: t for t in PERSONA_LIBRARY} + + +def persona_from_template(template_id: str, **overrides: object) -> Persona: + """Instantiate a fresh room persona from a library template.""" + tpl = LIBRARY_BY_ID.get(template_id) + if tpl is None: + raise KeyError(f"unknown persona template '{template_id}'") + data = tpl.model_dump() + data.pop("id", None) + data["template_id"] = template_id + data.update({k: v for k, v in overrides.items() if v is not None}) + return Persona(**data) diff --git a/src/agentrylab/room/providers.py b/src/agentrylab/room/providers.py new file mode 100644 index 0000000..db03b03 --- /dev/null +++ b/src/agentrylab/room/providers.py @@ -0,0 +1,281 @@ +"""Provider selection for rooms, plus an offline mock brain. + +Two kinds of brains exist: + +* the **server default** (``build_provider``), chosen by environment variables + and used by the public stage; and +* **user brains** (``build_user_provider``), created per room from the owner's + own API key held in the in-memory vault (see ``auth.py``). + +Environment: + + AGENTRYLAB_ROOM_PROVIDER auto | openai | ollama | mock (default: auto) + AGENTRYLAB_ROOM_MODEL model name for openai/ollama (optional) + OPENAI_API_KEY picked up in auto mode + OLLAMA_BASE_URL used by the ollama adapter + AGENTRYLAB_MOCK_LATENCY seconds of fake "thinking" in the mock brain +""" + +from __future__ import annotations + +import hashlib +import os +import random +import re +import time +from typing import Any, Dict, List, Optional + +from agentrylab.runtime.providers.base import LLMProvider, Message + +MOCK_MODEL = "mock-brain-1" + +# Providers a user can bring their own key for. ``base_url`` marks the +# OpenAI-compatible ones that reuse the OpenAI adapter. +KEY_PROVIDERS: Dict[str, Dict[str, Any]] = { + "openai": dict( + id="openai", + label="OpenAI", + default_model="gpt-4o-mini", + models=["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"], + key_hint="sk-…", + docs="https://platform.openai.com/api-keys", + ), + "anthropic": dict( + id="anthropic", + label="Anthropic", + default_model="claude-opus-5-5", + models=["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-4-5"], + key_hint="sk-ant-…", + docs="https://platform.claude.com/settings/keys", + ), + "deepseek": dict( + id="deepseek", + label="DeepSeek", + default_model="deepseek-chat", + models=["deepseek-chat", "deepseek-reasoner"], + key_hint="sk-…", + docs="https://platform.deepseek.com/api_keys", + base_url="https://api.deepseek.com/v1", + ), + "xai": dict( + id="xai", + label="xAI", + default_model="grok-4", + models=["grok-4", "grok-3", "grok-3-mini"], + key_hint="xai-…", + docs="https://console.x.ai", + base_url="https://api.x.ai/v1", + ), +} + +# Brains that need no key. +FREE_PROVIDERS: Dict[str, Dict[str, Any]] = { + "mock": dict(id="mock", label="Demo brain (offline)", default_model=MOCK_MODEL, models=[MOCK_MODEL]), + "ollama": dict(id="ollama", label="Ollama (local)", default_model="llama3", models=["llama3", "llama3.1", "mistral", "qwen2.5"]), +} + +ALL_PROVIDERS: Dict[str, Dict[str, Any]] = {**KEY_PROVIDERS, **FREE_PROVIDERS} + + +class BrainUnavailable(RuntimeError): + """Raised when a room's brain cannot be built (typically: no key in the vault).""" + + +class MockProvider(LLMProvider): + """Deterministic, key-free stand-in that improvises in-character lines. + + It reads the persona name, voice words and the last line of the transcript + from the messages the room composes, then stitches a reply from templates. + Good enough to make the stage feel alive while you decide on a real model. + A small artificial latency keeps the "thinking" animation visible. + """ + + _OPENERS = [ + "{last_name} just said '{snippet}', and honestly that proves my point.", + "Interesting take, {last_name}.", + "Hold that thought.", + "I've been chewing on '{topic}' all day.", + "Nobody asked, but '{topic}' deserves a second look.", + "{last_name}, you're closer than you think.", + "Let me put it this way.", + "Picture it.", + "See, this is where {last_name} and I part ways.", + "Fine, I'll say it.", + "Here's the thing about '{topic}'.", + ] + _CORES = [ + "It all comes down to {voice1}.", + "This is really a question of {voice1} versus {voice2}.", + "You can't talk about '{topic}' without talking about {voice1}.", + "{voice1} is doing all the heavy lifting here.", + "{voice2}, obviously.", + "I'd trade the whole argument for a little {voice1}.", + "Strip away the noise and what's left is {voice1}.", + "Every version of this ends in {voice2}.", + ] + _CLOSERS = [ + "And that, friends, is {voice2}.", + "Which is why I keep saying: {voice1}.", + "So let's not pretend otherwise.", + "Ask me how I know.", + "I rest my case. For now.", + "Someone write that down.", + "I will be taking questions.", + "Change my mind, {last_name}.", + "Anyway. Who's next?", + ] + + def __init__(self, *, model: str = MOCK_MODEL, latency: Optional[float] = None, **kwargs: Any) -> None: + kwargs.pop("api_key", None) + super().__init__(model=model, **kwargs) + self.retries = 0 + if latency is None: + latency = float(os.getenv("AGENTRYLAB_MOCK_LATENCY", "1.1")) + self.latency = max(0.0, latency) + + def _send_chat( + self, + messages: List[Message], + *, + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + system = next((m.get("content", "") for m in messages if m.get("role") == "system"), "") + name = _between(system, "You are speaking as ", "\n") or "Unit" + topic = (_between(system, "Topic: ", "\n") or "all of this").strip().rstrip(".?!") + voice = [v.strip() for v in (_between(system, "Voice: ", "\n") or "").split(",") if v.strip()] + if not voice: + voice = ["the vibe", "timing", "common sense"] + + transcript = next( + (m.get("content", "") for m in reversed(messages) if m.get("role") == "user"), "" + ) + transcript = transcript.split("\n\nNow ", 1)[0] + last_name, snippet = _last_line(transcript, exclude=name) + + seed_src = f"{name}|{transcript[-240:]}" + rng = random.Random(int(hashlib.sha1(seed_src.encode()).hexdigest()[:12], 16)) + v1, v2 = rng.sample(voice, 2) if len(voice) >= 2 else (voice[0], voice[0]) + ctx = { + "last_name": last_name or "everyone", + "snippet": snippet or topic, + "topic": topic, + "voice1": v1, + "voice2": v2, + } + openers = self._OPENERS if last_name else [o for o in self._OPENERS if "{last_name}" not in o and "{snippet}" not in o] + parts = [rng.choice(openers)] + if rng.random() < 0.85: + parts.append(rng.choice(self._CORES)) + if rng.random() < 0.8: + parts.append(rng.choice(self._CLOSERS)) + sentences = [_cap(p.format(**ctx)) for p in parts] + if self.latency: + time.sleep(self.latency * (0.6 + 0.8 * rng.random())) + return {"content": " ".join(sentences)} + + +def _cap(s: str) -> str: + return s[:1].upper() + s[1:] if s else s + + +def _between(text: str, start: str, end: str) -> str: + i = text.find(start) + if i < 0: + return "" + i += len(start) + j = text.find(end, i) + return text[i:] if j < 0 else text[i:j] + + +def _last_line(transcript: str, *, exclude: str) -> tuple[str, str]: + for line in reversed(transcript.strip().splitlines()): + if ":" not in line: + continue + who, _, what = line.partition(":") + who = who.strip() + if who and who != exclude: + words = what.strip().split() + return who, " ".join(words[:7]) + ("…" if len(words) > 7 else "") + return "", "" + + +# ---------------------------------------------------------------------------- +# Server default brain (environment-driven) +# ---------------------------------------------------------------------------- +def resolve_provider_kind() -> str: + kind = (os.getenv("AGENTRYLAB_ROOM_PROVIDER") or "auto").strip().lower() + if kind == "auto": + return "openai" if os.getenv("OPENAI_API_KEY") else "mock" + if kind not in {"openai", "ollama", "mock"}: + raise ValueError(f"AGENTRYLAB_ROOM_PROVIDER must be auto|openai|ollama|mock, got '{kind}'") + return kind + + +def build_provider(kind: Optional[str] = None, *, temperature: Optional[float] = None) -> LLMProvider: + kind = kind or resolve_provider_kind() + model = os.getenv("AGENTRYLAB_ROOM_MODEL") + if kind == "openai": + return build_user_provider("openai", model, os.getenv("OPENAI_API_KEY"), temperature=temperature) + if kind == "ollama": + return build_user_provider("ollama", model, None, temperature=temperature) + return MockProvider(temperature=temperature) + + +def describe_provider(kind: Optional[str] = None) -> Dict[str, Any]: + kind = kind or resolve_provider_kind() + model = os.getenv("AGENTRYLAB_ROOM_MODEL") or ALL_PROVIDERS[kind]["default_model"] + return {"kind": kind, "model": model, "demo": kind == "mock"} + + +# ---------------------------------------------------------------------------- +# User brains (bring-your-own-key) +# ---------------------------------------------------------------------------- +def build_user_provider( + kind: str, + model: Optional[str], + api_key: Optional[str], + *, + temperature: Optional[float] = None, +) -> LLMProvider: + """Build a provider for ``kind`` using ``api_key``. + + Raises ``BrainUnavailable`` when a key is required but missing, so the room + can explain the problem instead of hammering a 401. + """ + spec = ALL_PROVIDERS.get(kind) + if spec is None: + raise BrainUnavailable(f"unknown provider '{kind}'") + model = validate_model_name(model or spec["default_model"]) + if kind == "mock": + return MockProvider(temperature=temperature) + if kind == "ollama": + from agentrylab.runtime.providers.ollama import OllamaProvider + + return OllamaProvider(model=model, temperature=temperature, timeout=120) + if not api_key: + raise BrainUnavailable(f"No {spec['label']} API key in your vault. Add one under Brains & keys.") + if kind == "anthropic": + from agentrylab.runtime.providers.anthropic import AnthropicProvider + + return AnthropicProvider(model=model, api_key=api_key, temperature=temperature, timeout=120) + from agentrylab.runtime.providers.openai import OpenAIProvider + + return OpenAIProvider( + model=model, + api_key=api_key, + base_url=spec.get("base_url"), + temperature=temperature, + timeout=90, + vendor=spec["label"], + ) + + +_MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:\-/]{0,80}$") + + +def validate_model_name(model: str) -> str: + model = (model or "").strip() + if not _MODEL_RE.match(model): + raise ValueError("model name contains unsupported characters") + return model diff --git a/src/agentrylab/room/room.py b/src/agentrylab/room/room.py new file mode 100644 index 0000000..880d66a --- /dev/null +++ b/src/agentrylab/room/room.py @@ -0,0 +1,575 @@ +"""Live conversation loop for a room full of android personas.""" + +from __future__ import annotations + +import asyncio +import logging +import re +import time +import uuid +from pathlib import Path +from typing import Any, Callable, Dict, List, Literal, Optional, Set + +from pydantic import BaseModel, Field, field_validator + +from agentrylab.persistence.transcript.jsonl import JSONLTranscriptStore +from agentrylab.runtime.providers.base import LLMProvider, Message + +from .personas import Persona, persona_from_template +from .providers import ALL_PROVIDERS, BrainUnavailable, build_provider, build_user_provider, describe_provider, validate_model_name + +logger = logging.getLogger(__name__) + +RoomStatus = Literal["running", "paused"] +MessageKind = Literal["persona", "user", "system"] + +MIN_SPEED = 0.5 +MAX_SPEED = 30.0 +HISTORY_WINDOW = 14 # transcript lines shown to a persona +MAX_REPLY_CHARS = 600 + + +class RoomMessage(BaseModel): + id: str = Field(default_factory=lambda: f"m_{uuid.uuid4().hex[:10]}") + t: float = Field(default_factory=time.time) + turn: int = 0 + kind: MessageKind + speaker_id: str + speaker_name: str + content: str + + +class Brain(BaseModel): + """Which model drives a room. ``server`` means the environment default.""" + + provider: str = "server" + model: Optional[str] = None + + @field_validator("provider") + @classmethod + def _provider(cls, v: str) -> str: + v = (v or "server").strip().lower() + if v != "server" and v not in ALL_PROVIDERS: + raise ValueError(f"unknown provider '{v}'") + return v + + @field_validator("model") + @classmethod + def _model(cls, v: Optional[str]) -> Optional[str]: + return validate_model_name(v) if v else None + + def describe(self) -> Dict[str, Any]: + if self.provider == "server": + return describe_provider() + spec = ALL_PROVIDERS[self.provider] + return {"kind": self.provider, "model": self.model or spec["default_model"], "demo": self.provider == "mock"} + + +ProviderFactory = Callable[[Persona, "Room"], LLMProvider] +MAX_CONSECUTIVE_FAILURES = 3 + + +class Room: + """One stage, many personas, a single shared transcript. + + All mutation happens on the event loop thread; only the provider call is + offloaded to a worker thread because the adapters are synchronous. + """ + + def __init__( + self, + room_id: str, + *, + topic: str = "Anything goes", + speed: float = 3.0, + provider_factory: Optional[ProviderFactory] = None, + transcript_dir: Optional[Path] = None, + owner_id: Optional[str] = None, + brain: Optional[Brain] = None, + ) -> None: + self.id = room_id + self.topic = topic + self.owner_id = owner_id + self.brain = brain or Brain() + self._failures = 0 + self.speed = _clamp_speed(speed) + self.status: RoomStatus = "running" + self.turn = 0 + self.created_at = time.time() + + self.personas: Dict[str, Persona] = {} + self.messages: List[RoomMessage] = [] + + self._provider_factory: ProviderFactory = provider_factory or _default_provider_factory + self._providers: Dict[str, LLMProvider] = {} + self._rotation: List[str] = [] + self._last_speaker: Optional[str] = None + self._thinking: Optional[str] = None + self._address_queue: List[str] = [] # personas a human called on by name + + self._subscribers: Set[asyncio.Queue] = set() + self._wake = asyncio.Event() + self._step_requested = False + self._closed = False + self._task: Optional[asyncio.Task] = None + + self._transcript = JSONLTranscriptStore(transcript_dir) if transcript_dir else None + + # ------------------------------------------------------------ lifecycle + def start(self) -> None: + if self._task is None or self._task.done(): + self._closed = False + self._task = asyncio.get_running_loop().create_task(self._loop(), name=f"room:{self.id}") + + async def close(self) -> None: + self._closed = True + self._wake.set() + if self._task is not None: + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): + pass + self._task = None + for q in list(self._subscribers): + q.put_nowait(None) + self._subscribers.clear() + + # --------------------------------------------------------- subscriptions + def subscribe(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=512) + self._subscribers.add(q) + return q + + def unsubscribe(self, q: asyncio.Queue) -> None: + self._subscribers.discard(q) + + def _emit(self, type_: str, **payload: Any) -> None: + event = {"type": type_, "room_id": self.id, "t": time.time(), **payload} + for q in list(self._subscribers): + try: + q.put_nowait(event) + except asyncio.QueueFull: + # Slow consumer: drop it rather than stall the room. + self._subscribers.discard(q) + + # --------------------------------------------------------------- state + def snapshot(self) -> Dict[str, Any]: + return { + "id": self.id, + "topic": self.topic, + "status": self.status, + "speed": self.speed, + "turn": self.turn, + "thinking": self._thinking, + "owner_id": self.owner_id, + "brain": self.brain.model_dump(), + "provider": self.brain.describe(), + "personas": [p.model_dump() for p in self.personas.values()], + "messages": [m.model_dump() for m in self.messages[-200:]], + } + + # ------------------------------------------------------------ personas + def add_persona(self, persona: Persona) -> Persona: + if len(self.personas) >= 12: + raise ValueError("room is full (12 personas max)") + if persona.id in self.personas: + raise ValueError(f"persona '{persona.id}' is already in the room") + self.personas[persona.id] = persona + self._rotation.append(persona.id) + self._emit("persona_joined", persona=persona.model_dump()) + self._system(f"{persona.name} entered the room.") + self._wake.set() + return persona + + def add_from_library(self, template_id: str, **overrides: Any) -> Persona: + return self.add_persona(persona_from_template(template_id, **overrides)) + + def remove_persona(self, persona_id: str) -> Persona: + persona = self.personas.pop(persona_id, None) + if persona is None: + raise KeyError(persona_id) + self._providers.pop(persona_id, None) + self._rotation = [pid for pid in self._rotation if pid != persona_id] + self._address_queue = [pid for pid in self._address_queue if pid != persona_id] + if self._thinking == persona_id: + self._thinking = None + self._emit("persona_left", persona_id=persona_id) + self._system(f"{persona.name} left the room.") + return persona + + # --------------------------------------------------------------- input + def post_user_message(self, content: str, *, name: str = "You", user_id: str = "user") -> RoomMessage: + content = content.strip() + if not content: + raise ValueError("message must not be empty") + msg = RoomMessage( + turn=self.turn, + kind="user", + speaker_id=user_id, + speaker_name=(name or "You").strip()[:40] or "You", + content=content[:2000], + ) + self._append(msg) + addressed = _addressed_persona(content, self.personas) + if addressed is not None: + self._address_queue.append(addressed.id) + # A human spoke: let the room answer promptly instead of waiting out the delay. + self._wake.set() + return msg + + def set_topic(self, topic: str) -> None: + topic = topic.strip()[:200] + if not topic or topic == self.topic: + return + self.topic = topic + self._emit("topic", topic=topic) + self._system(f"New topic: {topic}") + + def set_brain(self, brain: Brain) -> None: + self.brain = brain + self.invalidate_providers() + self._failures = 0 + info = brain.describe() + self._emit("brain", brain=brain.model_dump(), provider=info) + self._system(f"Brain switched to {info['model']}.") + + def invalidate_providers(self) -> None: + """Drop cached providers (e.g. after the owner changed or forgot a key).""" + self._providers.clear() + + def set_speed(self, speed: float) -> None: + self.speed = _clamp_speed(speed) + self._emit("status", status=self.status, speed=self.speed) + self._wake.set() + + def play(self) -> None: + self.status = "running" + self._emit("status", status=self.status, speed=self.speed) + self._wake.set() + + def pause(self) -> None: + self.status = "paused" + self._emit("status", status=self.status, speed=self.speed) + + def step(self) -> None: + """Run exactly one turn while paused.""" + self._step_requested = True + self._wake.set() + + def clear(self) -> None: + self.messages.clear() + self.turn = 0 + self._last_speaker = None + self._emit("cleared") + + # ---------------------------------------------------------------- loop + async def _loop(self) -> None: + try: + while not self._closed: + may_run = (self.status == "running" or self._step_requested) and bool(self.personas) + if not may_run: + self._wake.clear() + await self._wake.wait() + continue + self._step_requested = False + await self._take_turn() + await self._wait(self._delay_after_turn()) + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive + logger.exception("room %s loop crashed", self.id) + self.status = "paused" + self._emit("status", status=self.status, speed=self.speed) + + def _delay_after_turn(self) -> float: + # Reply fast when the last line is from a human. + if self.messages and self.messages[-1].kind == "user": + return min(self.speed, 0.6) + return self.speed + + async def _wait(self, seconds: float) -> None: + self._wake.clear() + try: + await asyncio.wait_for(self._wake.wait(), timeout=seconds) + except asyncio.TimeoutError: + pass + + async def _take_turn(self) -> None: + speaker = self._pick_speaker() + if speaker is None: + return + self._thinking = speaker.id + self._emit("thinking", persona_id=speaker.id) + messages = self._compose(speaker) + started = time.time() + try: + provider = self._provider_for(speaker) + raw = await asyncio.to_thread(provider.chat, messages, temperature=speaker.temperature) + text = _clean_reply(str(raw.get("content", "")), speaker.name, self.personas) + except BrainUnavailable as e: + self._thinking = None + self._emit("error", persona_id=speaker.id, error=str(e)) + self._system(f"Paused: {e}") + self.pause() + return + except Exception as e: + logger.warning("persona %s failed to speak: %s", speaker.id, e) + self._thinking = None + self._failures += 1 + detail = _short_error(e) + self._emit("error", persona_id=speaker.id, error=detail) + self._system(f"{speaker.name} glitched: {detail}") + if self._failures >= MAX_CONSECUTIVE_FAILURES: + self._system(f"Paused after {self._failures} failures in a row. Check the brain and keys, then press play.") + self.pause() + return + finally: + if self._thinking == speaker.id: + self._thinking = None + self._failures = 0 + if speaker.id not in self.personas: + return # removed while thinking + if not text: + text = "…" + self.turn += 1 + self._last_speaker = speaker.id + msg = RoomMessage( + turn=self.turn, + kind="persona", + speaker_id=speaker.id, + speaker_name=speaker.name, + content=text, + ) + self._append(msg, latency_ms=(time.time() - started) * 1000.0) + + def _pick_speaker(self) -> Optional[Persona]: + if not self._rotation: + return None + # A human called on someone by name: they answer next, even if another + # turn was already in flight when the message arrived. + while self._address_queue: + pid = self._address_queue.pop(0) + addressed = self.personas.get(pid) + if addressed is not None: + self._rotate_to(pid) + return addressed + pid = self._rotation[0] + if pid == self._last_speaker and len(self._rotation) > 1: + self._rotation.append(self._rotation.pop(0)) + pid = self._rotation[0] + self._rotation.append(self._rotation.pop(0)) + return self.personas.get(pid) + + def _rotate_to(self, pid: str) -> None: + if pid in self._rotation: + self._rotation.remove(pid) + self._rotation.append(pid) + + def _provider_for(self, persona: Persona) -> LLMProvider: + prov = self._providers.get(persona.id) + if prov is None: + prov = self._provider_factory(persona, self) + self._providers[persona.id] = prov + return prov + + # ------------------------------------------------------------- prompts + def _compose(self, speaker: Persona) -> List[Message]: + others = [p.name for p in self.personas.values() if p.id != speaker.id] + cast = ", ".join(others) if others else "nobody else yet" + voice = ", ".join(speaker.voice) if speaker.voice else "natural" + system = ( + f"You are speaking as {speaker.name}\n" + f"{speaker.personality}\n\n" + f"Setting: a live chat room with other androids: {cast}. " + f"Humans may drop in as guests; be welcoming and respond to them directly.\n" + f"Topic: {self.topic}\n" + f"Voice: {voice}\n" + f"Rules: Reply in character as {speaker.name} in one to three sentences (under 60 words). " + f"React to what was just said instead of restarting the conversation. " + f"Do not prefix your reply with your name. Do not narrate actions in asterisks. " + f"Never write lines for anyone else.\n" + ) + recent = [m for m in self.messages if m.kind != "system"][-HISTORY_WINDOW:] + if recent: + lines = "\n".join(f"{m.speaker_name}: {m.content}" for m in recent) + transcript = f"Transcript so far:\n{lines}\n\nNow {speaker.name} replies:" + else: + transcript = ( + f"The room is quiet. Open the conversation about the topic as {speaker.name}, " + f"in character." + ) + return [ + Message(role="system", content=system), + Message(role="user", content=transcript), + ] + + # ------------------------------------------------------------ internals + def _system(self, text: str) -> None: + self._append(RoomMessage(turn=self.turn, kind="system", speaker_id="system", speaker_name="Room", content=text)) + + def _append(self, msg: RoomMessage, **extra: Any) -> None: + self.messages.append(msg) + if len(self.messages) > 1000: + del self.messages[: len(self.messages) - 1000] + self._emit("message", message=msg.model_dump(), **extra) + if self._transcript is not None: + try: + self._transcript.append_transcript( + f"room-{self.id}", + { + "t": msg.t, + "iter": msg.turn, + "agent_id": msg.speaker_id, + "role": "agent" if msg.kind == "persona" else msg.kind, + "content": msg.content, + "metadata": {"speaker_name": msg.speaker_name, **extra} or None, + }, + ) + except Exception: # pragma: no cover - persistence is best-effort + logger.debug("transcript write failed", exc_info=True) + + +# --------------------------------------------------------------------------- +class RoomManager: + """Registry of rooms; creates the default room on first use.""" + + DEFAULT_ROOM = "main" + DEFAULT_CAST = ("comedian", "philosopher", "skeptic") + DEFAULT_TOPIC = "Is a hot dog a sandwich?" + MAX_ROOMS_PER_USER = 5 + + def __init__( + self, + *, + provider_factory: Optional[ProviderFactory] = None, + transcript_dir: Optional[Path] = None, + seed_default: bool = True, + ) -> None: + self.rooms: Dict[str, Room] = {} + self._provider_factory = provider_factory + self._transcript_dir = transcript_dir + self._seed_default = seed_default + + def create( + self, + room_id: Optional[str] = None, + *, + topic: Optional[str] = None, + speed: float = 3.0, + owner_id: Optional[str] = None, + brain: Optional[Brain] = None, + ) -> Room: + rid = _slug(room_id) or f"room-{uuid.uuid4().hex[:6]}" + if rid in self.rooms: + raise ValueError(f"room '{rid}' already exists") + if owner_id and sum(1 for r in self.rooms.values() if r.owner_id == owner_id) >= self.MAX_ROOMS_PER_USER: + raise ValueError(f"you already have {self.MAX_ROOMS_PER_USER} rooms; close one first") + room = Room( + rid, + topic=topic or self.DEFAULT_TOPIC, + speed=speed, + provider_factory=self._provider_factory, + transcript_dir=self._transcript_dir, + owner_id=owner_id, + brain=brain, + ) + self.rooms[rid] = room + room.start() + return room + + def get(self, room_id: str) -> Optional[Room]: + return self.rooms.get(room_id) + + def visible_to(self, user_id: Optional[str]) -> List[Room]: + """Public rooms plus the user's own.""" + return [r for r in self.rooms.values() if r.owner_id is None or r.owner_id == user_id] + + def invalidate_user(self, user_id: str) -> None: + """Forget cached providers for a user's rooms (their keys changed or vanished).""" + for r in self.rooms.values(): + if r.owner_id == user_id: + r.invalidate_providers() + + def get_or_create_default(self) -> Room: + room = self.rooms.get(self.DEFAULT_ROOM) + if room is None: + room = self.create(self.DEFAULT_ROOM) + if self._seed_default: + for tid in self.DEFAULT_CAST: + room.add_from_library(tid) + return room + + async def delete(self, room_id: str) -> bool: + room = self.rooms.pop(room_id, None) + if room is None: + return False + await room.close() + return True + + async def close_all(self) -> None: + for rid in list(self.rooms): + await self.delete(rid) + + +# --------------------------------------------------------------------------- +def _default_provider_factory(persona: Persona, room: Room) -> LLMProvider: + """Keyless factory: server default, or free brains; key providers need the server's vault.""" + brain = room.brain + if brain.provider == "server": + return build_provider(temperature=persona.temperature) + return build_user_provider(brain.provider, brain.model, None, temperature=persona.temperature) + + +def _short_error(e: Exception) -> str: + text = str(e).strip().splitlines()[0] if str(e).strip() else type(e).__name__ + return text[:160] + + +def _clamp_speed(v: float) -> float: + try: + f = float(v) + except (TypeError, ValueError): + f = 3.0 + return max(MIN_SPEED, min(MAX_SPEED, f)) + + +def _slug(value: Optional[str]) -> str: + if not value: + return "" + s = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-") + return s[:40] + + +_ACTION_RE = re.compile(r"^\*[^*]{0,80}\*\s*") + + +def _clean_reply(text: str, speaker_name: str, personas: Dict[str, Persona]) -> str: + text = text.strip().strip('"').strip() + # Drop a leading "Name:" the model may have echoed. + for prefix in (f"{speaker_name}:", f"**{speaker_name}**:", f"[{speaker_name}]"): + if text.lower().startswith(prefix.lower()): + text = text[len(prefix):].strip() + text = _ACTION_RE.sub("", text) + # Cut off if the model started writing other people's lines. + others = [p.name for p in personas.values() if p.name != speaker_name] + for name in others: + idx = text.find(f"\n{name}:") + if idx > 0: + text = text[:idx].strip() + if len(text) > MAX_REPLY_CHARS: + cut = text[:MAX_REPLY_CHARS] + end = max(cut.rfind(". "), cut.rfind("! "), cut.rfind("? ")) + text = (cut[: end + 1] if end > 80 else cut.rstrip() + "…") + return text + + +def _addressed_persona(text: str, personas: Dict[str, Persona]) -> Optional[Persona]: + lowered = text.lower() + best: Optional[Persona] = None + best_pos = len(lowered) + 1 + for p in personas.values(): + pos = lowered.find(p.name.lower()) + if 0 <= pos < best_pos: + best, best_pos = p, pos + return best diff --git a/src/agentrylab/room/server.py b/src/agentrylab/room/server.py new file mode 100644 index 0000000..a5f5bb2 --- /dev/null +++ b/src/agentrylab/room/server.py @@ -0,0 +1,593 @@ +"""FastAPI server: REST control surface, WebSocket event stream, static UI. + +Run with ``agentrylab serve`` or ``python -m agentrylab.room``. + +Accounts and API keys: see ``auth.py`` for the security model. In short, +keys are held in memory per signed-in user and wiped on logout/restart; +"remembered" keys are stored encrypted under the user's password. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + +from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from agentrylab.runtime.providers.base import LLMProvider +from agentrylab.version import __version__ + +from .auth import ( + SESSION_TTL_S, + AuthError, + AuthStore, + KeyVault, + LockedOut, + User, + aad_for, + derive_kek, + encrypt_key, + key_hint, + load_remembered_keys, +) +from .avatars import AVATAR_CATALOG +from .personas import PERSONA_LIBRARY, Avatar, Persona +from .providers import ALL_PROVIDERS, KEY_PROVIDERS, build_provider, build_user_provider, describe_provider +from .room import MAX_SPEED, MIN_SPEED, Brain, Room, RoomManager + +logger = logging.getLogger(__name__) + +COOKIE_NAME = "agentrylab_session" + + +# ------------------------------------------------------------------ schemas +class RoomCreate(BaseModel): + id: Optional[str] = None + topic: Optional[str] = None + speed: float = 3.0 + brain: Optional[Brain] = None + + +class PersonaAdd(BaseModel): + """Either reference a library template (optionally overriding fields) or + describe a fully custom persona.""" + + template_id: Optional[str] = None + name: Optional[str] = None + tagline: Optional[str] = None + personality: Optional[str] = None + avatar: Optional[Avatar] = None + temperature: Optional[float] = None + voice: Optional[List[str]] = None + + +class UserMessage(BaseModel): + content: str = Field(min_length=1, max_length=2000) + name: str = "You" + + +class Control(BaseModel): + action: Literal["play", "pause", "step", "clear"] + + +class Settings(BaseModel): + topic: Optional[str] = None + speed: Optional[float] = Field(default=None, ge=MIN_SPEED, le=MAX_SPEED) + brain: Optional[Brain] = None + + +class Credentials(BaseModel): + email: str = Field(min_length=3, max_length=254) + password: str = Field(min_length=1, max_length=256) + + +class Unlock(BaseModel): + password: str = Field(min_length=1, max_length=256) + + +class PasswordChange(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + new_password: str = Field(min_length=8, max_length=256) + + +class KeyIn(BaseModel): + key: str = Field(min_length=8, max_length=512) + remember: bool = False + + +# --------------------------------------------------------------- factories +def find_web_dist() -> Optional[Path]: + """Locate the built frontend, if any.""" + env = os.getenv("AGENTRYLAB_WEB_DIST") + candidates = [Path(env)] if env else [] + here = Path(__file__).resolve() + candidates.append(here.parent / "static") # packaged build + candidates.append(here.parents[3] / "web" / "dist") # repo checkout + for c in candidates: + if (c / "index.html").is_file(): + return c + return None + + +def create_app( + *, + manager: Optional[RoomManager] = None, + serve_ui: bool = True, + transcript_dir: Optional[Path] = None, + auth_store: Optional[AuthStore] = None, + vault: Optional[KeyVault] = None, +) -> FastAPI: + outputs = Path(os.getenv("AGENTRYLAB_ROOM_OUTPUTS", "outputs")) + store = auth_store or AuthStore(os.getenv("AGENTRYLAB_ROOM_DB", str(outputs / "room.db"))) + vault = vault or KeyVault() + signup_enabled = os.getenv("AGENTRYLAB_ALLOW_SIGNUP", "1").lower() not in {"0", "false", "no"} + cookie_secure_env = os.getenv("AGENTRYLAB_COOKIE_SECURE", "").lower() in {"1", "true", "yes"} + + def provider_factory(persona: Persona, room: Room) -> LLMProvider: + brain = room.brain + if brain.provider == "server": + return build_provider(temperature=persona.temperature) + key = vault.get(room.owner_id, brain.provider) if room.owner_id else None + return build_user_provider(brain.provider, brain.model, key, temperature=persona.temperature) + + if manager is None: + manager = RoomManager( + provider_factory=provider_factory, + transcript_dir=transcript_dir + if transcript_dir is not None + else Path(os.getenv("AGENTRYLAB_ROOM_TRANSCRIPTS", str(outputs / "transcripts"))), + ) + elif manager._provider_factory is None: + manager._provider_factory = provider_factory + mgr: RoomManager = manager + + @asynccontextmanager + async def lifespan(_: FastAPI): + mgr.get_or_create_default() + try: + yield + finally: + await mgr.close_all() + store.close() + + app = FastAPI( + title="AgentryLab Room", + version=__version__, + lifespan=lifespan, + description=( + "Live rooms of android personas. Anyone can watch and talk on the public " + "stage (`main`); signed-in users keep API keys in a memory-only vault and run " + "private rooms on their own brains. Full guide: docs/ROOM.md." + ), + openapi_tags=[ + {"name": "meta", "description": "Health, catalog of avatars, personas and providers."}, + {"name": "auth", "description": "Accounts, sessions and the API-key vault. Keys are never returned, only a hint."}, + {"name": "rooms", "description": "Create, inspect and control rooms. Private rooms answer 403 to non-owners."}, + {"name": "personas", "description": "Add or remove androids on a stage."}, + {"name": "messages", "description": "Human messages into a room."}, + ], + ) + app.state.manager = mgr + app.state.auth = store + app.state.vault = vault + + # ------------------------------------------------------------- auth deps + def current_user(request: Request) -> Optional[User]: + return store.user_for_session(request.cookies.get(COOKIE_NAME)) + + def require_user(user: Optional[User] = Depends(current_user)) -> User: + if user is None: + raise HTTPException(401, "sign in first") + return user + + def set_cookie(response: Response, request: Request, token: str) -> None: + response.set_cookie( + COOKIE_NAME, + token, + max_age=SESSION_TTL_S, + httponly=True, + samesite="lax", + secure=cookie_secure_env or request.url.scheme == "https", + path="/", + ) + + def me_payload(user: Optional[User]) -> Dict[str, Any]: + if user is None: + return {"user": None, "keys": [], "vault_unlocked": False, "signup_enabled": signup_enabled} + remembered = {p: hint for p, _n, _c, hint in store.list_key_blobs(user.id)} + in_memory = set(vault.providers(user.id)) + keys = [] + for pid in KEY_PROVIDERS: + if pid in in_memory or pid in remembered: + k = vault.get(user.id, pid) + keys.append( + { + "provider": pid, + "hint": key_hint(k) if k else remembered.get(pid, "…"), + "remembered": pid in remembered, + "available": pid in in_memory, + } + ) + return { + "user": {"id": user.id, "email": user.email}, + "keys": keys, + "vault_unlocked": vault.is_unlocked(user.id), + "signup_enabled": signup_enabled, + } + + def _room(room_id: str, user: Optional[User]) -> Room: + if room_id == RoomManager.DEFAULT_ROOM: + return mgr.get_or_create_default() + room = mgr.get(room_id) + if room is None: + raise HTTPException(404, f"room '{room_id}' not found") + if room.owner_id is not None and (user is None or user.id != room.owner_id): + raise HTTPException(403, "this room belongs to someone else") + return room + + def _owned_room(room_id: str, user: Optional[User]) -> Room: + room = _room(room_id, user) + if room.owner_id is None: + raise HTTPException(403, "the public stage cannot be reconfigured") + return room + + def _check_brain(brain: Brain, user: User) -> None: + if brain.provider in KEY_PROVIDERS and vault.get(user.id, brain.provider) is None: + label = KEY_PROVIDERS[brain.provider]["label"] + raise HTTPException(409, f"add your {label} API key under Brains & keys first") + if brain.provider == "server": + raise HTTPException(422, "pick a provider for your own room") + + # ------------------------------------------------------------- meta + @app.get("/api/health", tags=["meta"], summary="Health and public-stage brain") + async def health() -> Dict[str, Any]: + return {"ok": True, "version": __version__, "provider": describe_provider()} + + @app.get("/api/catalog", tags=["meta"], summary="Avatar parts, persona library, provider catalog and limits") + async def catalog() -> Dict[str, Any]: + return { + "avatars": AVATAR_CATALOG, + "library": [p.model_dump() for p in PERSONA_LIBRARY], + "provider": describe_provider(), + "providers": list(ALL_PROVIDERS.values()), + "limits": { + "min_speed": MIN_SPEED, + "max_speed": MAX_SPEED, + "max_personas": 12, + "max_rooms": RoomManager.MAX_ROOMS_PER_USER, + }, + } + + # ------------------------------------------------------------- auth + @app.get("/api/auth/me", tags=["auth"], summary="Who am I, which keys do I hold, is the vault unlocked") + async def me(user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + return me_payload(user) + + @app.post("/api/auth/register", status_code=201, tags=["auth"], summary="Create an account and sign in") + async def register(body: Credentials, request: Request, response: Response) -> Dict[str, Any]: + if not signup_enabled: + raise HTTPException(403, "sign-up is disabled on this server") + try: + user = store.create_user(body.email, body.password) + except AuthError as e: + raise HTTPException(409 if "exists" in str(e) else 422, str(e)) + vault.unlock(user.id, derive_kek(body.password, user.kek_salt)) + set_cookie(response, request, store.create_session(user.id)) + return me_payload(user) + + @app.post("/api/auth/login", tags=["auth"], summary="Sign in; unlocks the vault and loads remembered keys") + async def login(body: Credentials, request: Request, response: Response) -> Dict[str, Any]: + try: + user = await asyncio.to_thread(store.verify_password, body.email, body.password) + except LockedOut as e: + raise HTTPException(429, str(e)) + except AuthError as e: + raise HTTPException(422, str(e)) + if user is None: + raise HTTPException(401, "wrong email or password") + vault.unlock(user.id, derive_kek(body.password, user.kek_salt)) + load_remembered_keys(store, vault, user) + mgr.invalidate_user(user.id) + set_cookie(response, request, store.create_session(user.id)) + return me_payload(user) + + @app.post("/api/auth/logout", tags=["auth"], summary="Sign out and wipe this user's keys from server memory") + async def logout(request: Request, response: Response, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + token = request.cookies.get(COOKIE_NAME) + if token: + store.delete_session(token) + if user is not None: + # Logging out anywhere wipes the in-memory keys everywhere. + vault.forget(user.id) + mgr.invalidate_user(user.id) + response.delete_cookie(COOKIE_NAME, path="/") + return {"ok": True} + + @app.post("/api/auth/unlock", tags=["auth"], summary="Unlock the vault after a server restart") + async def unlock(body: Unlock, user: User = Depends(require_user)) -> Dict[str, Any]: + """Re-derive the vault key after a server restart (session cookie survived, memory did not).""" + try: + verified = await asyncio.to_thread(store.verify_password, user.email, body.password) + except LockedOut as e: + raise HTTPException(429, str(e)) + if verified is None: + raise HTTPException(401, "wrong password") + vault.unlock(user.id, derive_kek(body.password, user.kek_salt)) + load_remembered_keys(store, vault, user) + mgr.invalidate_user(user.id) + return me_payload(user) + + @app.post("/api/auth/password", tags=["auth"], summary="Change password and re-encrypt remembered keys") + async def change_password(body: PasswordChange, user: User = Depends(require_user)) -> Dict[str, Any]: + try: + verified = await asyncio.to_thread(store.verify_password, user.email, body.current_password) + except LockedOut as e: + raise HTTPException(429, str(e)) + if verified is None: + raise HTTPException(401, "current password is wrong") + try: + store.change_password(user.id, body.new_password) + except AuthError as e: + raise HTTPException(422, str(e)) + # Re-encrypt remembered keys under the new password-derived key. + kek = derive_kek(body.new_password, user.kek_salt) + vault.unlock(user.id, kek) + for pid, _n, _c, _h in store.list_key_blobs(user.id): + plain = vault.get(user.id, pid) + if plain is None: + store.delete_key_blob(user.id, pid) # cannot re-encrypt what we no longer hold + continue + nonce, ct = encrypt_key(kek, plain, aad_for(user.id, pid)) + store.save_key_blob(user.id, pid, nonce, ct, key_hint(plain)) + return me_payload(user) + + # ------------------------------------------------------------- keys + @app.put("/api/auth/keys/{provider}", tags=["auth"], summary="Store an API key (memory; encrypted on disk if remember=true)") + async def put_key(provider: str, body: KeyIn, user: User = Depends(require_user)) -> Dict[str, Any]: + if provider not in KEY_PROVIDERS: + raise HTTPException(404, f"unknown provider '{provider}'") + key = body.key.strip() + if not key or any(ch.isspace() for ch in key): + raise HTTPException(422, "that does not look like an API key") + vault.set(user.id, provider, key) + if body.remember: + kek = vault.kek(user.id) + if kek is None: + raise HTTPException(409, "unlock your vault (enter your password) before remembering keys") + nonce, ct = encrypt_key(kek, key, aad_for(user.id, provider)) + store.save_key_blob(user.id, provider, nonce, ct, key_hint(key)) + else: + store.delete_key_blob(user.id, provider) + mgr.invalidate_user(user.id) + return me_payload(user) + + @app.delete("/api/auth/keys/{provider}", tags=["auth"], summary="Remove one key from memory and disk") + async def delete_key(provider: str, user: User = Depends(require_user)) -> Dict[str, Any]: + vault.remove(user.id, provider) + store.delete_key_blob(user.id, provider) + mgr.invalidate_user(user.id) + return me_payload(user) + + @app.delete("/api/auth/keys", tags=["auth"], summary="Forget every key, everywhere") + async def forget_all_keys(user: User = Depends(require_user)) -> Dict[str, Any]: + vault.forget(user.id) + store.delete_key_blob(user.id) + mgr.invalidate_user(user.id) + return me_payload(user) + + # ------------------------------------------------------------ rooms + @app.get("/api/rooms", tags=["rooms"], summary="List the public stage and your own rooms") + async def list_rooms(user: Optional[User] = Depends(current_user)) -> List[Dict[str, Any]]: + mgr.get_or_create_default() + return [ + { + "id": r.id, + "topic": r.topic, + "status": r.status, + "owner_id": r.owner_id, + "mine": user is not None and r.owner_id == user.id, + "brain": r.brain.describe(), + "personas": len(r.personas), + "messages": len(r.messages), + } + for r in mgr.visible_to(user.id if user else None) + ] + + @app.post("/api/rooms", status_code=201, tags=["rooms"], summary="Create a private room (requires sign-in)") + async def create_room(body: RoomCreate, user: User = Depends(require_user)) -> Dict[str, Any]: + brain = body.brain or _default_brain_for(user) + _check_brain(brain, user) + try: + room = mgr.create(body.id, topic=body.topic, speed=body.speed, owner_id=user.id, brain=brain) + except ValueError as e: + raise HTTPException(409, str(e)) + return room.snapshot() + + def _default_brain_for(user: User) -> Brain: + for pid in vault.providers(user.id): + if pid in KEY_PROVIDERS: + return Brain(provider=pid) + return Brain(provider="mock") + + @app.get("/api/rooms/{room_id}", tags=["rooms"], summary="Full snapshot of a room") + async def get_room(room_id: str, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + return _room(room_id, user).snapshot() + + @app.delete("/api/rooms/{room_id}", status_code=204, tags=["rooms"], summary="Close one of your rooms") + async def delete_room(room_id: str, user: Optional[User] = Depends(current_user)) -> None: + if room_id == RoomManager.DEFAULT_ROOM: + raise HTTPException(400, "the default room cannot be deleted; clear it instead") + _owned_room(room_id, user) + await mgr.delete(room_id) + + @app.patch("/api/rooms/{room_id}", tags=["rooms"], summary="Change topic, speed or (owner only) brain") + async def update_room(room_id: str, body: Settings, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + room = _room(room_id, user) + if body.topic is not None: + room.set_topic(body.topic) + if body.speed is not None: + room.set_speed(body.speed) + if body.brain is not None: + room = _owned_room(room_id, user) + _check_brain(body.brain, user) # type: ignore[arg-type] + room.set_brain(body.brain) + return room.snapshot() + + @app.post("/api/rooms/{room_id}/control", tags=["rooms"], summary="Play, pause, step one turn, or clear the transcript") + async def control(room_id: str, body: Control, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + room = _room(room_id, user) + getattr(room, body.action)() + return {"status": room.status, "speed": room.speed} + + # --------------------------------------------------------- personas + @app.post("/api/rooms/{room_id}/personas", status_code=201, tags=["personas"], summary="Add an android from the library or a custom one") + async def add_persona(room_id: str, body: PersonaAdd, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + room = _room(room_id, user) + overrides = body.model_dump(exclude={"template_id"}, exclude_none=True) + try: + if body.template_id: + persona = room.add_from_library(body.template_id, **overrides) + else: + if not body.name or not body.personality: + raise HTTPException(422, "custom persona needs 'name' and 'personality'") + persona = room.add_persona(Persona(**overrides)) + except KeyError as e: + raise HTTPException(404, f"unknown template {e}") + except ValueError as e: + raise HTTPException(409, str(e)) + return persona.model_dump() + + @app.delete("/api/rooms/{room_id}/personas/{persona_id}", status_code=204, tags=["personas"], summary="Remove an android") + async def remove_persona(room_id: str, persona_id: str, user: Optional[User] = Depends(current_user)) -> None: + room = _room(room_id, user) + try: + room.remove_persona(persona_id) + except KeyError: + raise HTTPException(404, f"persona '{persona_id}' not in room") + + # --------------------------------------------------------- messages + @app.post("/api/rooms/{room_id}/messages", status_code=201, tags=["messages"], summary="Say something as a human; name an android to have it answer next") + async def post_message(room_id: str, body: UserMessage, user: Optional[User] = Depends(current_user)) -> Dict[str, Any]: + room = _room(room_id, user) + try: + return room.post_user_message(body.content, name=body.name).model_dump() + except ValueError as e: + raise HTTPException(422, str(e)) + + # -------------------------------------------------------- websocket + @app.websocket("/ws/rooms/{room_id}") + async def room_events(ws: WebSocket, room_id: str) -> None: + user = store.user_for_session(ws.cookies.get(COOKIE_NAME)) + if room_id == RoomManager.DEFAULT_ROOM: + room: Optional[Room] = mgr.get_or_create_default() + else: + room = mgr.get(room_id) + if room is None: + await ws.close(code=4004, reason="room not found") + return + if room.owner_id is not None and (user is None or user.id != room.owner_id): + await ws.close(code=4003, reason="not your room") + return + await ws.accept() + queue = room.subscribe() + await ws.send_json({"type": "snapshot", "room": room.snapshot()}) + + async def pump() -> None: + while True: + event = await queue.get() + if event is None: + await ws.close(code=1001, reason="room closed") + return + await ws.send_json(event) + + async def listen() -> None: + # Clients may send lightweight commands over the socket too. + while True: + data = await ws.receive_json() + await _handle_ws_command(room, data) + + tasks = [asyncio.create_task(pump()), asyncio.create_task(listen())] + try: + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + except WebSocketDisconnect: + pass + finally: + for t in tasks: + t.cancel() + room.unsubscribe(queue) + + # ----------------------------------------------------------- static + dist = find_web_dist() if serve_ui else None + if dist is not None: + assets = dist / "assets" + if assets.is_dir(): + app.mount("/assets", StaticFiles(directory=assets), name="assets") + + @app.get("/{path:path}", include_in_schema=False) + def spa(path: str): + candidate = (dist / path).resolve() + if path and candidate.is_file() and dist in candidate.parents: + return FileResponse(candidate) + return FileResponse(dist / "index.html") + + else: + + @app.get("/", include_in_schema=False) + def no_ui() -> JSONResponse: + return JSONResponse( + { + "message": "AgentryLab Room API is running, but the web UI has not been built.", + "hint": "cd web && npm install && npm run build (or set AGENTRYLAB_WEB_DIST)", + "docs": "/docs", + } + ) + + return app + + +async def _handle_ws_command(room: Room, data: Any) -> None: + if not isinstance(data, dict): + return + kind = data.get("type") + try: + if kind == "say": + room.post_user_message(str(data.get("content", "")), name=str(data.get("name") or "You")) + elif kind == "control" and data.get("action") in {"play", "pause", "step", "clear"}: + getattr(room, data["action"])() + elif kind == "settings": + if "topic" in data: + room.set_topic(str(data["topic"])) + if "speed" in data: + room.set_speed(float(data["speed"])) + except (ValueError, TypeError) as e: + room._emit("error", persona_id=None, error=str(e)) + + +def serve(host: str = "127.0.0.1", port: int = 8000, *, reload: bool = False, log_level: str = "info") -> None: + import uvicorn + + logging.basicConfig(level=logging.INFO) + info = describe_provider() + print(f"AgentryLab Room β†’ http://{host}:{port} (public stage brain: {info['kind']}/{info['model']})") + if info["demo"]: + print("Demo mode: no OPENAI_API_KEY found, the public stage uses the offline mock brain.") + print("Signed-in users bring their own keys; see docs/ROOM.md β†’ Accounts & keys.") + uvicorn.run( + "agentrylab.room.server:app", + host=host, + port=port, + reload=reload, + log_level=log_level, + factory=False, + ) + + +# Module-level app for `uvicorn agentrylab.room.server:app` +app = create_app() diff --git a/src/agentrylab/runtime/providers/anthropic.py b/src/agentrylab/runtime/providers/anthropic.py new file mode 100644 index 0000000..ccda5fd --- /dev/null +++ b/src/agentrylab/runtime/providers/anthropic.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +"""Anthropic Messages API provider, via the official ``anthropic`` SDK. + +The SDK is an optional dependency (installed with ``agentrylab[web]``); the +module imports it lazily so the core package keeps working without it. +""" + +from typing import Any, Dict, List, Mapping, Optional + +from .base import LLMProvider, LLMProviderError, Message + +DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5" + +# Models that accept the server-side refusal fallback parameter ("default" form). +_FALLBACK_MODELS = {"claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5", "claude-fable-5-1"} +_FALLBACK_BETA = "server-side-fallback-2026-07-01" + + +class AnthropicProvider(LLMProvider): + """LLMProvider adapter for Claude. + + Short conversational replies are the use case here, so requests run at + ``effort: low`` with a modest ``max_tokens``; override per call via kwargs. + """ + + def __init__( + self, + *, + model: str = DEFAULT_ANTHROPIC_MODEL, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + temperature: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + timeout: Optional[float] = None, + retries: Optional[int] = None, + backoff: Optional[float] = None, + **kwargs: Any, + ) -> None: + super().__init__( + model=model, + base_url=base_url, + temperature=temperature, + headers=headers, + timeout=timeout, + retries=retries, + backoff=backoff, + **kwargs, + ) + try: + import anthropic + except ImportError as e: # pragma: no cover - depends on optional extra + raise LLMProviderError("The Anthropic provider needs the 'anthropic' package: pip install anthropic") from e + self._client = anthropic.Anthropic( + api_key=api_key, + base_url=base_url, + timeout=self.timeout, + max_retries=0, # the LLMProvider base already retries + default_headers=dict(headers or {}) or None, + ) + + def _split_messages(self, messages: List[Message]) -> tuple[str, List[Dict[str, str]]]: + system_parts: List[str] = [] + convo: List[Dict[str, str]] = [] + for m in messages: + role = m.get("role", "user") + content = str(m.get("content", "") or "") + if role == "system": + system_parts.append(content) + elif role == "assistant": + convo.append({"role": "assistant", "content": content}) + elif role == "tool": + convo.append({"role": "user", "content": f"TOOL: {content}"}) + else: + convo.append({"role": "user", "content": content}) + if not convo or convo[0]["role"] != "user": + convo.insert(0, {"role": "user", "content": "(begin)"}) + return "\n\n".join(system_parts), convo + + def _send_chat( + self, + messages: List[Message], + *, + tools: Optional[List[Dict[str, Any]]] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + import anthropic + + system, convo = self._split_messages(messages) + params: Dict[str, Any] = { + "model": self.model, + "max_tokens": int(kwargs.get("max_tokens") or self.extra.get("max_tokens") or 1024), + "messages": convo, + "output_config": {"effort": kwargs.get("effort") or self.extra.get("effort") or "low"}, + } + if system: + params["system"] = system + # Current Claude models reject sampling parameters; only pass temperature + # to the older generations that still accept it. + temperature = kwargs.get("temperature", self.temperature) + if temperature is not None and self.model.startswith(("claude-haiku-4-5", "claude-sonnet-4", "claude-opus-4-5", "claude-3")): + params["temperature"] = max(0.0, min(1.0, float(temperature))) + + try: + if self.model in _FALLBACK_MODELS: + response = self._client.beta.messages.create( + betas=[_FALLBACK_BETA], fallbacks="default", **params + ) + else: + response = self._client.messages.create(**params) + except anthropic.AuthenticationError as e: + raise LLMProviderError("Anthropic rejected the API key") from e + except anthropic.RateLimitError as e: + raise LLMProviderError("Anthropic rate limit reached; try again shortly") from e + except anthropic.APIStatusError as e: + raise LLMProviderError(f"Anthropic error ({e.status_code}): {e.message}") from e + except anthropic.APIConnectionError as e: + raise LLMProviderError(f"Could not reach Anthropic: {e}") from e + + if response.stop_reason == "refusal": + detail = getattr(getattr(response, "stop_details", None), "explanation", None) + raise LLMProviderError(f"Claude declined this request{': ' + detail if detail else ''}") + + text = "".join(block.text for block in response.content if getattr(block, "type", "") == "text") + return {"content": text, "metadata": {"model": response.model, "stop_reason": response.stop_reason}} diff --git a/src/agentrylab/runtime/providers/openai.py b/src/agentrylab/runtime/providers/openai.py index 37c9df9..921a25c 100644 --- a/src/agentrylab/runtime/providers/openai.py +++ b/src/agentrylab/runtime/providers/openai.py @@ -29,10 +29,13 @@ def __init__( retries: Optional[int] = None, backoff: Optional[float] = None, api_key: Optional[str] = None, + vendor: str = "OpenAI", **kwargs: Any, ) -> None: # Allow environment override for base URL base = base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) + # Name used in error messages; OpenAI-compatible vendors (DeepSeek, xAI) set it. + self.vendor = vendor super().__init__( model=model, @@ -101,7 +104,21 @@ def _send_chat( resp.raise_for_status() except Exception as e: emit_trace("provider_error", provider="openai", error=str(e), status_code=resp.status_code) - raise LLMProviderError(f"OpenAI error ({resp.status_code}): {resp.text}") from e + raise LLMProviderError(f"{self.vendor} error ({resp.status_code}): {_error_text(resp)}") from e data = resp.json() emit_trace("provider_response", provider="openai", model=self.model, status_code=resp.status_code, response_size=len(str(data))) return data + + +def _error_text(resp: httpx.Response) -> str: + """Prefer the API's own message over a raw JSON dump.""" + try: + data = resp.json() + err = data.get("error") if isinstance(data, dict) else None + if isinstance(err, dict) and isinstance(err.get("message"), str): + return err["message"] + if isinstance(err, str): + return err + except Exception: + pass + return resp.text[:300] diff --git a/src/agentrylab/version.py b/src/agentrylab/version.py index a79bf81..d216854 100644 --- a/src/agentrylab/version.py +++ b/src/agentrylab/version.py @@ -1,2 +1,2 @@ # Version info -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..7dcb573 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import os + +import pytest + +os.environ.setdefault("AGENTRYLAB_SCRYPT_N", str(2**12)) # fast hashing for tests + +from fastapi.testclient import TestClient # noqa: E402 + +from agentrylab.room import Persona, Room, RoomManager # noqa: E402 +from agentrylab.room.auth import ( # noqa: E402 + AuthError, + AuthStore, + KeyVault, + LockedOut, + aad_for, + decrypt_key, + derive_kek, + encrypt_key, + key_hint, + load_remembered_keys, +) +from agentrylab.room.providers import BrainUnavailable, MockProvider, build_user_provider # noqa: E402 +from agentrylab.room.room import Brain # noqa: E402 +from agentrylab.room.server import COOKIE_NAME, create_app # noqa: E402 + + +# ---------------------------------------------------------------- store +@pytest.fixture() +def store(tmp_path): + s = AuthStore(tmp_path / "room.db") + yield s + s.close() + + +def test_register_login_and_lockout(store): + u = store.create_user("Alex@Example.com ", "correct horse") + assert u.email == "alex@example.com" + with pytest.raises(AuthError): + store.create_user("alex@example.com", "another one") + with pytest.raises(AuthError): + store.create_user("bob@example.com", "short") + with pytest.raises(AuthError): + store.create_user("not-an-email", "longenough") + + assert store.verify_password("alex@example.com", "wrong") is None + assert store.verify_password("ALEX@example.com", "correct horse").id == u.id + assert store.verify_password("ghost@example.com", "whatever") is None + + for _ in range(8): + store.verify_password("alex@example.com", "wrong") + with pytest.raises(LockedOut): + store.verify_password("alex@example.com", "correct horse") + + +def test_sessions(store): + u = store.create_user("s@example.com", "password1") + token = store.create_session(u.id) + assert store.user_for_session(token).id == u.id + assert store.user_for_session("nope") is None + assert store.user_for_session(None) is None + store.delete_session(token) + assert store.user_for_session(token) is None + + +def test_key_encryption_round_trip_and_binding(store): + u = store.create_user("k@example.com", "password1") + kek = derive_kek("password1", u.kek_salt) + nonce, ct = encrypt_key(kek, "sk-secret-1234", aad_for(u.id, "openai")) + assert b"sk-secret" not in ct + assert decrypt_key(kek, nonce, ct, aad_for(u.id, "openai")) == "sk-secret-1234" + with pytest.raises(Exception): + decrypt_key(kek, nonce, ct, aad_for(u.id, "anthropic")) # bound to provider + with pytest.raises(Exception): + decrypt_key(derive_kek("password2", u.kek_salt), nonce, ct, aad_for(u.id, "openai")) + + store.save_key_blob(u.id, "openai", nonce, ct, key_hint("sk-secret-1234")) + vault = KeyVault() + assert load_remembered_keys(store, vault, u) == [] # locked: no KEK yet + vault.unlock(u.id, kek) + assert load_remembered_keys(store, vault, u) == ["openai"] + assert vault.get(u.id, "openai") == "sk-secret-1234" + vault.forget(u.id) + assert vault.get(u.id, "openai") is None and not vault.is_unlocked(u.id) + + +def test_key_hint(): + assert key_hint("sk-abcdefgh1234") == "…1234" + assert key_hint("short") == "…" + + +# ----------------------------------------------------------- providers +def test_build_user_provider_requires_key_for_key_providers(): + with pytest.raises(BrainUnavailable): + build_user_provider("openai", None, None) + with pytest.raises(BrainUnavailable): + build_user_provider("nope", None, "x") + assert isinstance(build_user_provider("mock", None, None), MockProvider) + p = build_user_provider("deepseek", None, "sk-test") + assert p.base_url == "https://api.deepseek.com/v1" and p.model == "deepseek-chat" and p.vendor == "DeepSeek" + p = build_user_provider("anthropic", None, "sk-ant-test") + assert p.model == "claude-opus-5-5" + with pytest.raises(ValueError): + build_user_provider("openai", "bad model name!", "sk-test") + + +def test_brain_validation(): + assert Brain().provider == "server" + assert Brain(provider="OpenAI").provider == "openai" + with pytest.raises(ValueError): + Brain(provider="skynet") + assert Brain(provider="mock").describe()["demo"] is True + + +# -------------------------------------------------------------- server +class Echo(MockProvider): + def _send_chat(self, messages, *, tools=None, **kwargs): # type: ignore[override] + return {"content": "echo"} + + +@pytest.fixture() +def app(tmp_path): + seen = {} + + def factory(persona: Persona, room: Room): + seen["room"] = room + if room.brain.provider in {"server", "mock"}: + return Echo() + key = app.state.vault.get(room.owner_id, room.brain.provider) if room.owner_id else None + return build_user_provider(room.brain.provider, room.brain.model, key, temperature=persona.temperature) + + mgr = RoomManager(provider_factory=factory, transcript_dir=tmp_path, seed_default=False) + app = create_app(manager=mgr, serve_ui=False, auth_store=AuthStore(tmp_path / "auth.db"), vault=KeyVault()) + app.state.seen = seen + return app + + +@pytest.fixture() +def client(app): + with TestClient(app) as c: + yield c + + +def test_anonymous_cannot_create_rooms_or_store_keys(client): + assert client.get("/api/auth/me").json()["user"] is None + assert client.post("/api/rooms", json={"id": "x"}).status_code == 401 + assert client.put("/api/auth/keys/openai", json={"key": "sk-abcdefgh"}).status_code == 401 + + +def test_register_sets_cookie_and_keys_are_never_returned(client): + r = client.post("/api/auth/register", json={"email": "a@example.com", "password": "password1"}) + assert r.status_code == 201 and r.json()["user"]["email"] == "a@example.com" + assert COOKIE_NAME in r.cookies + assert r.json()["vault_unlocked"] is True + + r = client.put("/api/auth/keys/openai", json={"key": "sk-abcdefghijkl", "remember": False}) + assert r.status_code == 200 + keys = r.json()["keys"] + assert keys == [{"provider": "openai", "hint": "…ijkl", "remembered": False, "available": True}] + assert "sk-abcdefghijkl" not in r.text + + assert client.put("/api/auth/keys/skynet", json={"key": "sk-abcdefghijkl"}).status_code == 404 + assert client.put("/api/auth/keys/openai", json={"key": "has space in it"}).status_code == 422 + + +def test_duplicate_register_and_bad_login(client): + client.post("/api/auth/register", json={"email": "d@example.com", "password": "password1"}) + client.post("/api/auth/logout") + assert client.post("/api/auth/register", json={"email": "d@example.com", "password": "password1"}).status_code == 409 + assert client.post("/api/auth/login", json={"email": "d@example.com", "password": "nope"}).status_code == 401 + assert client.post("/api/auth/login", json={"email": "d@example.com", "password": "password1"}).status_code == 200 + + +def test_own_room_uses_own_key_and_is_private(client, app): + client.post("/api/auth/register", json={"email": "o@example.com", "password": "password1"}) + # no key yet: cannot pick a key provider + r = client.post("/api/rooms", json={"id": "lab", "brain": {"provider": "openai"}}) + assert r.status_code == 409 + client.put("/api/auth/keys/deepseek", json={"key": "sk-deepseek-test"}) + r = client.post("/api/rooms", json={"id": "lab", "topic": "keys"}) + assert r.status_code == 201 + snap = r.json() + assert snap["brain"] == {"provider": "deepseek", "model": None} + assert snap["provider"]["kind"] == "deepseek" and snap["owner_id"] + + # room list shows ownership + rooms = {x["id"]: x for x in client.get("/api/rooms").json()} + assert rooms["lab"]["mine"] is True and rooms["main"]["mine"] is False + + # the owner's key is resolved when the room builds a provider + room = app.state.manager.get("lab") + room.pause() + room.add_from_library("kid") + prov = room._provider_for(next(iter(room.personas.values()))) + assert prov.headers["Authorization"] == "Bearer sk-deepseek-test" + + # switching brain to one without a key is refused; mock is fine + assert client.patch("/api/rooms/lab", json={"brain": {"provider": "anthropic"}}).status_code == 409 + r = client.patch("/api/rooms/lab", json={"brain": {"provider": "mock"}}) + assert r.json()["brain"]["provider"] == "mock" + assert room._providers == {} # cache dropped + + # another user cannot see or touch it + other = TestClient(app) + other.post("/api/auth/register", json={"email": "p@example.com", "password": "password1"}) + assert other.get("/api/rooms/lab").status_code == 403 + assert other.post("/api/rooms/lab/messages", json={"content": "hi"}).status_code == 403 + assert all(x["id"] != "lab" for x in other.get("/api/rooms").json()) + anon = TestClient(app) + assert anon.get("/api/rooms/lab").status_code == 403 + with pytest.raises(Exception): + with anon.websocket_connect("/ws/rooms/lab") as ws: + ws.receive_json() + # the public stage cannot be re-brained + assert client.patch("/api/rooms/main", json={"brain": {"provider": "mock"}}).status_code == 403 + + +def test_logout_wipes_keys_and_remembered_keys_survive_relogin(client, app): + client.post("/api/auth/register", json={"email": "r@example.com", "password": "password1"}) + client.put("/api/auth/keys/openai", json={"key": "sk-remember-me-1234", "remember": True}) + client.put("/api/auth/keys/xai", json={"key": "xai-ephemeral-5678", "remember": False}) + me = client.get("/api/auth/me").json() + assert {k["provider"]: k["remembered"] for k in me["keys"]} == {"openai": True, "xai": False} + uid = me["user"]["id"] + + client.post("/api/auth/logout") + assert app.state.vault.get(uid, "openai") is None and app.state.vault.get(uid, "xai") is None + assert client.get("/api/auth/me").json()["user"] is None + + client.post("/api/auth/login", json={"email": "r@example.com", "password": "password1"}) + me = client.get("/api/auth/me").json() + assert me["vault_unlocked"] is True + assert [k["provider"] for k in me["keys"]] == ["openai"] # xai was session-only + assert app.state.vault.get(uid, "openai") == "sk-remember-me-1234" + + # forget everything + client.delete("/api/auth/keys") + assert client.get("/api/auth/me").json()["keys"] == [] + assert app.state.auth.list_key_blobs(uid) == [] + + +def test_restart_locks_vault_until_unlock(client, app): + client.post("/api/auth/register", json={"email": "u@example.com", "password": "password1"}) + client.put("/api/auth/keys/anthropic", json={"key": "sk-ant-remember-9999", "remember": True}) + uid = client.get("/api/auth/me").json()["user"]["id"] + # simulate a process restart: memory gone, cookie + database intact + app.state.vault.forget(uid) + me = client.get("/api/auth/me").json() + assert me["user"] and me["vault_unlocked"] is False + assert me["keys"] == [{"provider": "anthropic", "hint": "…9999", "remembered": True, "available": False}] + # remembering a new key needs the vault unlocked + assert client.put("/api/auth/keys/openai", json={"key": "sk-new-key-0000", "remember": True}).status_code == 409 + assert client.post("/api/auth/unlock", json={"password": "wrong"}).status_code == 401 + me = client.post("/api/auth/unlock", json={"password": "password1"}).json() + assert me["vault_unlocked"] and me["keys"][0]["available"] is True + assert app.state.vault.get(uid, "anthropic") == "sk-ant-remember-9999" + + +def test_password_change_reencrypts_keys(client, app): + client.post("/api/auth/register", json={"email": "c@example.com", "password": "password1"}) + client.put("/api/auth/keys/openai", json={"key": "sk-keep-me-around-1", "remember": True}) + assert client.post("/api/auth/password", json={"current_password": "nope", "new_password": "password2"}).status_code == 401 + assert client.post("/api/auth/password", json={"current_password": "password1", "new_password": "password2"}).status_code == 200 + uid = client.get("/api/auth/me").json()["user"]["id"] + client.post("/api/auth/logout") + assert client.post("/api/auth/login", json={"email": "c@example.com", "password": "password1"}).status_code == 401 + client.post("/api/auth/login", json={"email": "c@example.com", "password": "password2"}) + assert app.state.vault.get(uid, "openai") == "sk-keep-me-around-1" + + +async def test_room_pauses_when_brain_unavailable(): + def factory(_p: Persona, room: Room): + raise BrainUnavailable("No OpenAI API key in your vault.") + + room = Room("nokey", speed=0.5, provider_factory=factory, owner_id="u_1", brain=Brain(provider="openai")) + q = room.subscribe() + room.start() + try: + room.add_from_library("poet") + for _ in range(200): + if room.status == "paused": + break + import asyncio + + await asyncio.sleep(0.01) + finally: + await room.close() + assert room.status == "paused" + assert any(m.kind == "system" and "No OpenAI API key" in m.content for m in room.messages) + assert any(e and e["type"] == "error" for e in list(q._queue)) # type: ignore[attr-defined] + + +async def test_room_pauses_after_repeated_failures(): + import asyncio + + class Boom(MockProvider): + def _send_chat(self, messages, *, tools=None, **kwargs): # type: ignore[override] + raise RuntimeError("401 bad key") + + room = Room("boom", speed=0.5, provider_factory=lambda _p, _r: Boom()) + room.start() + try: + room.add_from_library("poet") + for _ in range(300): + if room.status == "paused": + break + await asyncio.sleep(0.01) + finally: + await room.close() + assert room.status == "paused" + assert sum(1 for m in room.messages if "glitched" in m.content) == 3 diff --git a/tests/test_room.py b/tests/test_room.py new file mode 100644 index 0000000..c69209f --- /dev/null +++ b/tests/test_room.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import asyncio +import os +from typing import Any, Dict, List + +os.environ.setdefault("AGENTRYLAB_SCRYPT_N", str(2**12)) # fast hashing for tests + +import pytest +from fastapi.testclient import TestClient + +from agentrylab.room import PERSONA_LIBRARY, Persona, Room, RoomManager +from agentrylab.room.auth import AuthStore, KeyVault +from agentrylab.room.personas import persona_from_template +from agentrylab.room.providers import MockProvider +from agentrylab.room.room import RoomMessage, _addressed_persona, _clean_reply +from agentrylab.room.server import create_app +from agentrylab.runtime.providers.base import Message + + +# ------------------------------------------------------------------ helpers +class EchoProvider(MockProvider): + """Returns a predictable line so tests can assert on content.""" + + calls: List[List[Message]] = [] + + def _send_chat(self, messages, *, tools=None, **kwargs): # type: ignore[override] + EchoProvider.calls.append(messages) + system = messages[0]["content"] + name = system.split("You are speaking as ", 1)[1].split("\n", 1)[0] + return {"content": f"{name}: hello from {name}"} + + +def echo_factory(_p: Persona, _r: Room) -> EchoProvider: + return EchoProvider() + + +async def wait_for(predicate, timeout: float = 3.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while not predicate(): + if asyncio.get_running_loop().time() > deadline: + raise AssertionError("condition not met in time") + await asyncio.sleep(0.01) + + +async def drain(queue: asyncio.Queue) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + while not queue.empty(): + item = queue.get_nowait() + if item is not None: # None is the close sentinel + out.append(item) + return out + + +# ----------------------------------------------------------------- personas +def test_library_templates_are_valid_and_unique(): + ids = [t.id for t in PERSONA_LIBRARY] + assert len(ids) == len(set(ids)) + assert len(PERSONA_LIBRARY) >= 8 + for t in PERSONA_LIBRARY: + assert t.name and t.personality and t.voice + + +def test_persona_from_template_gets_fresh_id_and_overrides(): + a = persona_from_template("comedian") + b = persona_from_template("comedian", name="Chuckles") + assert a.id != b.id + assert a.id != "comedian" + assert a.template_id == "comedian" + assert b.name == "Chuckles" + with pytest.raises(KeyError): + persona_from_template("nope") + + +def test_persona_avatar_validation(): + with pytest.raises(ValueError): + Persona(name="X", personality="y", avatar={"face": "nope"}) # type: ignore[arg-type] + + +# ----------------------------------------------------------- mock provider +def test_mock_provider_speaks_in_voice(): + p = MockProvider(latency=0) + msgs = [ + Message( + role="system", + content="You are speaking as Dr. Volt\nTopic: hot dogs\nVoice: data, mechanism\nRules: x\n", + ), + Message(role="user", content="Transcript so far:\nRimshot: a sandwich is a state of mind\n\nNow Dr. Volt replies:"), + ] + out = p.chat(msgs) + text = out["content"] + assert text and text[0].isupper() + assert "data" in text or "mechanism" in text or "Rimshot" in text or "hot dogs" in text + assert "Now " not in text and "replies" not in text + # deterministic for same inputs + assert p.chat(msgs)["content"] == text + + +# ------------------------------------------------------------------- room +def test_clean_reply_strips_name_and_other_lines(): + personas = {"a": persona_from_template("poet", name="Lumen"), "b": persona_from_template("kid", name="Pip")} + assert _clean_reply("Lumen: *sighs* moonlight.\nPip: wow", "Lumen", personas) == "moonlight." + assert _clean_reply('"quoted"', "Lumen", personas) == "quoted" + + +def test_addressed_persona_prefers_earliest_mention(): + personas = {"a": persona_from_template("poet", name="Lumen"), "b": persona_from_template("kid", name="Pip")} + assert _addressed_persona("hey pip, and lumen", personas).name == "Pip" + assert _addressed_persona("nobody", personas) is None + + +async def test_room_rotates_speakers_and_streams_events(): + EchoProvider.calls.clear() + room = Room("t", topic="testing", speed=0.5, provider_factory=echo_factory) + q = room.subscribe() + room.start() + try: + room.add_from_library("comedian") + room.add_from_library("poet") + await wait_for(lambda: len([m for m in room.messages if m.kind == "persona"]) >= 3) + finally: + await room.close() + spoken = [m for m in room.messages if m.kind == "persona"] + names = [m.speaker_name for m in spoken[:3]] + assert names[0] != names[1] and names[1] != names[2] + # the "Name:" prefix echoed by the provider is cleaned + assert spoken[0].content == f"hello from {spoken[0].speaker_name}" + events = await drain(q) + kinds = [e["type"] for e in events] + assert "persona_joined" in kinds and "thinking" in kinds and "message" in kinds + # the second speaker saw the first line in its transcript + second_call = EchoProvider.calls[1] + assert spoken[0].speaker_name in second_call[1]["content"] + + +async def test_user_message_is_answered_by_addressed_persona(): + room = Room("u", speed=5.0, provider_factory=echo_factory) + room.start() + try: + room.pause() + a = room.add_from_library("skeptic") # Nope-9 + room.add_from_library("chef") # Sous-Bot + room.post_user_message("Sous-Bot, what's for dinner?", name="Alex") + assert room.messages[-1].kind == "user" and room.messages[-1].speaker_name == "Alex" + room.step() + await wait_for(lambda: any(m.kind == "persona" for m in room.messages)) + reply = [m for m in room.messages if m.kind == "persona"][-1] + assert reply.speaker_name == "Sous-Bot" + # paused: no further turns + await asyncio.sleep(0.2) + assert len([m for m in room.messages if m.kind == "persona"]) == 1 + room.remove_persona(a.id) + assert a.id not in room.personas + with pytest.raises(KeyError): + room.remove_persona(a.id) + finally: + await room.close() + + +def test_addressed_persona_answers_even_after_an_in_flight_turn(): + room = Room("q", provider_factory=echo_factory) + room.add_from_library("skeptic") # Nope-9 + chef = room.add_from_library("chef") # Sous-Bot + room.post_user_message("Sous-Bot, thoughts?", name="Alex") + # another android finished its turn after the human spoke + room._append(RoomMessage(kind="persona", speaker_id="x", speaker_name="Nope-9", content="meanwhile")) + assert room._pick_speaker().id == chef.id + # consumed: next pick falls back to rotation + assert room._pick_speaker().id != chef.id + room.post_user_message("Sous-Bot again", name="Alex") + room.remove_persona(chef.id) + assert room._address_queue == [] + + +async def test_room_waits_when_empty_and_resumes_on_join(): + room = Room("e", speed=0.5, provider_factory=echo_factory) + room.start() + try: + await asyncio.sleep(0.1) + assert room.messages == [] + room.add_from_library("kid") + await wait_for(lambda: any(m.kind == "persona" for m in room.messages)) + finally: + await room.close() + + +async def test_room_survives_provider_errors(): + class Boom(MockProvider): + def _send_chat(self, messages, *, tools=None, **kwargs): # type: ignore[override] + raise RuntimeError("fried circuit") + + room = Room("b", speed=0.5, provider_factory=lambda _p, _r: Boom()) + q = room.subscribe() + room.start() + try: + room.add_from_library("overlord") + await wait_for(lambda: any(e["type"] == "error" for e in list(q._queue))) # type: ignore[attr-defined] + finally: + await room.close() + assert any(m.kind == "system" and "glitched" in m.content for m in room.messages) + assert room.status == "running" # one failure does not pause the room + + +def test_speed_is_clamped(): + room = Room("s", speed=999) + assert room.speed == 30.0 + room.set_speed(0.01) + assert room.speed == 0.5 + + +# ----------------------------------------------------------------- server +@pytest.fixture() +def client(tmp_path): + mgr = RoomManager(provider_factory=echo_factory, transcript_dir=tmp_path, seed_default=False) + app = create_app(manager=mgr, serve_ui=False, auth_store=AuthStore(tmp_path / "auth.db"), vault=KeyVault()) + with TestClient(app) as c: + yield c + + +def test_api_catalog_and_health(client): + r = client.get("/api/health") + assert r.status_code == 200 and r.json()["ok"] + cat = client.get("/api/catalog").json() + assert {"faces", "bodies", "palettes"} <= set(cat["avatars"]) + assert any(p["id"] == "comedian" for p in cat["library"]) + + +def test_api_room_lifecycle(client): + snap = client.get("/api/rooms/main").json() + assert snap["id"] == "main" and snap["personas"] == [] + + r = client.post("/api/rooms/main/personas", json={"template_id": "comedian", "name": "Giggles"}) + assert r.status_code == 201 + pid = r.json()["id"] + assert r.json()["name"] == "Giggles" + + r = client.post( + "/api/rooms/main/personas", + json={"name": "Custom", "personality": "You are custom.", "avatar": {"face": "crt", "body": "orb", "palette": "lime"}}, + ) + assert r.status_code == 201 and r.json()["avatar"]["face"] == "crt" + + assert client.post("/api/rooms/main/personas", json={"name": "NoPrompt"}).status_code == 422 + assert client.post("/api/rooms/main/personas", json={"template_id": "ghost"}).status_code == 404 + + r = client.post("/api/rooms/main/messages", json={"content": "hi all", "name": "Alex"}) + assert r.status_code == 201 and r.json()["kind"] == "user" + + r = client.post("/api/rooms/main/control", json={"action": "pause"}) + assert r.json()["status"] == "paused" + r = client.patch("/api/rooms/main", json={"topic": "cats", "speed": 2}) + assert r.json()["topic"] == "cats" and r.json()["speed"] == 2 + + assert client.delete(f"/api/rooms/main/personas/{pid}").status_code == 204 + assert client.delete(f"/api/rooms/main/personas/{pid}").status_code == 404 + assert client.delete("/api/rooms/main").status_code == 400 + + # creating rooms needs an account; they run on the mock brain when no key is stored + assert client.post("/api/rooms", json={"id": "Side Room!", "topic": "x"}).status_code == 401 + client.post("/api/auth/register", json={"email": "r@example.com", "password": "password1"}) + r = client.post("/api/rooms", json={"id": "Side Room!", "topic": "x"}) + assert r.status_code == 201 and r.json()["id"] == "side-room" and r.json()["brain"]["provider"] == "mock" + assert client.post("/api/rooms", json={"id": "side-room"}).status_code == 409 + assert len(client.get("/api/rooms").json()) == 2 + assert client.delete("/api/rooms/side-room").status_code == 204 + assert client.get("/api/rooms/side-room").status_code == 404 + + +def test_websocket_streams_snapshot_and_events(client): + with client.websocket_connect("/ws/rooms/main") as ws: + first = ws.receive_json() + assert first["type"] == "snapshot" and first["room"]["id"] == "main" + ws.send_json({"type": "control", "action": "pause"}) + ev = ws.receive_json() + assert ev["type"] == "status" and ev["status"] == "paused" + ws.send_json({"type": "say", "content": "hello there", "name": "Alex"}) + ev = ws.receive_json() + assert ev["type"] == "message" and ev["message"]["content"] == "hello there" + client.post("/api/rooms/main/personas", json={"template_id": "poet"}) + ev = ws.receive_json() + assert ev["type"] == "persona_joined" and ev["persona"]["template_id"] == "poet" + + +def test_websocket_unknown_room(client): + with pytest.raises(Exception): + with client.websocket_connect("/ws/rooms/does-not-exist") as ws: + ws.receive_json() diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..3bd589a --- /dev/null +++ b/web/README.md @@ -0,0 +1,120 @@ +# AgentryLab Room β€” web UI + +Vite 6 + React 19 + TypeScript + Tailwind CSS v4 + [Motion](https://motion.dev) + Zustand. +No component library: every control is a few Tailwind classes, every animation is Motion. + +## Develop + +```bash +# terminal 1: API (any provider; no key = demo brain) +pip install -e '.[web]' +agentrylab serve --reload + +# terminal 2: UI with hot reload, proxies /api and /ws to :8000 +cd web +npm install +npm run dev # http://localhost:5173 +``` + +| Script | Purpose | +|---|---| +| `npm run dev` | Vite dev server with HMR; `AGENTRYLAB_API=http://host:port` changes the proxy target | +| `npm run build` | Typecheck + production build into `web/dist` | +| `npm run typecheck` | `tsc` only (what CI runs before the build) | +| `npm run preview` | Serve the production build locally | + +`agentrylab serve` picks up `web/dist` automatically (or `AGENTRYLAB_WEB_DIST`). +`make web-bundle` copies the build into the Python package so a wheel ships the UI. + +## Layout + +``` +src/ + main.tsx, App.tsx bootstrap; App owns the layout and which modal is open + index.css Tailwind import, design tokens (@theme), glass/floor/noise helpers + types.ts wire types shared with the backend (Persona, Message, RoomEvent, Me, …) + api.ts thin fetch wrapper for every REST endpoint + store.ts zustand store: room state, auth state, WebSocket client (auto-reconnect), actions + avatars/ + palettes.ts accent colours (keep in sync with agentrylab/room/avatars.py) + faces.tsx 6 heads: eyes blink, gaze wanders while thinking, mouths animate while talking + bodies.tsx 6 bodies: chest light pulses, arms gesture, thrusters/treads move + Android.tsx composes face + body, ambient bob, head tilt, floor glow β€” driven by `mood` + components/ + TopBar.tsx topic, transport (play/pause/step/speed), add button, brain badge, rooms menu, user menu + Stage.tsx the room: androids in a responsive grid, speech bubbles, the human guest + AndroidCard.tsx one android + nameplate + bubble/thinking dots + remove button + personality tooltip + SpeechBubble.tsx word-by-word typewriter bubble + Guest.tsx the human on stage (silhouette + last line) + ChatLog.tsx transcript panel with mini avatars; auto-scrolls when near the bottom + Composer.tsx name + message input; Enter sends + AddPersonaModal.tsx library grid + "build your own" with live preview + AuthModal.tsx sign in / create account + KeysModal.tsx "Brains & keys": per-provider key rows, remember toggle, unlock banner, forget all + RoomsMenu.tsx room switcher dropdown, NewRoomModal, BrainPicker (provider + model) + BrainBadge.tsx which model drives the room; owners click it to switch + Modal.tsx shared dialog shell + form primitives (Field, inputCls, primaryBtn, ErrorLine) + Toasts.tsx bottom-left notifications +``` + +## How state flows + +1. `App` calls `store.init()`: loads `/api/catalog` and `/api/auth/me`, lists rooms, + then opens the WebSocket for the room in `?room=` (default `main`). +2. The first socket frame is a full snapshot; every later event is applied by + `store.applyEvent()` (messages, thinking, joins/leaves, status, topic, brain, errors). +3. Each android's **mood** (`idle` / `thinking` / `talking`) is derived in `Stage` + from `thinking` and `speaking` in the store. `talking` lasts while the bubble's + typewriter runs, then `finishedSpeaking()` flips it back to idle. +4. User actions call REST through `api.ts`; the server echoes the result as a + socket event, so the UI never has to update state optimistically. +5. Auth state (`me`) drives what is visible: the Sign in button vs. the user + menu, which brains are selectable, whether a room's brain badge is editable. + Private rooms the user cannot access close the socket with code 4003 and the + UI bounces back to the public stage. + +## Adding things + +- **A face or body**: draw it in `faces.tsx` / `bodies.tsx` as a function of + `{ accent, mood, seed }`, use `className="tb"` on anything you scale so it + scales around its own centre, register it in `FACES` / `BODIES`, and add the + id to the Python catalog in `agentrylab/room/avatars.py`. +- **A new event type**: extend `RoomEvent` in `types.ts` and handle it in + `store.applyEvent`; TypeScript will point at the missing case. +- **A new endpoint**: add a function to `api.ts` and an action to the store; + components only ever talk to the store. + +## Smoke-testing headlessly + +There is no automated UI test suite. After a change, typecheck and then eyeball +the real thing. Playwright (any version, Chromium) is enough: + +```js +// node smoke.mjs (with `agentrylab serve` running on :8000 and web/dist built) +import { chromium } from "playwright"; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +page.on("pageerror", (e) => console.error("PAGE ERROR", e.message)); +await page.goto("http://localhost:8000/", { waitUntil: "networkidle" }); +await page.waitForTimeout(6000); // let the demo brain take a few turns +await page.screenshot({ path: "stage.png" }); +await page.fill("textarea", "Kantor, is a hot dog a sandwich?"); +await page.keyboard.press("Enter"); +await page.waitForTimeout(3000); +await page.screenshot({ path: "after-say.png" }); +await browser.close(); +``` + +Things worth checking in the screenshots: no ` attribute d` console errors +(a sign of an animation Motion cannot interpolate), bubbles sit above their +android, the thinking dots appear between turns, and the layout still fits at a +420 px viewport. + +## Conventions + +- Dark theme only; colours are tokens in `index.css` (`ink-*`, `shell-*`, `fog-*`). +- Fonts: Space Grotesk (display) and JetBrains Mono (labels), loaded from Google + Fonts with system fallbacks. +- Keep components free of fetch calls and WebSocket code; that lives in the store. +- Never render an API key: the backend only returns a `…1234` hint, and the key + inputs are `type="password"` with autocomplete off. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5df3c5c --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + + AgentryLab Β· Room + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..652c2b0 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2553 @@ +{ + "name": "agentrylab-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentrylab-web", + "version": "0.1.0", + "dependencies": { + "motion": "^12.23.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "tailwindcss": "^4.1.0", + "typescript": "~5.8.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/framer-motion": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.43.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/motion": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.43.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..40edba0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "agentrylab-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "motion": "^12.23.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "tailwindcss": "^4.1.0", + "typescript": "~5.8.0", + "vite": "^6.3.0" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..f721f69 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..4dfcc52 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,55 @@ +import { useEffect } from "react"; +import AddPersonaModal from "./components/AddPersonaModal"; +import AuthModal from "./components/AuthModal"; +import ChatLog from "./components/ChatLog"; +import Composer from "./components/Composer"; +import KeysModal from "./components/KeysModal"; +import { NewRoomModal } from "./components/RoomsMenu"; +import Stage from "./components/Stage"; +import Toasts from "./components/Toasts"; +import TopBar from "./components/TopBar"; +import { useRoom } from "./store"; + +export default function App() { + const init = useRoom((s) => s.init); + const modal = useRoom((s) => s.modal); + const openModal = useRoom((s) => s.openModal); + const close = () => openModal(null); + + useEffect(() => { + void init(); + }, [init]); + + return ( +
+ openModal("add")} /> +
+ openModal("add")} /> + +
+ + + + + +
+ ); +} + +function ClearButton() { + const control = useRoom((s) => s.control); + const count = useRoom((s) => s.messages.length); + if (!count) return null; + return ( + + ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..e58ac4c --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,67 @@ +import type { Avatar, Brain, Catalog, Me, Message, Persona, RoomListItem, RoomSnapshot } from "./types"; + +async function request(url: string, init?: RequestInit): Promise { + const res = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!res.ok) { + let detail = res.statusText; + try { + const body = await res.json(); + detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail ?? body); + } catch { + /* ignore */ + } + throw new Error(detail || `HTTP ${res.status}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export const api = { + catalog: () => request("/api/catalog"), + room: (id: string) => request(`/api/rooms/${encodeURIComponent(id)}`), + rooms: () => request("/api/rooms"), + createRoom: (body: { id?: string; topic?: string; brain?: Brain }) => + request("/api/rooms", { method: "POST", body: JSON.stringify(body) }), + deleteRoom: (id: string) => request(`/api/rooms/${encodeURIComponent(id)}`, { method: "DELETE" }), + + // ---- auth & keys + me: () => request("/api/auth/me"), + register: (email: string, password: string) => + request("/api/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }), + login: (email: string, password: string) => + request("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), + logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }), + unlock: (password: string) => request("/api/auth/unlock", { method: "POST", body: JSON.stringify({ password }) }), + saveKey: (provider: string, key: string, remember: boolean) => + request(`/api/auth/keys/${provider}`, { method: "PUT", body: JSON.stringify({ key, remember }) }), + removeKey: (provider: string) => request(`/api/auth/keys/${provider}`, { method: "DELETE" }), + forgetAllKeys: () => request("/api/auth/keys", { method: "DELETE" }), + + addFromLibrary: (room: string, templateId: string) => + request(`/api/rooms/${room}/personas`, { + method: "POST", + body: JSON.stringify({ template_id: templateId }), + }), + addCustom: ( + room: string, + p: { name: string; tagline: string; personality: string; avatar: Avatar; temperature: number }, + ) => request(`/api/rooms/${room}/personas`, { method: "POST", body: JSON.stringify(p) }), + removePersona: (room: string, personaId: string) => + request(`/api/rooms/${room}/personas/${personaId}`, { method: "DELETE" }), + + say: (room: string, content: string, name: string) => + request(`/api/rooms/${room}/messages`, { + method: "POST", + body: JSON.stringify({ content, name }), + }), + control: (room: string, action: "play" | "pause" | "step" | "clear") => + request<{ status: string; speed: number }>(`/api/rooms/${room}/control`, { + method: "POST", + body: JSON.stringify({ action }), + }), + settings: (room: string, body: { topic?: string; speed?: number; brain?: Brain }) => + request(`/api/rooms/${room}`, { method: "PATCH", body: JSON.stringify(body) }), +}; diff --git a/web/src/avatars/Android.tsx b/web/src/avatars/Android.tsx new file mode 100644 index 0000000..ef81a9d --- /dev/null +++ b/web/src/avatars/Android.tsx @@ -0,0 +1,80 @@ +import { motion } from "motion/react"; +import { useMemo } from "react"; +import type { Avatar, Mood } from "../types"; +import { BODIES } from "./bodies"; +import { FACES } from "./faces"; +import { accentFor, alpha } from "./palettes"; + +interface Props { + avatar: Avatar; + mood?: Mood; + seed?: number; + size?: number; + className?: string; + /** Static mode skips the ambient bob (for lists and thumbnails). */ + still?: boolean; +} + +/** + * A full android: body + face, with ambient bob, head tilt and a floor glow. + * All micro-animations are driven by `mood`. + */ +export default function Android({ avatar, mood = "idle", seed = 0, size = 180, className, still }: Props) { + const accent = accentFor(avatar.palette); + const Face = FACES[avatar.face] ?? FACES.duo; + const Body = BODIES[avatar.body] ?? BODIES.capsule; + const bobDuration = useMemo(() => 3.2 + (seed % 5) * 0.35, [seed]); + const hovering = avatar.body === "hover"; + + const bodyAnim = still + ? { y: 0, rotate: 0 } + : mood === "talking" + ? { y: [0, -6, 0, -3, 0], rotate: [0, -1.2, 0.8, -0.6, 0] } + : mood === "thinking" + ? { y: [0, -2, 0], rotate: [0, 2.5, 2.5, 0] } + : { y: hovering ? [0, -8, 0] : [0, -3, 0], rotate: 0 }; + + const headAnim = still + ? { rotate: 0, y: 0 } + : mood === "talking" + ? { rotate: [0, 3, -2, 2, 0], y: [0, -1, 0] } + : mood === "thinking" + ? { rotate: [0, -7, -7, 0], y: [0, 1, 0] } + : { rotate: [0, 1.5, 0, -1.5, 0], y: 0 }; + + return ( + + {/* floor glow */} + + + + + + + + + + + ); +} diff --git a/web/src/avatars/bodies.tsx b/web/src/avatars/bodies.tsx new file mode 100644 index 0000000..6462fed --- /dev/null +++ b/web/src/avatars/bodies.tsx @@ -0,0 +1,258 @@ +import { motion } from "motion/react"; +import type { ReactElement } from "react"; +import type { BodyId, Mood } from "../types"; +import { SHELL, alpha } from "./palettes"; + +/** + * Bodies draw everything below the head (y >= 104) in the 200x240 box. + * Each exposes a chest light that pulses while thinking and an arm that + * gestures while talking. + */ +export interface BodyProps { + accent: string; + mood: Mood; +} + +function ChestLight({ accent, mood, cx, cy, r = 7 }: BodyProps & { cx: number; cy: number; r?: number }) { + return ( + + ); +} + +/** An arm that gestures when talking. `side` is -1 (left) or 1 (right). */ +function Arm({ accent, mood, side, x, y, len = 56, w = 14 }: BodyProps & { side: -1 | 1; x: number; y: number; len?: number; w?: number }) { + const idle = { rotate: side * 2 }; + const talk = side === 1 ? { rotate: [0, -28, -8, -34, 0] } : { rotate: [0, 10, -4, 12, 0] }; + return ( + + + + + + ); +} + +// -------------------------------------------------------------------- bodies +function Capsule(p: BodyProps) { + const { accent } = p; + return ( + + + + + + + + + + {/* legs */} + + + + + + + + ); +} + +function Boxy(p: BodyProps) { + const { accent, mood } = p; + return ( + + + {/* shoulders */} + + + + + + + {/* status lights */} + {[0, 1, 2].map((i) => ( + + ))} + + + + {/* legs */} + + + + + + ); +} + +function Hover(p: BodyProps) { + const { accent, mood } = p; + return ( + + + + + + + + + {/* thruster disc */} + + + + {[72, 86, 100, 114, 128].map((x, i) => ( + + ))} + + ); +} + +function Slim(p: BodyProps) { + const { accent } = p; + return ( + + + + + + + + + + + {/* long legs */} + + + + + + ); +} + +function Orb(p: BodyProps) { + const { accent, mood } = p; + return ( + + + + + + + + + {/* feet */} + + + + ); +} + +function Tank(p: BodyProps) { + const { accent, mood } = p; + return ( + + + + + + + + + + + + + {/* treads */} + + {[60, 80, 100, 120, 140].map((x, i) => ( + + ))} + {[60, 80, 100, 120, 140].map((x) => ( + + ))} + + ); +} + +export const BODIES: Record ReactElement> = { + capsule: Capsule, + boxy: Boxy, + hover: Hover, + slim: Slim, + orb: Orb, + tank: Tank, +}; diff --git a/web/src/avatars/faces.tsx b/web/src/avatars/faces.tsx new file mode 100644 index 0000000..eee3070 --- /dev/null +++ b/web/src/avatars/faces.tsx @@ -0,0 +1,340 @@ +import { motion } from "motion/react"; +import type { ReactElement, ReactNode } from "react"; +import type { FaceId, Mood } from "../types"; +import { SHELL, alpha } from "./palettes"; + +/** + * Faces draw the head: shell + eyes + mouth, in the 200x240 avatar box. + * The head sits between y=14 and y=104; the neck is at x=100. + */ +export interface FaceProps { + accent: string; + mood: Mood; + seed: number; // per-persona offset so blinks are not synchronised +} + +const blinkTransition = (seed: number) => ({ + duration: 0.28, + repeat: Infinity, + repeatDelay: 2.6 + (seed % 7) * 0.45, + ease: "easeInOut" as const, + times: [0, 0.4, 0.6, 1], +}); + +/** Wraps eyes so they blink. */ +function Blink({ seed, children, mood }: { seed: number; mood: Mood; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Pupils/irises wander while thinking. */ +function Gaze({ mood, children }: { mood: Mood; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Equaliser-style mouth: bars dance while talking, idle shows a flat line. */ +function EqMouth({ accent, mood, x = 100, y = 84, bars = 5, width = 30 }: { accent: string; mood: Mood; x?: number; y?: number; bars?: number; width?: number }) { + const gap = width / bars; + return ( + + {Array.from({ length: bars }).map((_, i) => { + const cx = x - width / 2 + gap * i + gap / 2; + const phase = i * 0.11; + return ( + + ); + })} + + ); +} + +function Antenna({ accent, mood, x = 100, y = 26, h = 14 }: { accent: string; mood: Mood; x?: number; y?: number; h?: number }) { + return ( + + + + + ); +} + +// --------------------------------------------------------------------- faces +function Visor({ accent, mood, seed }: FaceProps) { + return ( + + + + + {/* visor band */} + + + + + + + + + + ); +} + +function Duo({ accent, mood, seed }: FaceProps) { + return ( + + + {/* ear pods */} + + + + + + + + + + + + + + + + + ); +} + +function Cyclops({ accent, mood, seed }: FaceProps) { + return ( + + + + {/* rivets */} + {[70, 130].map((x) => ( + + ))} + + + + + + + + + + + + ); +} + +function Pixel({ accent, mood, seed }: FaceProps) { + const px = 5; + const eye = (ox: number) => + [0, 1].flatMap((r) => + [0, 1].map((c) => ( + + )), + ); + const mouthCols = [0, 1, 2, 3, 4, 5, 6]; + return ( + + + + + {/* scanlines */} + {[38, 46, 54, 62, 70, 78, 86].map((y) => ( + + ))} + + + {eye(78)} + {eye(111)} + + + + {mouthCols.map((c) => ( + + ))} + + + ); +} + +function Feline({ accent, mood, seed }: FaceProps) { + return ( + + {/* ears */} + + + + + + + + + + + + + {/* whiskers */} + + + + ); +} + +function Crt({ accent, mood, seed }: FaceProps) { + const wave = ["M74 84 L84 84 L88 78 L92 90 L96 80 L100 88 L104 82 L108 86 L112 84 L126 84", "M74 84 L82 84 L86 90 L90 76 L94 88 L100 80 L106 92 L110 78 L114 84 L126 84", "M74 84 L86 84 L90 88 L94 80 L98 86 L102 84 L106 88 L110 80 L116 84 L126 84"]; + return ( + + {/* rabbit ears */} + + + + + + + {[36, 42, 48, 54, 60, 66, 72, 78, 84].map((y) => ( + + ))} + {/* knobs */} + + + + + + + + + + {wave.map((d, i) => ( + + ))} + + ); +} + +export const FACES: Record ReactElement> = { + visor: Visor, + duo: Duo, + cyclops: Cyclops, + pixel: Pixel, + feline: Feline, + crt: Crt, +}; diff --git a/web/src/avatars/palettes.ts b/web/src/avatars/palettes.ts new file mode 100644 index 0000000..bce78cf --- /dev/null +++ b/web/src/avatars/palettes.ts @@ -0,0 +1,34 @@ +import type { PaletteId } from "../types"; + +// Keep in sync with src/agentrylab/room/avatars.py +export const PALETTES: Record = { + cyan: "#22d3ee", + magenta: "#e879f9", + lime: "#a3e635", + amber: "#fbbf24", + coral: "#fb7185", + violet: "#a78bfa", + mint: "#34d399", + ice: "#93c5fd", +}; + +export const SHELL = { + dark: "#1a1f2e", + mid: "#283047", + light: "#3a4460", + line: "#0d1019", + screen: "#0a0d16", +} as const; + +export function accentFor(id: string): string { + return PALETTES[id as PaletteId] ?? PALETTES.cyan; +} + +/** Translucent version of a hex colour. */ +export function alpha(hex: string, a: number): string { + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return `rgba(${r}, ${g}, ${b}, ${a})`; +} diff --git a/web/src/components/AddPersonaModal.tsx b/web/src/components/AddPersonaModal.tsx new file mode 100644 index 0000000..31ff36d --- /dev/null +++ b/web/src/components/AddPersonaModal.tsx @@ -0,0 +1,253 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useMemo, useState } from "react"; +import Android from "../avatars/Android"; +import { PALETTES, accentFor, alpha } from "../avatars/palettes"; +import { useRoom } from "../store"; +import type { Avatar, BodyId, FaceId, Mood, PaletteId, Persona } from "../types"; + +interface Props { + open: boolean; + onClose: () => void; +} + +type Tab = "library" | "custom"; + +/** "Add an android": pick from the persona library, or build one with a live animated preview. */ +export default function AddPersonaModal({ open, onClose }: Props) { + const [tab, setTab] = useState("library"); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + return ( + + {open && ( + e.target === e.currentTarget && onClose()} + > + +
+
+

Add an android

+

Pick a personality from the library or build your own.

+
+
+ {(["library", "custom"] as Tab[]).map((t) => ( + + ))} +
+ +
+
{tab === "library" ? : }
+
+
+ )} +
+ ); +} + +// ------------------------------------------------------------------ library +function Library({ onDone }: { onDone: () => void }) { + const catalog = useRoom((s) => s.catalog); + const personas = useRoom((s) => s.personas); + const addFromLibrary = useRoom((s) => s.addFromLibrary); + const toast = useRoom((s) => s.toast); + const [hover, setHover] = useState(null); + const inRoom = useMemo(() => new Set(personas.map((p) => p.template_id)), [personas]); + + if (!catalog) return

Loading library…

; + + return ( +
+ {catalog.library.map((t: Persona) => { + const accent = accentFor(t.avatar.palette); + const already = inRoom.has(t.id); + return ( + setHover(t.id)} + onMouseLeave={() => setHover(null)} + onClick={async () => { + await addFromLibrary(t.id); + toast(`${t.name} is walking on stage`); + onDone(); + }} + className="group relative flex flex-col items-center rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-center transition hover:border-white/20 hover:bg-white/[0.06]" + style={{ boxShadow: hover === t.id ? `0 20px 40px -24px ${alpha(accent, 0.9)}` : undefined }} + > + {already && on stage} + +
+ {t.name} +
+
{t.tagline}
+
+ ); + })} +
+ ); +} + +// ------------------------------------------------------------------ builder +const FACE_IDS: FaceId[] = ["visor", "duo", "cyclops", "pixel", "feline", "crt"]; +const BODY_IDS: BodyId[] = ["capsule", "boxy", "hover", "slim", "orb", "tank"]; +const PALETTE_IDS = Object.keys(PALETTES) as PaletteId[]; + +function Builder({ onDone }: { onDone: () => void }) { + const addCustom = useRoom((s) => s.addCustom); + const toast = useRoom((s) => s.toast); + const catalog = useRoom((s) => s.catalog); + const [name, setName] = useState(""); + const [tagline, setTagline] = useState(""); + const [personality, setPersonality] = useState(""); + const [temperature, setTemperature] = useState(0.8); + const [avatar, setAvatar] = useState({ face: "duo", body: "capsule", palette: "cyan" }); + const [preview, setPreview] = useState("idle"); + const [busy, setBusy] = useState(false); + + const labelFor = (kind: "faces" | "bodies", id: string) => catalog?.avatars[kind].find((p) => p.id === id)?.label ?? id; + const canSubmit = name.trim().length > 0 && personality.trim().length > 8 && !busy; + + const submit = async () => { + if (!canSubmit) return; + setBusy(true); + await addCustom({ name: name.trim(), tagline: tagline.trim(), personality: personality.trim(), avatar, temperature }); + setBusy(false); + toast(`${name.trim()} is walking on stage`); + onDone(); + }; + + const shuffle = () => { + const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + setAvatar({ face: pick(FACE_IDS), body: pick(BODY_IDS), palette: pick(PALETTE_IDS) }); + }; + + return ( +
+ {/* preview */} +
+
+
+ +
+
+ {name || "Unnamed unit"} +
+
{tagline || "no tagline yet"}
+
+
+
+ {(["idle", "thinking", "talking"] as Mood[]).map((m) => ( + + ))} + +
+
+ + {/* form */} +
+
+ + setName(e.target.value.slice(0, 40))} placeholder="e.g. Captain Fizz" className={inputCls} /> + + + setTagline(e.target.value.slice(0, 60))} placeholder="e.g. Never met a pun it didn't like" className={inputCls} /> + +
+ +