From db9f904222b79b79a794d9140e5258dbc2fe1f44 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Mon, 6 Jul 2026 16:08:19 +0530 Subject: [PATCH] Initial commit: AI Dungeon clone (FastAPI backend + React frontend) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg --- .gitignore | 18 + CODE_REVIEW_FINDINGS.md | 131 ++ README.md | 54 + backend/app/__init__.py | 0 backend/app/context/__init__.py | 3 + backend/app/context/builder.py | 206 +++ backend/app/database.py | 33 + backend/app/debuglog.py | 56 + backend/app/main.py | 55 + backend/app/memorybank.py | 275 ++++ backend/app/migrations.py | 66 + backend/app/models.py | 221 +++ backend/app/providers/__init__.py | 4 + backend/app/providers/base.py | 27 + backend/app/providers/openai_compatible.py | 242 +++ backend/app/routers/__init__.py | 0 backend/app/routers/adventures.py | 611 ++++++++ backend/app/routers/debug.py | 11 + backend/app/routers/scenarios.py | 166 ++ backend/app/routers/scripts.py | 114 ++ backend/app/routers/settings.py | 57 + backend/app/routers/story_cards.py | 57 + backend/app/schemas.py | 265 ++++ backend/app/scripting/__init__.py | 4 + backend/app/scripting/engine.py | 143 ++ backend/app/scripting/pipeline.py | 95 ++ backend/requirements.txt | 7 + backend/seed_demo.py | 250 +++ frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 16 + frontend/index.html | 19 + frontend/package-lock.json | 1643 ++++++++++++++++++++ frontend/package.json | 27 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.jsx | 24 + frontend/src/api.js | 115 ++ frontend/src/components.jsx | 113 ++ frontend/src/index.css | 677 ++++++++ frontend/src/main.jsx | 34 + frontend/src/pages/Home.jsx | 111 ++ frontend/src/pages/Play.jsx | 706 +++++++++ frontend/src/pages/ScenarioEditor.jsx | 140 ++ frontend/src/pages/Scenarios.jsx | 143 ++ frontend/src/pages/ScriptEditor.jsx | 149 ++ frontend/src/pages/Scripts.jsx | 63 + frontend/src/pages/Settings.jsx | 187 +++ frontend/vite.config.js | 12 + plan/00-OVERVIEW.md | 74 + plan/01-phase-foundation.md | 38 + plan/02-phase-play-loop.md | 45 + plan/03-phase-context-insights.md | 62 + plan/04-phase-scripting.md | 84 + plan/05-phase-polish.md | 38 + plan/06-phase-memory-bank.md | 47 + start.ps1 | 9 + 57 files changed, 7804 insertions(+) create mode 100644 .gitignore create mode 100644 CODE_REVIEW_FINDINGS.md create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/context/__init__.py create mode 100644 backend/app/context/builder.py create mode 100644 backend/app/database.py create mode 100644 backend/app/debuglog.py create mode 100644 backend/app/main.py create mode 100644 backend/app/memorybank.py create mode 100644 backend/app/migrations.py create mode 100644 backend/app/models.py create mode 100644 backend/app/providers/__init__.py create mode 100644 backend/app/providers/base.py create mode 100644 backend/app/providers/openai_compatible.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/adventures.py create mode 100644 backend/app/routers/debug.py create mode 100644 backend/app/routers/scenarios.py create mode 100644 backend/app/routers/scripts.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/routers/story_cards.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/scripting/__init__.py create mode 100644 backend/app/scripting/engine.py create mode 100644 backend/app/scripting/pipeline.py create mode 100644 backend/requirements.txt create mode 100644 backend/seed_demo.py create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/Play.jsx create mode 100644 frontend/src/pages/ScenarioEditor.jsx create mode 100644 frontend/src/pages/Scenarios.jsx create mode 100644 frontend/src/pages/ScriptEditor.jsx create mode 100644 frontend/src/pages/Scripts.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/vite.config.js create mode 100644 plan/00-OVERVIEW.md create mode 100644 plan/01-phase-foundation.md create mode 100644 plan/02-phase-play-loop.md create mode 100644 plan/03-phase-context-insights.md create mode 100644 plan/04-phase-scripting.md create mode 100644 plan/05-phase-polish.md create mode 100644 plan/06-phase-memory-bank.md create mode 100644 start.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f945aea --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Secrets +*.env +backend/openrouter_key.env + +# Python +backend/.venv/ +__pycache__/ +*.pyc + +# Database +*.db + +# Node +node_modules/ +frontend/dist/ + +# Misc +.claude/ diff --git a/CODE_REVIEW_FINDINGS.md b/CODE_REVIEW_FINDINGS.md new file mode 100644 index 0000000..f98822f --- /dev/null +++ b/CODE_REVIEW_FINDINGS.md @@ -0,0 +1,131 @@ +# Code Review Findings — 2026-07-05 + +Full-codebase review (no git history, so whole project was the scope). +Status: `pending` = not yet fixed, `fixed` = applied, `verify-failed` = finding was wrong on closer look, `skipped` = intentionally not fixed. +Resume point: fix `pending` items top-to-bottom (they are ranked by severity). + +## Correctness bugs + +### 1. [pending] seed_demo.py doesn't stamp schema version → server crashes on next start +- `backend/seed_demo.py:10` +- Fresh DB created via `Base.metadata.create_all` leaves `PRAGMA user_version` at 0. Next server start sees tables exist, replays every ALTER TABLE migration → `duplicate column name` crash. +- Fix: stamp user_version to latest after create_all (reuse migrations.bootstrap logic). + +### 2. [pending] Turn-lock race: two simultaneous turns can run on the same adventure +- `backend/app/routers/adventures.py:302` +- `ensure_not_generating()` runs in the route handler but `_active_turns.add()` only happens when the StreamingResponse generator is first iterated. Double-click Continue → both requests pass the 409 check → duplicate Action.index rows, interleaved generations. +- Fix: atomically test-and-set the lock in the request phase, release in the stream's `finally`. + +### 3. [pending] Migration 10 renumbers indexes with a correlated subquery on the table being updated +- `backend/app/migrations.py:34` +- SQLite may evaluate the subquery against partially-updated rows → duplicate indexes survive the "repair". +- Fix: compute new indexes in Python (SELECT ordered, then UPDATE per row). + +### 4. [pending] SQLite foreign keys never enabled → CASCADE/SET NULL clauses are dead +- `backend/app/database.py:8` +- Deleting a Script leaves orphaned `scenario_scripts` rows; SQLite rowid reuse can attach a future script to an old scenario. +- Fix: `PRAGMA foreign_keys=ON` via engine connect event. + +### 5. [pending] Provider generate() silently yields nothing for non-SSE 200 responses +- `backend/app/providers/openai_compatible.py:84` +- Server that ignores `stream=true` and returns plain JSON → no `data:` lines → empty AI action, no error. +- Fix: buffer non-SSE body and fall back to parsing it as a single JSON completion. + +### 6. [pending] Fire-and-forget asyncio task can be GC'd mid-run and wedge the memory bank +- `backend/app/memorybank.py:146` +- `asyncio.create_task` result not referenced; task can vanish silently; adventure ID can stay stuck in `_running`. +- Fix: keep strong refs in a set, discard in done-callback. + +### 7. [pending] Memory cursors are list positions but Memory.source_start/end are Action.index values +- `backend/app/memorybank.py:213` +- After any action deletion (indexes keep gaps, positions shift), summarization skips/duplicates blocks and `_update_story_summary` folds the wrong memories. +- Fix: use one space consistently — track cursors by Action.index (position-independent), or renumber on delete. + +### 8. [pending] Pinned memories don't count toward memory_top_k cap +- `backend/app/memorybank.py:120` +- 6 pinned + top_k=5 → 11 memories injected, blowing token budget. +- Fix: fill with unpinned only up to `top_k - len(pinned)` (min 0). + +### 9. [pending] Embedding-model change → cosine() zips different-dimension vectors silently +- `backend/app/memorybank.py:69` +- Old 768-dim embeddings scored against new 1536-dim query → garbage similarity, no error, never re-embedded. +- Fix: return 0.0 on length mismatch (and ideally clear stale embeddings so _embed_pending redoes them). + +### 10. [pending] MAX_STORY_CARDS cap is a no-op for cards created in one hook +- `backend/app/scripting/pipeline.py:59` +- `len(existing) + len(seen_ids) < MAX...` never counts newly added cards (seen_ids ⊂ existing). A script can insert unbounded cards in one turn. +- Fix: count inserts made during the loop. + +### 11. [pending] addStoryCard returns 0 (falsy) for the first card, indistinguishable from `false` rejection +- `backend/app/scripting/engine.py:39` +- `if (!addStoryCard(...))` misfires when the card list was empty. +- Fix: return `storyCards.length` (1-based, always truthy) or `true`; document. + +### 12. [pending] scenario_id=0 truthiness bug in create_story_card +- `backend/app/routers/story_cards.py:28` +- `scenario_id or ...` picks the wrong owner when id is 0. Use `is not None`. + +### 13. [pending] test_connection 500s on non-dict JSON from /models +- `backend/app/routers/settings.py:54` +- Only ValueError caught; `data.get`/`m.get` on non-dict raises AttributeError → 500 instead of `{ok:false}`. +- Fix: catch (ValueError, AttributeError, TypeError) or validate shapes. + +### 14. [pending] AI Dungeon exports with `worldInformation` key lose all story cards silently +- `backend/app/routers/scenarios.py:133` +- Import reads only `storyCards`/`worldInfo`; `worldInformation` is in _IGNORED_KEYS so it's dropped and not reported. +- Fix: accept `worldInformation` as a card source too. + +### 15. [pending] Shared debounce timer loses edits (Play PlotPanel) +- `frontend/src/pages/Play.jsx:28` +- One `saveTimer` shared by all plot fields AND story-card saves; editing a second thing within 600ms cancels the first pending PATCH → silent data loss. +- Fix: per-key timers (e.g. a Map keyed by field/card id). + +### 16. [pending] Same shared-debounce data loss in ScenarioEditor +- `frontend/src/pages/ScenarioEditor.jsx:22` +- Same fix as #15. + +### 17. [pending] Continue button silently discards typed input text +- `frontend/src/pages/Play.jsx:469` +- Clicking Continue with text in the box sends type 'continue' (backend ignores text) and clears the input. +- Fix: don't clear input on continue (or treat non-empty input as a normal send). + +### 18. [pending] retry() optimistically deletes last AI action with no rollback on failure +- `frontend/src/pages/Play.jsx:475` +- Failed retry (409/network) leaves UI missing an action that still exists server-side. +- Fix: restore the removed action in the catch path (or only remove on first stream event). + +### 19. [pending] Settings test()/save() have no error handling → stuck on "Testing…" +- `frontend/src/pages/Settings.jsx:66` +- Rejection leaves `{pending:true}` forever + unhandled rejection. +- Fix: try/catch → setTestResult({ok:false, error:msg}). + +### 20. [pending] InsightsPanel race: slow earlier request overwrites newer report +- `frontend/src/pages/Play.jsx:302` +- No staleness guard; slow getAdventureContext can clobber a newer action snapshot. +- Fix: track a request id / cancelled flag in the effect. + +### 21. [pending] extractPlaceholders ignores ${...} in story-card trigger keys +- `frontend/src/pages/Scenarios.jsx:50` +- Backend fills placeholders in card.keys but the modal never prompts for those names → literal `${hero}` keys never match. +- Fix: also scan card.keys when collecting placeholder names. + +## Cleanup backlog (reuse / simplification / efficiency / altitude — not bugs, apply later) + +- **R1** `frontend/src/pages/Play.jsx:26` + `ScenarioEditor.jsx:19` + `ScriptEditor.jsx:34` — three copies of the debounced-autosave + story-card handlers; extract a `useDebouncedSave` hook / shared StoryCardList component. (Fixing bugs #15/#16 properly may accomplish this.) +- **R2** `backend/seed_demo.py:228` — re-implements create_adventure; call the router logic instead. +- **R3** `backend/app/providers/openai_compatible.py:122` — complete() duplicates _request() body building; add a `stream` param to _request(). +- **R4** `backend/app/routers/adventures.py:476` — six copies of child-resource get+owner-check+404; extract `get_owned_or_404`. +- **R5** `frontend/src/api.js:26` — streamSSE duplicates request()'s error extraction; extract `throwIfNotOk(resp)`. +- **S1** `backend/app/models.py:210` — `Settings.stream` is dead state (never read); delete column + schema fields. +- **S2** `frontend/src/pages/Play.jsx:6` — MODES and PLAYER_TYPES are identical constants; lastIsAi/canUndo computed twice. +- **E1** `backend/app/context/builder.py:119` — joins+tokenizes the ENTIRE adventure history every turn for the trigger window; walk reversed(actions) until budget instead. +- **E2** `backend/app/scripting/pipeline.py:77` — rebuilds full history dicts + JSON + a blocking commit per script per hook; build once per hook, slice to HISTORY_WINDOW first, commit once. +- **E3** `frontend/src/pages/Play.jsx:561` — every SSE chunk re-renders all action rows; isolate streaming text in a child component / React.memo rows. +- **E4** `backend/app/context/builder.py:48` — Section.tokens uncached, whole context tokenized 2-3×/turn; cache counts, sum sections. +- **E5** `frontend/src/pages/Play.jsx:490` — keydown effect has no dep array → listener re-registered every render. +- **E6** `backend/app/memorybank.py:182` — catch-up summarization awaits blocks sequentially; gather independent blocks. +- **A1** `backend/app/models.py:145` — no UniqueConstraint('adventure_id','index'); index allocation is ad-hoc per writer. (Related to bug #2.) +- **A2** `backend/app/providers/openai_compatible.py:45` — CHAT_CONTINUE_HINT appended below the budgeting layer; assemble prompts in context builder. +- **A3** `backend/app/routers/adventures.py:390` — import endpoints hand-coerce raw dicts; use a Pydantic bundle schema. +- **A4** `backend/app/routers/adventures.py:207` — onModelContext flattens (system, story) and ships everything as user content if modified; pass structure through the hook. +- **A5** `frontend/src/pages/Home.jsx:87` — client appends 'Z' to naive datetimes; emit ISO-8601 with offset from the API instead. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fb7b6a --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# AI D&D — local AI Dungeon-style storytelling app + +Python (FastAPI + SQLite) backend, React (Vite) frontend, connectable to any +OpenAI-compatible AI endpoint (Ollama, LM Studio, OpenAI, OpenRouter, …). + +Plan: see [`plan/00-OVERVIEW.md`](plan/00-OVERVIEW.md). Currently completed: **Phase 4** (AI Dungeon-compatible scripting). + +## Run + +```powershell +.\start.ps1 +``` + +Then open http://localhost:5173. Backend API docs at http://localhost:8000/docs. + +Or manually, in two terminals: + +```powershell +# Terminal 1 — backend +cd backend +.\.venv\Scripts\uvicorn.exe app.main:app --port 8000 --reload + +# Terminal 2 — frontend +cd frontend +npm run dev +``` + +## First-time setup + +```powershell +cd backend +python -m venv .venv +.\.venv\Scripts\pip.exe install -r requirements.txt + +cd ..\frontend +npm install +``` + +## Production-ish serving (single port) + +Build the frontend, then the backend serves it statically: + +```powershell +cd frontend +npm run build +# then run the backend and open http://localhost:8000 +``` + +## Layout + +- `backend/app/` — FastAPI app: `models.py` (SQLAlchemy), `schemas.py` (Pydantic), `routers/` +- `backend/data.db` — SQLite database (created on first run) +- `frontend/src/` — React SPA: `pages/`, `api.js` +- `plan/` — phased implementation plan diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/context/__init__.py b/backend/app/context/__init__.py new file mode 100644 index 0000000..fef581e --- /dev/null +++ b/backend/app/context/__init__.py @@ -0,0 +1,3 @@ +from .builder import build_context, count_tokens, truncate_to_last_tokens + +__all__ = ["build_context", "count_tokens", "truncate_to_last_tokens"] diff --git a/backend/app/context/builder.py b/backend/app/context/builder.py new file mode 100644 index 0000000..ee802b1 --- /dev/null +++ b/backend/app/context/builder.py @@ -0,0 +1,206 @@ +"""Context assembly per AI Dungeon's memory system +(help.aidungeon.com/faq/the-memory-system): + + [AI Instructions] always included + [Plot Essentials] always included (classic "Memory") + [Story Summary] always included (manual in Phase 3, auto in Phase 6) + [Used Memories] top-K memory-bank retrievals (Phase 6, when enabled) + [Triggered Story Cards] "World Lore: ", conditional; first dropped when over budget + [Story history] newest actions that fit the remaining token budget + [Author's Note] injected AUTHORS_NOTE_DEPTH actions before the end of history + [Latest player action] (+ script frontMemory right after it, Phase 4) +""" + +import functools +from dataclasses import dataclass + +import tiktoken + +from .. import models + +AUTHORS_NOTE_DEPTH = 3 # actions from the end of history +CARD_BUDGET_SHARE = 0.4 # max share of non-reserved budget that story cards may take +SEPARATOR = "\n\n" + + +@functools.lru_cache(maxsize=1) +def _encoding() -> tiktoken.Encoding: + return tiktoken.get_encoding("cl100k_base") + + +def count_tokens(text: str) -> int: + return len(_encoding().encode(text)) + + +def truncate_to_last_tokens(text: str, budget: int) -> str: + tokens = _encoding().encode(text) + if len(tokens) <= budget: + return text + return _encoding().decode(tokens[-budget:]) + + +@dataclass +class Section: + label: str + text: str + + @property + def tokens(self) -> int: + return count_tokens(self.text) + + +def _script_memory(adventure: models.Adventure) -> dict: + """Script-provided memory overrides (populated by Phase 4 scripting).""" + state = adventure.script_state if isinstance(adventure.script_state, dict) else {} + memory = state.get("memory") + return memory if isinstance(memory, dict) else {} + + +def _match_cards(cards: list[models.StoryCard], window_text: str) -> list[dict]: + """AI Dungeon trigger rules: case-insensitive, space-sensitive, partial-word + ('boat' triggers on 'boats'). Returns one record per card with the keyword that fired.""" + haystack = window_text.lower() + matched = [] + for card in cards: + for key in (k.strip().lower() for k in card.keys.split(",")): + if key and key in haystack: + matched.append( + {"id": card.id, "name": card.name, "keyword": key, "entry": card.entry} + ) + break + return matched + + +def build_context( + adventure: models.Adventure, + settings: models.Settings, + memory_bank: dict | None = None, +) -> tuple[str, str, dict]: + """Returns (system_text, story_text, context_report). `memory_bank` is the + result of memorybank.retrieve_memories (None when the bank is off).""" + script_mem = _script_memory(adventure) + + # ----- Always-included components ----- + system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())] + if isinstance(script_mem.get("context"), str) and script_mem["context"].strip(): + system_sections.append(Section("script_context", script_mem["context"].strip())) + if adventure.ai_instructions.strip(): + system_sections.append(Section("ai_instructions", adventure.ai_instructions.strip())) + if adventure.memory.strip(): + system_sections.append( + Section("plot_essentials", f"Plot essentials:\n{adventure.memory.strip()}") + ) + if adventure.story_summary.strip(): + system_sections.append( + Section("story_summary", f"Story summary:\n{adventure.story_summary.strip()}") + ) + if memory_bank and memory_bank.get("used"): + lines = "\n".join(f"- {m['text']}" for m in memory_bank["used"]) + system_sections.append(Section("used_memories", f"Memories:\n{lines}")) + + authors_note_text = adventure.authors_note.strip() + if isinstance(script_mem.get("authorsNote"), str) and script_mem["authorsNote"].strip(): + authors_note_text = script_mem["authorsNote"].strip() + authors_note = f"[Author's note: {authors_note_text}]" if authors_note_text else "" + + front_memory = "" + if isinstance(script_mem.get("frontMemory"), str): + front_memory = script_mem["frontMemory"].strip() + + reserved = ( + sum(s.tokens for s in system_sections) + + count_tokens(authors_note) + + count_tokens(front_memory) + ) + available = max(256, settings.context_token_budget - reserved) + + # ----- Story cards: triggered by recent story text (the window history could fill) ----- + actions = [a for a in adventure.actions if a.text.strip()] + trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available) + triggered = _match_cards(adventure.story_cards, trigger_window) + + card_budget = int(available * CARD_BUDGET_SHARE) + card_records = [] + lore_lines: list[str] = [] + used = 0 + for match in triggered: + line = f"World Lore: {match['entry'].strip()}" + tokens = count_tokens(line) + included = used + tokens <= card_budget + if included: + lore_lines.append(line) + used += tokens + card_records.append( + {"id": match["id"], "name": match["name"], "keyword": match["keyword"], + "included": included} + ) + if lore_lines: + system_sections.append(Section("world_lore", "\n".join(lore_lines))) + + # ----- Story history: newest first until the remaining budget is spent ----- + history_budget = available - used + included_actions: list[models.Action] = [] + spent = 0 + oldest_truncated = False + for action in reversed(actions): + tokens = count_tokens(action.text) + count_tokens(SEPARATOR) + if spent + tokens > history_budget: + if not included_actions: + # Even the newest action alone is over budget: hard-truncate it. + included_actions.append( + models.Action( + adventure_id=action.adventure_id, index=action.index, + type=action.type, + text=truncate_to_last_tokens(action.text, history_budget), + ) + ) + oldest_truncated = True + break + included_actions.append(action) + spent += tokens + included_actions.reverse() + + # ----- Assemble story text with author's note near the end ----- + texts = [a.text for a in included_actions] + note_sections: list[Section] = [] + if authors_note: + pos = max(0, len(texts) - AUTHORS_NOTE_DEPTH) + before, after = texts[:pos], texts[pos:] + if before: + note_sections.append(Section("history", SEPARATOR.join(before))) + note_sections.append(Section("authors_note", authors_note)) + note_sections.append(Section("recent_history", SEPARATOR.join(after))) + else: + note_sections.append(Section("history", SEPARATOR.join(texts))) + if front_memory: + note_sections.append(Section("front_memory", front_memory)) + + story_sections = [s for s in note_sections if s.text] + system_text = SEPARATOR.join(s.text for s in system_sections if s.text) + story_text = SEPARATOR.join(s.text for s in story_sections) + + all_sections = [s for s in system_sections if s.text] + story_sections + report = { + "sections": [ + {"label": s.label, "text": s.text, "tokens": s.tokens} for s in all_sections + ], + "prompt": {"system": system_text, "story": story_text}, + "tokens": { + "total": count_tokens(system_text) + count_tokens(story_text), + "budget": settings.context_token_budget, + }, + "cards": card_records, + "memories": memory_bank, + "history": { + "included": len(included_actions), + "total": len(actions), + "oldest_truncated": oldest_truncated, + }, + "settings": { + "model": settings.model, + "api_mode": settings.api_mode, + "temperature": settings.temperature, + "max_output_tokens": settings.max_output_tokens, + }, + } + return system_text, story_text, report diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..fbb3d8b --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +DB_PATH = Path(__file__).resolve().parent.parent / "data.db" + +engine = create_engine( + f"sqlite:///{DB_PATH}", + connect_args={"check_same_thread": False}, +) + + +@event.listens_for(engine, "connect") +def _enable_sqlite_foreign_keys(dbapi_connection, _record): + # SQLite ships with foreign keys OFF per connection; without this every + # ondelete=CASCADE/SET NULL in models.py is silently ignored. + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/debuglog.py b/backend/app/debuglog.py new file mode 100644 index 0000000..dcd1cd7 --- /dev/null +++ b/backend/app/debuglog.py @@ -0,0 +1,56 @@ +"""In-memory ring buffer of recent provider requests/responses for the debug page. + +API keys never enter the log: only the request body (which carries no +credentials) and response text are recorded, both truncated. +""" + +import itertools +from collections import deque +from datetime import datetime, timezone + +MAX_ENTRIES = 30 +MAX_TEXT = 6000 + +_entries: deque[dict] = deque(maxlen=MAX_ENTRIES) +_ids = itertools.count(1) + + +def _clip(text: str) -> str: + if len(text) <= MAX_TEXT: + return text + return text[:MAX_TEXT] + f"\n… [{len(text) - MAX_TEXT} more chars truncated]" + + +def _clip_obj(obj): + if isinstance(obj, str): + return _clip(obj) + if isinstance(obj, dict): + return {k: _clip_obj(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_clip_obj(v) for v in obj] + return obj + + +def start_entry(url: str, model: str, body: dict) -> dict: + entry = { + "id": next(_ids), + "time": datetime.now(timezone.utc).isoformat(), + "url": url, + "model": model, + "request": _clip_obj(body), + "status": "pending", + "response": "", + "error": None, + } + _entries.appendleft(entry) + return entry + + +def finish_entry(entry: dict, *, response: str = "", error: str | None = None) -> None: + entry["response"] = _clip(response) + entry["error"] = error + entry["status"] = "error" if error else "ok" + + +def recent() -> list[dict]: + return list(_entries) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..ffd4969 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException + +from .database import engine +from .migrations import bootstrap +from .routers import adventures, debug, scenarios, scripts, settings, story_cards + +bootstrap(engine) + +app = FastAPI(title="AI D&D") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(scenarios.router) +app.include_router(adventures.router) +app.include_router(story_cards.router) +app.include_router(scripts.router) +app.include_router(settings.router) +app.include_router(debug.router) + + +@app.get("/api/health") +def health(): + return {"ok": True} + + +# In production, serve the built frontend (frontend/dist) as static files. +class SPAStaticFiles(StaticFiles): + """Serve index.html for unknown paths so client-side routes (/play/3) + survive a page reload. API routes are matched before this mount.""" + + async def get_response(self, path, scope): + try: + response = await super().get_response(path, scope) + except StarletteHTTPException as exc: + if exc.status_code != 404: + raise + return await super().get_response("index.html", scope) + if response.status_code == 404: + return await super().get_response("index.html", scope) + return response + + +frontend_dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" +if frontend_dist.is_dir(): + app.mount("/", SPAStaticFiles(directory=frontend_dist, html=True), name="frontend") diff --git a/backend/app/memorybank.py b/backend/app/memorybank.py new file mode 100644 index 0000000..6975ad5 --- /dev/null +++ b/backend/app/memorybank.py @@ -0,0 +1,275 @@ +"""Phase 6 — auto summarization + embedding memory bank +(per help.aidungeon.com/faq/the-memory-system). + +After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB +session: + - every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered + block of actions is summarized into a short "memory"; + - every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in + the new memories (the user-edited text is always the base, never clobbered); + - new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank + is evicted down to capacity ("forgotten" memories are kept for the UI). + +At generation time, `retrieve_memories` embeds the recent story text and ranks +the bank by cosine similarity; the top-K become the "Memories" context section. + +All AI calls here are best-effort: failures are logged (debug page) and retried +on a later turn because the cursors only advance on success. +""" + +import asyncio +import math + +from sqlalchemy.orm import Session + +from . import models +from .context import truncate_to_last_tokens +from .database import SessionLocal +from .providers import OpenAICompatibleProvider, ProviderError + +MEMORY_INTERVAL = 6 # actions per memory +MEMORY_START = 12 # first memory once the adventure reaches this many actions +SUMMARY_INTERVAL = 15 # actions between Story Summary updates +MAX_MEMORIES_PER_RUN = 5 # cap catch-up work (e.g. imported adventures) per turn +MAX_EMBED_BATCH = 32 +RETRIEVAL_WINDOW_TOKENS = 600 # recent story text used as the similarity query +SUMMARY_MAX_WORDS = 250 + +MEMORY_SYSTEM_PROMPT = ( + "You compress interactive-fiction story excerpts into memories. Respond with " + "1-2 plain sentences in past tense stating the concrete facts and events " + "(names, places, items, promises, injuries). No preamble, no commentary." +) +SUMMARY_SYSTEM_PROMPT = ( + "You maintain the running summary of an interactive-fiction story. Respond " + "with only the updated summary: a single plain-prose overview of the plot " + f"so far, at most {SUMMARY_MAX_WORDS} words. Preserve important established " + "facts; compress older events harder than recent ones." +) + +# Adventures with a post-turn task currently running (single-process app). +_running: set[int] = set() +# Strong refs to in-flight tasks — the event loop only keeps weak references, +# so a fire-and-forget task can otherwise be garbage-collected mid-run. +_tasks: set[asyncio.Task] = set() + + +def summary_provider(settings: models.Settings) -> OpenAICompatibleProvider: + return OpenAICompatibleProvider( + settings.endpoint_url, + settings.api_key, + settings.summary_model or settings.model, + settings.api_mode, + settings.reasoning_max_tokens, + ) + + +def embedding_provider(settings: models.Settings) -> OpenAICompatibleProvider: + return OpenAICompatibleProvider( + settings.endpoint_url, settings.api_key, settings.embedding_model + ) + + +def cosine(a: list[float], b: list[float]) -> float: + dot = sum(x * y for x, y in zip(a, b)) + norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)) + return dot / norm if norm else 0.0 + + +def story_actions(adventure: models.Adventure) -> list[models.Action]: + return [a for a in adventure.actions if a.text.strip()] + + +# ---------- Retrieval (runs inside the turn, before build_context) ---------- + +async def retrieve_memories( + adventure: models.Adventure, + settings: models.Settings, + *, + update_stats: bool, +) -> dict | None: + """Returns {"used": [{id, text, similarity, pinned}], "error": str|None}, + or None when the memory bank is off for this adventure. `update_stats` + bumps use counters (real turns only, not Insights dry runs); the caller's + commit persists them.""" + if not adventure.memory_bank_enabled: + return None + if not settings.embedding_model.strip(): + return {"used": [], "error": "No embedding model configured in Settings."} + + candidates = [m for m in adventure.memories if not m.forgotten and m.embedding] + if not candidates: + return {"used": [], "error": None} + + actions = story_actions(adventure) + query = truncate_to_last_tokens( + "\n\n".join(a.text for a in actions[-4:]), RETRIEVAL_WINDOW_TOKENS + ) + if not query.strip(): + return {"used": [], "error": None} + + try: + [query_vec] = await embedding_provider(settings).embed([query]) + except ProviderError as exc: + return {"used": [], "error": str(exc)} + + scored = sorted( + ((cosine(query_vec, m.embedding), m) for m in candidates), + key=lambda pair: pair[0], + reverse=True, + ) + # Pinned memories are always used; the rest fill up to top_k by similarity. + top_k = max(1, settings.memory_top_k) + used = [(score, m) for score, m in scored if m.pinned] + used += [(score, m) for score, m in scored if not m.pinned][:top_k] + used.sort(key=lambda pair: pair[0], reverse=True) + + if update_stats: + now = models.utcnow() + for _, m in used: + m.use_count += 1 + m.last_used_at = now + + return { + "used": [ + {"id": m.id, "text": m.text, "similarity": round(score, 4), "pinned": m.pinned} + for score, m in used + ], + "error": None, + } + + +# ---------- Post-turn background work ---------- + +def schedule_post_turn(adventure: models.Adventure) -> None: + """Fire-and-forget summarization/embedding work after a turn is saved.""" + if not (adventure.auto_summarize or adventure.memory_bank_enabled): + return + if adventure.id in _running: + return + task = asyncio.get_running_loop().create_task(run_post_turn(adventure.id)) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + + +async def run_post_turn(adventure_id: int) -> None: + if adventure_id in _running: + return + _running.add(adventure_id) + db = SessionLocal() + try: + adventure = db.get(models.Adventure, adventure_id) + settings = db.get(models.Settings, 1) + if adventure is None or settings is None: + return + if adventure.auto_summarize: + await _create_due_memories(adventure, settings, db) + await _update_story_summary(adventure, settings, db) + if adventure.memory_bank_enabled and settings.embedding_model.strip(): + await _embed_pending(adventure, settings, db) + _evict_over_capacity(adventure, settings, db) + finally: + db.close() + _running.discard(adventure_id) + + +async def _create_due_memories( + adventure: models.Adventure, settings: models.Settings, db: Session +) -> None: + actions = story_actions(adventure) + provider = summary_provider(settings) + for _ in range(MAX_MEMORIES_PER_RUN): + cursor = adventure.memory_cursor + if len(actions) < MEMORY_START or len(actions) - cursor < MEMORY_INTERVAL: + return + block = actions[cursor:cursor + MEMORY_INTERVAL] + excerpt = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000) + try: + text = await provider.complete( + MEMORY_SYSTEM_PROMPT, f"Story excerpt:\n\n{excerpt}\n\nMemory:" + ) + except ProviderError: + return # logged in the debug page; cursor unchanged → retried next turn + if not text: + return + db.add( + models.Memory( + adventure_id=adventure.id, + text=text, + source_start=block[0].index, + source_end=block[-1].index, + ) + ) + adventure.memory_cursor = cursor + MEMORY_INTERVAL + db.commit() + + +async def _update_story_summary( + adventure: models.Adventure, settings: models.Settings, db: Session +) -> None: + actions = story_actions(adventure) + if len(actions) - adventure.summary_cursor < SUMMARY_INTERVAL: + return + + # Fold in memories covering the uncovered stretch; fall back to raw story + # text if memory creation is lagging (e.g. it just failed). + new_events = [ + m.text + for m in adventure.memories + if m.source_end is not None and m.source_end >= adventure.summary_cursor + ] + if new_events: + events_text = "\n".join(f"- {t}" for t in new_events) + else: + block = actions[adventure.summary_cursor:] + events_text = truncate_to_last_tokens("\n\n".join(a.text for a in block), 2000) + + current = adventure.story_summary.strip() + user_prompt = ( + f"Current story summary:\n{current or '(none yet)'}\n\n" + f"New events since the last update:\n{events_text}\n\n" + "Updated summary:" + ) + try: + text = await summary_provider(settings).complete( + SUMMARY_SYSTEM_PROMPT, user_prompt, max_tokens=600 + ) + except ProviderError: + return + if not text: + return + adventure.story_summary = text + adventure.summary_cursor = len(actions) + db.commit() + + +async def _embed_pending( + adventure: models.Adventure, settings: models.Settings, db: Session +) -> None: + pending = [m for m in adventure.memories if m.embedding is None and not m.forgotten] + pending = pending[:MAX_EMBED_BATCH] + if not pending: + return + try: + vectors = await embedding_provider(settings).embed([m.text for m in pending]) + except ProviderError: + return + for memory, vector in zip(pending, vectors): + memory.embedding = vector + db.commit() + + +def _evict_over_capacity( + adventure: models.Adventure, settings: models.Settings, db: Session +) -> None: + active = [m for m in adventure.memories if not m.forgotten] + overflow = len(active) - max(1, settings.memory_bank_capacity) + if overflow <= 0: + return + evictable = sorted( + (m for m in active if not m.pinned), + key=lambda m: (m.use_count, m.last_used_at or m.created_at), + ) + for memory in evictable[:overflow]: + memory.forgotten = True + db.commit() diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..4e6f9ca --- /dev/null +++ b/backend/app/migrations.py @@ -0,0 +1,66 @@ +"""Lightweight versioned schema migrations over SQLite's PRAGMA user_version. + +How it works: +- A fresh database is created by `Base.metadata.create_all()` (always current) + and stamped with LATEST_VERSION. +- An existing database runs every migration whose version is greater than its + stored user_version, in order, then is stamped. + +To change the schema: update models.py (keeps fresh DBs current) AND append a +(version, sql) pair here (upgrades existing DBs). Keep migrations idempotent +where cheap (IF NOT EXISTS etc.). +""" + +from sqlalchemy import inspect, text +from sqlalchemy.engine import Engine + +from .database import Base + +# (version, SQL to run when upgrading past it) — append only, never reorder. +MIGRATIONS: list[tuple[int, str]] = [ + # Phase 6: auto-summarization + memory bank (the `memories` table itself is + # created by create_all, which runs for existing DBs too). + (2, "ALTER TABLE adventures ADD COLUMN auto_summarize BOOLEAN NOT NULL DEFAULT 0"), + (3, "ALTER TABLE adventures ADD COLUMN memory_bank_enabled BOOLEAN NOT NULL DEFAULT 0"), + (4, "ALTER TABLE adventures ADD COLUMN memory_cursor INTEGER NOT NULL DEFAULT 0"), + (5, "ALTER TABLE adventures ADD COLUMN summary_cursor INTEGER NOT NULL DEFAULT 0"), + (6, "ALTER TABLE settings ADD COLUMN summary_model VARCHAR(200) NOT NULL DEFAULT ''"), + (7, "ALTER TABLE settings ADD COLUMN embedding_model VARCHAR(200) NOT NULL DEFAULT ''"), + (8, "ALTER TABLE settings ADD COLUMN memory_bank_capacity INTEGER NOT NULL DEFAULT 200"), + (9, "ALTER TABLE settings ADD COLUMN memory_top_k INTEGER NOT NULL DEFAULT 5"), + # Repair duplicate action indexes (player + AI actions of one turn used to + # get the same index): renumber 0..n-1 per adventure, preserving order. + # UPDATE..FROM: ranks are computed as a snapshot before any row is + # rewritten (a correlated subquery would see partially-updated rows and + # could produce duplicates again). + (10, """ + UPDATE actions SET "index" = ranked.new_index + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY adventure_id ORDER BY "index", id + ) - 1 AS new_index + FROM actions + ) AS ranked + WHERE ranked.id = actions.id + """), + # Reasoning-model support: separate thinking budget + stored reasoning text. + (11, "ALTER TABLE settings ADD COLUMN reasoning_max_tokens INTEGER NOT NULL DEFAULT 0"), + (12, "ALTER TABLE actions ADD COLUMN reasoning TEXT"), +] + +LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) + + +def bootstrap(engine: Engine) -> None: + fresh = not inspect(engine).get_table_names() + Base.metadata.create_all(bind=engine) + with engine.begin() as conn: + if fresh: + conn.execute(text(f"PRAGMA user_version = {LATEST_VERSION}")) + return + current = conn.execute(text("PRAGMA user_version")).scalar() or 1 + for version, sql in MIGRATIONS: + if version > current: + conn.execute(text(sql)) + current = version + conn.execute(text(f"PRAGMA user_version = {current}")) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..ef20ef6 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,221 @@ +from datetime import datetime, timezone + +from sqlalchemy import ( + JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table, Text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +scenario_scripts = Table( + "scenario_scripts", + Base.metadata, + Column("scenario_id", ForeignKey("scenarios.id", ondelete="CASCADE"), primary_key=True), + Column("script_id", ForeignKey("scripts.id", ondelete="CASCADE"), primary_key=True), +) + + +class Scenario(Base): + __tablename__ = "scenarios" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200), default="Untitled Scenario") + description: Mapped[str] = mapped_column(Text, default="") + prompt: Mapped[str] = mapped_column(Text, default="") + # Plot components (AI Dungeon terminology; `memory` == Plot Essentials) + memory: Mapped[str] = mapped_column(Text, default="") + authors_note: Mapped[str] = mapped_column(Text, default="") + ai_instructions: Mapped[str] = mapped_column(Text, default="") + tags: Mapped[str] = mapped_column(String(500), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) + + story_cards: Mapped[list["StoryCard"]] = relationship( + back_populates="scenario", cascade="all, delete-orphan" + ) + adventures: Mapped[list["Adventure"]] = relationship(back_populates="scenario") + scripts: Mapped[list["Script"]] = relationship(secondary=scenario_scripts) + + +class Adventure(Base): + __tablename__ = "adventures" + + id: Mapped[int] = mapped_column(primary_key=True) + scenario_id: Mapped[int | None] = mapped_column( + ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True + ) + title: Mapped[str] = mapped_column(String(200), default="Untitled Adventure") + memory: Mapped[str] = mapped_column(Text, default="") + authors_note: Mapped[str] = mapped_column(Text, default="") + ai_instructions: Mapped[str] = mapped_column(Text, default="") + story_summary: Mapped[str] = mapped_column(Text, default="") + script_state: Mapped[dict] = mapped_column(JSON, default=dict) + # Phase 6: opt-in per adventure (extra AI calls) + auto_summarize: Mapped[bool] = mapped_column(Boolean, default=False) + memory_bank_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + # How many actions have already been folded into memories / the story summary. + memory_cursor: Mapped[int] = mapped_column(Integer, default=0) + summary_cursor: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) + + scenario: Mapped[Scenario | None] = relationship(back_populates="adventures") + story_cards: Mapped[list["StoryCard"]] = relationship( + back_populates="adventure", cascade="all, delete-orphan" + ) + actions: Mapped[list["Action"]] = relationship( + back_populates="adventure", + cascade="all, delete-orphan", + order_by="Action.index", + ) + scripts: Mapped[list["AdventureScript"]] = relationship( + back_populates="adventure", + cascade="all, delete-orphan", + order_by="AdventureScript.position", + ) + memories: Mapped[list["Memory"]] = relationship( + back_populates="adventure", + cascade="all, delete-orphan", + order_by="Memory.id", + ) + + +class Memory(Base): + """Phase 6: an auto-summarized (or hand-written) fact about the adventure. + + `embedding` is the raw vector as a JSON list (cosine ranking happens in + Python — fine at bank sizes of a few hundred). NULL until embedded, which + also marks it for backfill when an embedding model becomes available. + """ + + __tablename__ = "memories" + + id: Mapped[int] = mapped_column(primary_key=True) + adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE")) + text: Mapped[str] = mapped_column(Text, default="") + embedding: Mapped[list | None] = mapped_column(JSON, nullable=True) + # Action index range this memory summarizes (null for manual memories). + source_start: Mapped[int | None] = mapped_column(Integer, nullable=True) + source_end: Mapped[int | None] = mapped_column(Integer, nullable=True) + pinned: Mapped[bool] = mapped_column(Boolean, default=False) + forgotten: Mapped[bool] = mapped_column(Boolean, default=False) # evicted, kept for UI + use_count: Mapped[int] = mapped_column(Integer, default=0) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + adventure: Mapped[Adventure] = relationship(back_populates="memories") + + @property + def embedded(self) -> bool: + return self.embedding is not None + + +class StoryCard(Base): + """Owned by either a scenario or an adventure (exactly one set).""" + + __tablename__ = "story_cards" + + id: Mapped[int] = mapped_column(primary_key=True) + scenario_id: Mapped[int | None] = mapped_column( + ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=True + ) + adventure_id: Mapped[int | None] = mapped_column( + ForeignKey("adventures.id", ondelete="CASCADE"), nullable=True + ) + type: Mapped[str] = mapped_column(String(100), default="") + name: Mapped[str] = mapped_column(String(200), default="") + keys: Mapped[str] = mapped_column(Text, default="") # comma-separated triggers + entry: Mapped[str] = mapped_column(Text, default="") + notes: Mapped[str] = mapped_column(Text, default="") + + scenario: Mapped[Scenario | None] = relationship(back_populates="story_cards") + adventure: Mapped[Adventure | None] = relationship(back_populates="story_cards") + + +class Action(Base): + __tablename__ = "actions" + + id: Mapped[int] = mapped_column(primary_key=True) + adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE")) + index: Mapped[int] = mapped_column(Integer) + type: Mapped[str] = mapped_column(String(20)) # start|do|say|story|continue|ai + text: Mapped[str] = mapped_column(Text, default="") + # Reasoning-model "thinking" that preceded the text (AI actions only). + reasoning: Mapped[str | None] = mapped_column(Text, nullable=True) + context_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + adventure: Mapped[Adventure] = relationship(back_populates="actions") + + +class Script(Base): + __tablename__ = "scripts" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(200), default="Untitled Script") + description: Mapped[str] = mapped_column(Text, default="") + library_js: Mapped[str] = mapped_column(Text, default="") + input_js: Mapped[str] = mapped_column(Text, default="") + context_js: Mapped[str] = mapped_column(Text, default="") + output_js: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) + + +class AdventureScript(Base): + """A script copied into an adventure at creation, so library edits don't + change running adventures. `state` lives on Adventure.script_state (one + shared state per adventure, as in AI Dungeon).""" + + __tablename__ = "adventure_scripts" + + id: Mapped[int] = mapped_column(primary_key=True) + adventure_id: Mapped[int] = mapped_column(ForeignKey("adventures.id", ondelete="CASCADE")) + position: Mapped[int] = mapped_column(Integer, default=0) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + name: Mapped[str] = mapped_column(String(200), default="Untitled Script") + description: Mapped[str] = mapped_column(Text, default="") + library_js: Mapped[str] = mapped_column(Text, default="") + input_js: Mapped[str] = mapped_column(Text, default="") + context_js: Mapped[str] = mapped_column(Text, default="") + output_js: Mapped[str] = mapped_column(Text, default="") + + adventure: Mapped[Adventure] = relationship(back_populates="scripts") + + +class Settings(Base): + __tablename__ = "settings" + + id: Mapped[int] = mapped_column(primary_key=True) # single row, id=1 + endpoint_url: Mapped[str] = mapped_column(String(500), default="http://localhost:11434/v1") + api_key: Mapped[str] = mapped_column(String(500), default="") + model: Mapped[str] = mapped_column(String(200), default="") + api_mode: Mapped[str] = mapped_column(String(20), default="chat") # chat|completion + temperature: Mapped[float] = mapped_column(Float, default=0.8) + max_output_tokens: Mapped[int] = mapped_column(Integer, default=400) + # Separate thinking budget for reasoning models (OpenRouter-style + # `reasoning: {max_tokens}`); 0 = param not sent. Added on top of + # max_output_tokens so story output keeps its full budget. + reasoning_max_tokens: Mapped[int] = mapped_column(Integer, default=0) + context_token_budget: Mapped[int] = mapped_column(Integer, default=4096) + narrator_prompt: Mapped[str] = mapped_column( + Text, + default=( + "You are a masterful storyteller continuing an interactive adventure. " + "Continue the story naturally in second person, staying consistent with " + "everything established so far. Write vivid prose. Never speak for the " + "player or break character. Do not conclude the story; always leave room " + "for the player's next action." + ), + ) + stream: Mapped[bool] = mapped_column(Boolean, default=True) + # Phase 6: auto-summarization + memory bank + summary_model: Mapped[str] = mapped_column(String(200), default="") # "" = main model + embedding_model: Mapped[str] = mapped_column(String(200), default="") # "" = bank disabled + memory_bank_capacity: Mapped[int] = mapped_column(Integer, default=200) + memory_top_k: Mapped[int] = mapped_column(Integer, default=5) diff --git a/backend/app/providers/__init__.py b/backend/app/providers/__init__.py new file mode 100644 index 0000000..98a1b84 --- /dev/null +++ b/backend/app/providers/__init__.py @@ -0,0 +1,4 @@ +from .base import PromptParts, Provider, ProviderError +from .openai_compatible import OpenAICompatibleProvider + +__all__ = ["PromptParts", "Provider", "ProviderError", "OpenAICompatibleProvider"] diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py new file mode 100644 index 0000000..7a9f2f9 --- /dev/null +++ b/backend/app/providers/base.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import AsyncIterator + + +@dataclass +class PromptParts: + """Assembled context, provider-agnostic. Providers map this to their wire format.""" + + system: str # narrator prompt + AI instructions + memory + story: str # the story text so far (already token-budgeted) + + +class ProviderError(Exception): + """User-presentable provider failure (connection refused, bad key, model not found…).""" + + +class Provider(ABC): + @abstractmethod + def generate( + self, + parts: PromptParts, + *, + temperature: float, + max_tokens: int, + ) -> AsyncIterator[tuple[str, str]]: + """Yield ("text" | "reasoning", chunk) pairs. Raises ProviderError on failure.""" diff --git a/backend/app/providers/openai_compatible.py b/backend/app/providers/openai_compatible.py new file mode 100644 index 0000000..5ac9313 --- /dev/null +++ b/backend/app/providers/openai_compatible.py @@ -0,0 +1,242 @@ +import json +from typing import AsyncIterator + +import httpx + +from .. import debuglog +from .base import PromptParts, Provider, ProviderError + +# Framing appended after the story text in chat mode, so chat-tuned models keep +# continuing prose instead of replying conversationally. +CHAT_CONTINUE_HINT = "\n\n[Continue the story directly. Output only story text.]" + + +class OpenAICompatibleProvider(Provider): + """Adapter for any /v1-style endpoint: Ollama, LM Studio, OpenAI, OpenRouter, vLLM, Groq…""" + + def __init__( + self, + endpoint_url: str, + api_key: str, + model: str, + api_mode: str = "chat", + reasoning_max_tokens: int = 0, + ): + self.base_url = endpoint_url.rstrip("/") + self.api_key = api_key + self.model = model + self.api_mode = api_mode # "chat" | "completion" + # Thinking budget for reasoning models, on top of max_tokens. 0 = the + # `reasoning` param is not sent (endpoints that don't know it may + # reject unknown fields). + self.reasoning_max_tokens = reasoning_max_tokens + + def _headers(self) -> dict: + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + def _apply_reasoning_budget(self, body: dict) -> None: + """Give reasoning models their own thinking budget (OpenRouter-style), + raising max_tokens so the actual output keeps its full budget.""" + if self.reasoning_max_tokens > 0 and self.api_mode == "chat": + body["reasoning"] = {"max_tokens": self.reasoning_max_tokens} + body["max_tokens"] += self.reasoning_max_tokens + + def _request(self, parts: PromptParts, temperature: float, max_tokens: int) -> tuple[str, dict]: + if self.api_mode == "completion": + url = f"{self.base_url}/completions" + body = { + "model": self.model, + "prompt": f"{parts.system}\n\n{parts.story}", + "temperature": temperature, + "max_tokens": max_tokens, + "stream": True, + } + else: + url = f"{self.base_url}/chat/completions" + body = { + "model": self.model, + "messages": [ + {"role": "system", "content": parts.system}, + {"role": "user", "content": parts.story + CHAT_CONTINUE_HINT}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + "stream": True, + } + self._apply_reasoning_budget(body) + return url, body + + @staticmethod + def _extract_chunk(payload: dict) -> str: + choices = payload.get("choices") or [] + if not choices: + return "" + choice = choices[0] + # chat stream → delta.content; completion stream → text; + # non-stream fallbacks → message.content / text + delta = choice.get("delta") or {} + return ( + delta.get("content") + or choice.get("text") + or (choice.get("message") or {}).get("content") + or "" + ) + + @staticmethod + def _extract_reasoning(payload: dict) -> str: + """Reasoning-model thinking: OpenRouter normalizes to `reasoning`; + DeepSeek-style servers use `reasoning_content`.""" + choices = payload.get("choices") or [] + if not choices: + return "" + choice = choices[0] + delta = choice.get("delta") or {} + message = choice.get("message") or {} + return ( + delta.get("reasoning") + or delta.get("reasoning_content") + or message.get("reasoning") + or message.get("reasoning_content") + or "" + ) + + async def generate( + self, parts: PromptParts, *, temperature: float, max_tokens: int + ) -> AsyncIterator[tuple[str, str]]: + """Yields ("text" | "reasoning", chunk) pairs.""" + if not self.model: + raise ProviderError("No model configured — set one in Settings.") + url, body = self._request(parts, temperature, max_tokens) + + log = debuglog.start_entry(url, self.model, body) + received: list[str] = [] + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client: + async with client.stream("POST", url, json=body, headers=self._headers()) as resp: + if resp.status_code != 200: + detail = (await resp.aread()).decode(errors="replace")[:500] + raise ProviderError(self._friendly_http_error(resp.status_code, detail)) + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + debuglog.finish_entry(log, response="".join(received)) + return + try: + payload = json.loads(data) + except ValueError: + continue + reasoning = self._extract_reasoning(payload) + if reasoning: + yield "reasoning", reasoning + chunk = self._extract_chunk(payload) + if chunk: + received.append(chunk) + yield "text", chunk + debuglog.finish_entry(log, response="".join(received)) + except httpx.ConnectError as exc: + error = f"Could not connect to {self.base_url} — is the AI server running?" + debuglog.finish_entry(log, response="".join(received), error=error) + raise ProviderError(error) from exc + except httpx.TimeoutException as exc: + debuglog.finish_entry(log, response="".join(received), error="Timed out") + raise ProviderError("The AI endpoint timed out.") from exc + except httpx.HTTPError as exc: + debuglog.finish_entry(log, response="".join(received), error=str(exc)) + raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc + except (ProviderError, GeneratorExit, BaseException) as exc: + status = "cancelled" if isinstance(exc, GeneratorExit) else str(exc) + debuglog.finish_entry(log, response="".join(received), error=status) + raise + + async def complete( + self, system: str, user: str, *, temperature: float = 0.3, max_tokens: int = 400 + ) -> str: + """Single non-streaming completion for background calls (summarization). + Unlike generate(), no story-continuation framing is added.""" + if not self.model: + raise ProviderError("No model configured — set one in Settings.") + if self.api_mode == "completion": + url = f"{self.base_url}/completions" + body = { + "model": self.model, + "prompt": f"{system}\n\n{user}", + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + } + else: + url = f"{self.base_url}/chat/completions" + body = { + "model": self.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + } + self._apply_reasoning_budget(body) + + log = debuglog.start_entry(url, self.model, body) + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=10)) as client: + resp = await client.post(url, json=body, headers=self._headers()) + except httpx.HTTPError as exc: + debuglog.finish_entry(log, error=str(exc)) + raise ProviderError(f"Request to AI endpoint failed: {exc}") from exc + if resp.status_code != 200: + error = self._friendly_http_error(resp.status_code, resp.text[:500]) + debuglog.finish_entry(log, error=error) + raise ProviderError(error) + try: + text = self._extract_chunk(resp.json()) + except ValueError as exc: + debuglog.finish_entry(log, error="Invalid JSON response") + raise ProviderError("AI endpoint returned invalid JSON.") from exc + debuglog.finish_entry(log, response=text) + return text.strip() + + async def embed(self, texts: list[str]) -> list[list[float]]: + """POST /v1/embeddings; self.model is the embedding model here.""" + if not self.model: + raise ProviderError("No embedding model configured — set one in Settings.") + url = f"{self.base_url}/embeddings" + body = {"model": self.model, "input": texts} + log = debuglog.start_entry(url, self.model, body) + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(60, connect=10)) as client: + resp = await client.post(url, json=body, headers=self._headers()) + except httpx.HTTPError as exc: + debuglog.finish_entry(log, error=str(exc)) + raise ProviderError(f"Embedding request failed: {exc}") from exc + if resp.status_code != 200: + error = self._friendly_http_error(resp.status_code, resp.text[:500]) + debuglog.finish_entry(log, error=error) + raise ProviderError(error) + try: + data = resp.json().get("data", []) + vectors = [item["embedding"] for item in sorted(data, key=lambda d: d.get("index", 0))] + except (ValueError, KeyError, TypeError) as exc: + debuglog.finish_entry(log, error="Malformed embeddings response") + raise ProviderError("AI endpoint returned malformed embeddings.") from exc + if len(vectors) != len(texts): + debuglog.finish_entry(log, error="Embedding count mismatch") + raise ProviderError("AI endpoint returned the wrong number of embeddings.") + debuglog.finish_entry(log, response=f"{len(vectors)} vectors × {len(vectors[0]) if vectors else 0} dims") + return vectors + + def _friendly_http_error(self, status: int, detail: str) -> str: + if status == 401: + return "Authentication failed — check your API key in Settings." + if status == 404: + return ( + f"Endpoint or model not found (HTTP 404). Check the endpoint URL and that " + f"model '{self.model}' exists. {detail}" + ) + return f"AI endpoint returned HTTP {status}: {detail}" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py new file mode 100644 index 0000000..f2c6c3c --- /dev/null +++ b/backend/app/routers/adventures.py @@ -0,0 +1,611 @@ +import json +import re +import threading + +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .. import memorybank, models, schemas +from ..context import build_context +from ..database import get_db +from ..providers import OpenAICompatibleProvider, PromptParts, ProviderError +from ..scripting import ScriptPipeline +from .settings import get_settings + +router = APIRouter(prefix="/api/adventures", tags=["adventures"]) + + +def get_adventure_or_404(adventure_id: int, db: Session) -> models.Adventure: + adventure = db.get(models.Adventure, adventure_id) + if adventure is None: + raise HTTPException(404, "Adventure not found") + return adventure + + +@router.get("", response_model=list[schemas.AdventureListItem]) +def list_adventures(db: Session = Depends(get_db)): + rows = ( + db.query(models.Adventure, func.count(models.Action.id), models.Scenario.title) + .outerjoin(models.Action) + .outerjoin(models.Scenario, models.Adventure.scenario_id == models.Scenario.id) + .group_by(models.Adventure.id) + .order_by(models.Adventure.updated_at.desc()) + .all() + ) + return [ + schemas.AdventureListItem( + id=adv.id, + scenario_id=adv.scenario_id, + scenario_title=scenario_title, + title=adv.title, + updated_at=adv.updated_at, + action_count=count, + ) + for adv, count, scenario_title in rows + ] + + +PLACEHOLDER_RE = re.compile(r"\$\{([^}]+)\}") + + +def fill_placeholders(text: str, values: dict[str, str]) -> str: + """Replace ${Name} with the player-provided value; unknown names are left as-is.""" + if not text or not values: + return text + return PLACEHOLDER_RE.sub( + lambda m: values.get(m.group(1).strip(), m.group(0)), text + ) + + +@router.post("", response_model=schemas.AdventureOut, status_code=201) +def create_adventure(payload: schemas.AdventureCreate, db: Session = Depends(get_db)): + scenario = None + if payload.scenario_id is not None: + scenario = db.get(models.Scenario, payload.scenario_id) + if scenario is None: + raise HTTPException(404, "Scenario not found") + + values = payload.placeholders + adventure = models.Adventure( + scenario_id=scenario.id if scenario else None, + title=payload.title or (scenario.title if scenario else "Untitled Adventure"), + memory=fill_placeholders(scenario.memory, values) if scenario else "", + authors_note=fill_placeholders(scenario.authors_note, values) if scenario else "", + ai_instructions=fill_placeholders(scenario.ai_instructions, values) if scenario else "", + ) + db.add(adventure) + db.flush() + + if scenario: + for card in scenario.story_cards: + db.add( + models.StoryCard( + adventure_id=adventure.id, + type=card.type, + name=card.name, + keys=fill_placeholders(card.keys, values), + entry=fill_placeholders(card.entry, values), + notes=card.notes, + ) + ) + for position, script in enumerate(scenario.scripts): + db.add( + models.AdventureScript( + adventure_id=adventure.id, + position=position, + name=script.name, + description=script.description, + library_js=script.library_js, + input_js=script.input_js, + context_js=script.context_js, + output_js=script.output_js, + ) + ) + if scenario.prompt.strip(): + db.add( + models.Action( + adventure_id=adventure.id, + index=0, + type="start", + text=fill_placeholders(scenario.prompt, values), + ) + ) + + db.commit() + db.refresh(adventure) + return adventure + + +@router.get("/{adventure_id}", response_model=schemas.AdventureOut) +def get_adventure(adventure_id: int, db: Session = Depends(get_db)): + return get_adventure_or_404(adventure_id, db) + + +@router.patch("/{adventure_id}", response_model=schemas.AdventureOut) +def update_adventure( + adventure_id: int, payload: schemas.AdventureUpdate, db: Session = Depends(get_db) +): + adventure = get_adventure_or_404(adventure_id, db) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(adventure, field, value) + db.commit() + return adventure + + +@router.delete("/{adventure_id}", status_code=204) +def delete_adventure(adventure_id: int, db: Session = Depends(get_db)): + adventure = get_adventure_or_404(adventure_id, db) + db.delete(adventure) + db.commit() + + +# ---------- Turn engine ---------- + +# One turn at a time per adventure (in-memory; fine for a single-process local app). +# Sync endpoints run in a threadpool, so the check-and-add must be guarded — and +# it must happen in the request phase, not when the SSE generator first runs, +# or two rapid requests both pass the check and generate concurrently. +_active_turns: set[int] = set() +_active_turns_guard = threading.Lock() + + +def acquire_turn_lock(adventure_id: int): + """Atomically claim the adventure's turn slot; with_turn_lock releases it.""" + with _active_turns_guard: + if adventure_id in _active_turns: + raise HTTPException(409, "A turn is already generating for this adventure.") + _active_turns.add(adventure_id) + + +async def with_turn_lock(adventure_id: int, gen): + """Wrap an SSE generator so the lock (from acquire_turn_lock) is released.""" + try: + async for event in gen: + yield event + finally: + _active_turns.discard(adventure_id) + + +def format_player_input(action_type: str, text: str) -> str: + """AI Dungeon input conventions.""" + text = text.strip() + if action_type == "say": + text = text.strip('"') + if text and text[-1] not in ".!?…": + text += "." + return f'> You say "{text}"' + if action_type == "do": + if text.lower().startswith("you "): + text = text[4:] + if text and text[-1] not in ".!?…": + text += "." + return f"> You {text}" + return text # story: raw text appended + + +def sse(obj: dict) -> str: + return f"data: {json.dumps(obj)}\n\n" + + +def action_json(action: models.Action) -> dict: + return schemas.ActionOut.model_validate(action).model_dump(mode="json") + + +def next_index(adventure: models.Adventure) -> int: + return max((a.index for a in adventure.actions), default=-1) + 1 + + +async def generate_turn(adventure: models.Adventure, db: Session, pipeline: ScriptPipeline): + """SSE generator: streams the AI continuation through the context/output + script hooks, then stores the result.""" + settings = get_settings(db) + memories = await memorybank.retrieve_memories(adventure, settings, update_stats=True) + system_text, story_text, snapshot = build_context(adventure, settings, memories) + + # onModelContext: scripts see (and may rewrite) the whole assembled context. + combined = f"{system_text}\n\n{story_text}" if system_text else story_text + modified, stop = pipeline.run("context", combined) + if stop: + yield sse({"type": "stopped", "script": pipeline.report()}) + return + context_changed = modified != combined + parts = ( + PromptParts(system="", story=modified) + if context_changed + else PromptParts(system=system_text, story=story_text) + ) + snapshot["script"] = pipeline.report() | { + "context_changed": context_changed, + "context_before": combined if context_changed else None, + "context_after": modified if context_changed else None, + } + + provider = OpenAICompatibleProvider( + settings.endpoint_url, settings.api_key, settings.model, settings.api_mode, + settings.reasoning_max_tokens, + ) + chunks: list[str] = [] + reasoning_chunks: list[str] = [] + try: + async for kind, chunk in provider.generate( + parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens + ): + if kind == "reasoning": + reasoning_chunks.append(chunk) + yield sse({"type": "reasoning", "text": chunk}) + else: + chunks.append(chunk) + yield sse({"type": "chunk", "text": chunk}) + except ProviderError as exc: + yield sse({"type": "error", "detail": str(exc)}) + return + + text = "".join(chunks).strip() + if not text: + yield sse({"type": "error", "detail": "The AI returned an empty response."}) + return + + # onOutput + text, _ = pipeline.run("output", text) + if not text.strip(): + yield sse({"type": "error", "detail": "A script's output modifier returned empty text."}) + return + snapshot["script"] = snapshot["script"] | pipeline.report() + + ai_action = models.Action( + adventure_id=adventure.id, + index=next_index(adventure), + type="ai", + text=text, + reasoning="".join(reasoning_chunks).strip() or None, + context_snapshot=snapshot, + ) + db.add(ai_action) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(ai_action) + yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()}) + # Phase 6: fire-and-forget summarization/embedding (opens its own DB session). + memorybank.schedule_post_turn(adventure) + + +async def run_player_turn( + adventure: models.Adventure, db: Session, payload: schemas.ActionCreate +): + pipeline = ScriptPipeline(adventure, db) + + # An empty do/say/story is just a continue. + if payload.type != "continue" and payload.text.strip(): + # onInput sees the formatted text (as in AI Dungeon: "> You ..."). + formatted = format_player_input(payload.type, payload.text) + modified, stop = pipeline.run("input", formatted) + if not modified.strip(): + yield sse({"type": "error", "detail": "A script's input modifier returned empty text.", + "script": pipeline.report()}) + return + player_action = models.Action( + adventure_id=adventure.id, + index=next_index(adventure), + type=payload.type, + text=modified, + ) + db.add(player_action) + db.commit() + db.refresh(player_action) + # The new action was added via its FK, so the loaded adventure.actions + # collection is stale — without this, build_context and next_index for + # the AI action would not see the player action just saved. + db.expire(adventure, ["actions"]) + yield sse({"type": "player", "action": action_json(player_action)}) + if stop: + # onInput { stop: true } prevents the AI call. + yield sse({"type": "stopped", "script": pipeline.report()}) + return + + async for event in generate_turn(adventure, db, pipeline): + yield event + + +@router.post("/{adventure_id}/actions") +def create_action( + adventure_id: int, payload: schemas.ActionCreate, db: Session = Depends(get_db) +): + adventure = get_adventure_or_404(adventure_id, db) + acquire_turn_lock(adventure_id) + return StreamingResponse( + with_turn_lock(adventure_id, run_player_turn(adventure, db, payload)), + media_type="text/event-stream", + ) + + +@router.post("/{adventure_id}/retry") +def retry_action(adventure_id: int, db: Session = Depends(get_db)): + """Delete the last AI action and regenerate from the same input.""" + adventure = get_adventure_or_404(adventure_id, db) + acquire_turn_lock(adventure_id) + try: + if adventure.actions and adventure.actions[-1].type == "ai": + db.delete(adventure.actions[-1]) + db.commit() + db.refresh(adventure) + except BaseException: + _active_turns.discard(adventure_id) + raise + return StreamingResponse( + with_turn_lock(adventure_id, generate_turn(adventure, db, ScriptPipeline(adventure, db))), + media_type="text/event-stream", + ) + + +@router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut]) +def undo_turn(adventure_id: int, db: Session = Depends(get_db)): + """Delete the last turn: the trailing AI action plus its player action, if any.""" + adventure = get_adventure_or_404(adventure_id, db) + actions = list(adventure.actions) + if not actions or actions[-1].type == "start": + raise HTTPException(400, "Nothing to undo") + last = actions.pop() + db.delete(last) + if last.type == "ai" and actions and actions[-1].type in ("do", "say", "story"): + db.delete(actions.pop()) + db.commit() + db.refresh(adventure) + return adventure.actions + + +# ---------- Import / Export ---------- + +@router.get("/{adventure_id}/export") +def export_adventure(adventure_id: int, db: Session = Depends(get_db)): + """Full backup: plot components, story cards, scripts (+state), every action.""" + adv = get_adventure_or_404(adventure_id, db) + return { + "format": "ai-dnd-adventure-v1", + "title": adv.title, + "memory": adv.memory, + "authorsNote": adv.authors_note, + "aiInstructions": adv.ai_instructions, + "storySummary": adv.story_summary, + "scriptState": adv.script_state, + "autoSummarize": adv.auto_summarize, + "memoryBankEnabled": adv.memory_bank_enabled, + "memoryCursor": adv.memory_cursor, + "summaryCursor": adv.summary_cursor, + "memories": [ + { + "text": m.text, "pinned": m.pinned, "forgotten": m.forgotten, + "sourceStart": m.source_start, "sourceEnd": m.source_end, + "useCount": m.use_count, + } + for m in adv.memories + ], + "storyCards": [ + {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} + for c in adv.story_cards + ], + "scripts": [ + { + "position": s.position, "enabled": s.enabled, + "name": s.name, "description": s.description, + "library": s.library_js, "input": s.input_js, + "context": s.context_js, "output": s.output_js, + } + for s in adv.scripts + ], + "actions": [ + { + "index": a.index, "type": a.type, "text": a.text, + "reasoning": a.reasoning, + "createdAt": a.created_at.isoformat(), + } + for a in adv.actions + ], + } + + +@router.post("/import", response_model=schemas.AdventureOut, status_code=201) +def import_adventure(bundle: dict = Body(...), db: Session = Depends(get_db)): + if bundle.get("format") != "ai-dnd-adventure-v1": + raise HTTPException(400, "Not an adventure export file (expected format ai-dnd-adventure-v1).") + + adventure = models.Adventure( + title=str(bundle.get("title") or "Imported Adventure"), + memory=str(bundle.get("memory") or ""), + authors_note=str(bundle.get("authorsNote") or ""), + ai_instructions=str(bundle.get("aiInstructions") or ""), + story_summary=str(bundle.get("storySummary") or ""), + script_state=bundle.get("scriptState") or {}, + auto_summarize=bool(bundle.get("autoSummarize", False)), + memory_bank_enabled=bool(bundle.get("memoryBankEnabled", False)), + memory_cursor=int(bundle.get("memoryCursor", 0)), + summary_cursor=int(bundle.get("summaryCursor", 0)), + ) + db.add(adventure) + db.flush() + + for m in bundle.get("memories") or []: + if isinstance(m, dict) and str(m.get("text") or "").strip(): + db.add(models.Memory( + adventure_id=adventure.id, + text=str(m["text"]), + pinned=bool(m.get("pinned", False)), + forgotten=bool(m.get("forgotten", False)), + source_start=m.get("sourceStart"), + source_end=m.get("sourceEnd"), + use_count=int(m.get("useCount", 0)), + )) + + for card in bundle.get("storyCards") or []: + if isinstance(card, dict): + db.add(models.StoryCard( + adventure_id=adventure.id, + type=str(card.get("type") or ""), + name=str(card.get("name") or ""), + keys=str(card.get("keys") or ""), + entry=str(card.get("entry") or ""), + notes=str(card.get("notes") or ""), + )) + + for i, s in enumerate(bundle.get("scripts") or []): + if isinstance(s, dict): + db.add(models.AdventureScript( + adventure_id=adventure.id, + position=int(s.get("position", i)), + enabled=bool(s.get("enabled", True)), + name=str(s.get("name") or "Imported Script"), + description=str(s.get("description") or ""), + library_js=str(s.get("library") or ""), + input_js=str(s.get("input") or ""), + context_js=str(s.get("context") or ""), + output_js=str(s.get("output") or ""), + )) + + for i, a in enumerate(bundle.get("actions") or []): + if isinstance(a, dict) and str(a.get("text") or ""): + db.add(models.Action( + adventure_id=adventure.id, + index=int(a.get("index", i)), + type=str(a.get("type") or "story"), + text=str(a["text"]), + reasoning=str(a["reasoning"]) if a.get("reasoning") else None, + )) + + db.commit() + db.refresh(adventure) + return adventure + + +# ---------- Adventure scripts ---------- + +@router.get("/{adventure_id}/scripts", response_model=list[schemas.AdventureScriptOut]) +def list_adventure_scripts(adventure_id: int, db: Session = Depends(get_db)): + return get_adventure_or_404(adventure_id, db).scripts + + +@router.patch( + "/{adventure_id}/scripts/{adv_script_id}", response_model=schemas.AdventureScriptOut +) +def update_adventure_script( + adventure_id: int, + adv_script_id: int, + payload: schemas.AdventureScriptUpdate, + db: Session = Depends(get_db), +): + script = db.get(models.AdventureScript, adv_script_id) + if script is None or script.adventure_id != adventure_id: + raise HTTPException(404, "Script not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(script, field, value) + db.commit() + return script + + +# ---------- Insights ---------- + +@router.get("/{adventure_id}/context") +async def dry_run_context(adventure_id: int, db: Session = Depends(get_db)): + """What would be sent to the AI if the player continued right now.""" + adventure = get_adventure_or_404(adventure_id, db) + settings = get_settings(db) + memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) + _, _, report = build_context(adventure, settings, memories) + return report + + +@router.get("/{adventure_id}/actions/{action_id}/context") +def action_context(adventure_id: int, action_id: int, db: Session = Depends(get_db)): + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + if action.context_snapshot is None: + raise HTTPException(404, "No context snapshot for this action") + return action.context_snapshot + + +# ---------- Memory bank (Phase 6) ---------- + +@router.get("/{adventure_id}/memories", response_model=list[schemas.MemoryOut]) +def list_memories(adventure_id: int, db: Session = Depends(get_db)): + return get_adventure_or_404(adventure_id, db).memories + + +@router.post("/{adventure_id}/memories", response_model=schemas.MemoryOut, status_code=201) +def create_memory( + adventure_id: int, payload: schemas.MemoryCreate, db: Session = Depends(get_db) +): + """Manually add a memory; it gets embedded by the next post-turn pass.""" + adventure = get_adventure_or_404(adventure_id, db) + if not payload.text.strip(): + raise HTTPException(400, "Memory text cannot be empty") + memory = models.Memory(adventure_id=adventure.id, text=payload.text.strip()) + db.add(memory) + db.commit() + db.refresh(memory) + return memory + + +@router.patch("/{adventure_id}/memories/{memory_id}", response_model=schemas.MemoryOut) +def update_memory( + adventure_id: int, + memory_id: int, + payload: schemas.MemoryUpdate, + db: Session = Depends(get_db), +): + memory = db.get(models.Memory, memory_id) + if memory is None or memory.adventure_id != adventure_id: + raise HTTPException(404, "Memory not found") + fields = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if v is not None} + if "text" in fields and fields["text"].strip() != memory.text: + memory.embedding = None # re-embed on the next post-turn pass + for field, value in fields.items(): + setattr(memory, field, value) + db.commit() + return memory + + +@router.delete("/{adventure_id}/memories/{memory_id}", status_code=204) +def delete_memory(adventure_id: int, memory_id: int, db: Session = Depends(get_db)): + memory = db.get(models.Memory, memory_id) + if memory is None or memory.adventure_id != adventure_id: + raise HTTPException(404, "Memory not found") + db.delete(memory) + db.commit() + + +# ---------- Actions (CRUD) ---------- + +@router.get("/{adventure_id}/actions", response_model=list[schemas.ActionOut]) +def list_actions(adventure_id: int, db: Session = Depends(get_db)): + get_adventure_or_404(adventure_id, db) + return ( + db.query(models.Action) + .filter(models.Action.adventure_id == adventure_id) + .order_by(models.Action.index) + .all() + ) + + +@router.patch("/{adventure_id}/actions/{action_id}", response_model=schemas.ActionOut) +def update_action( + adventure_id: int, + action_id: int, + payload: schemas.ActionUpdate, + db: Session = Depends(get_db), +): + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + action.text = payload.text + db.commit() + return action + + +@router.delete("/{adventure_id}/actions/{action_id}", status_code=204) +def delete_action(adventure_id: int, action_id: int, db: Session = Depends(get_db)): + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + db.delete(action) + db.commit() diff --git a/backend/app/routers/debug.py b/backend/app/routers/debug.py new file mode 100644 index 0000000..e5b697b --- /dev/null +++ b/backend/app/routers/debug.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from .. import debuglog + +router = APIRouter(prefix="/api/debug", tags=["debug"]) + + +@router.get("/requests") +def recent_requests(): + """Most-recent-first log of provider requests/responses (no API keys).""" + return debuglog.recent() diff --git a/backend/app/routers/scenarios.py b/backend/app/routers/scenarios.py new file mode 100644 index 0000000..b8eb721 --- /dev/null +++ b/backend/app/routers/scenarios.py @@ -0,0 +1,166 @@ +from fastapi import APIRouter, Body, Depends, HTTPException +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db + +router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) + + +def get_scenario_or_404(scenario_id: int, db: Session) -> models.Scenario: + scenario = db.get(models.Scenario, scenario_id) + if scenario is None: + raise HTTPException(404, "Scenario not found") + return scenario + + +@router.get("", response_model=list[schemas.ScenarioListItem]) +def list_scenarios(db: Session = Depends(get_db)): + return ( + db.query(models.Scenario) + .order_by(models.Scenario.updated_at.desc()) + .all() + ) + + +@router.post("", response_model=schemas.ScenarioOut, status_code=201) +def create_scenario(payload: schemas.ScenarioCreate, db: Session = Depends(get_db)): + scenario = models.Scenario(**payload.model_dump()) + db.add(scenario) + db.commit() + return scenario + + +@router.get("/{scenario_id}", response_model=schemas.ScenarioOut) +def get_scenario(scenario_id: int, db: Session = Depends(get_db)): + return get_scenario_or_404(scenario_id, db) + + +@router.patch("/{scenario_id}", response_model=schemas.ScenarioOut) +def update_scenario( + scenario_id: int, payload: schemas.ScenarioUpdate, db: Session = Depends(get_db) +): + scenario = get_scenario_or_404(scenario_id, db) + data = payload.model_dump(exclude_unset=True) + script_ids = data.pop("script_ids", None) + for field, value in data.items(): + setattr(scenario, field, value) + if script_ids is not None: + scripts = db.query(models.Script).filter(models.Script.id.in_(script_ids)).all() + if len(scripts) != len(set(script_ids)): + raise HTTPException(404, "One or more scripts not found") + scenario.scripts = sorted(scripts, key=lambda s: script_ids.index(s.id)) + db.commit() + return scenario + + +@router.delete("/{scenario_id}", status_code=204) +def delete_scenario(scenario_id: int, db: Session = Depends(get_db)): + scenario = get_scenario_or_404(scenario_id, db) + db.delete(scenario) + db.commit() + + +# ---------- Import / Export ---------- + +@router.get("/{scenario_id}/export") +def export_scenario(scenario_id: int, db: Session = Depends(get_db)): + s = get_scenario_or_404(scenario_id, db) + return { + "format": "ai-dnd-scenario-v1", + "title": s.title, + "description": s.description, + "prompt": s.prompt, + "memory": s.memory, + "authorsNote": s.authors_note, + "aiInstructions": s.ai_instructions, + "tags": s.tags, + "storyCards": [ + {"type": c.type, "name": c.name, "keys": c.keys, "entry": c.entry, "notes": c.notes} + for c in s.story_cards + ], + "scripts": [ + { + "name": sc.name, "description": sc.description, "library": sc.library_js, + "input": sc.input_js, "context": sc.context_js, "output": sc.output_js, + } + for sc in s.scripts + ], + } + + +# Key aliases seen in AI Dungeon scenario exports, mapped best-effort. +_SCENARIO_KEYS = { + "title": "title", + "description": "description", + "prompt": "prompt", + "memory": "memory", + "authorsNote": "authors_note", + "authors_note": "authors_note", + "authorsNoteText": "authors_note", + "aiInstructions": "ai_instructions", + "ai_instructions": "ai_instructions", + "instructions": "ai_instructions", +} +_IGNORED_KEYS = {"format", "storyCards", "worldInfo", "worldInformation", "scripts", "tags", + "createdAt", "updatedAt", "id", "publicId", "image", "nsfw", "type", "options"} + + +@router.post("/import", status_code=201) +def import_scenario(bundle: dict = Body(...), db: Session = Depends(get_db)): + """Accepts our export format and AI Dungeon scenario exports best-effort; + reports any keys it didn't understand.""" + fields: dict = {} + unmapped: list[str] = [] + for key, value in bundle.items(): + if key in _SCENARIO_KEYS and isinstance(value, str): + fields[_SCENARIO_KEYS[key]] = value + elif key not in _IGNORED_KEYS: + unmapped.append(key) + + tags = bundle.get("tags") + if isinstance(tags, list): + fields["tags"] = ", ".join(str(t) for t in tags) + elif isinstance(tags, str): + fields["tags"] = tags + + scenario = models.Scenario(**fields) + if not scenario.title: + scenario.title = "Imported Scenario" + db.add(scenario) + db.flush() + + cards = bundle.get("storyCards") or bundle.get("worldInfo") or [] + for card in cards: + if not isinstance(card, dict): + continue + db.add( + models.StoryCard( + scenario_id=scenario.id, + type=str(card.get("type") or ""), + name=str(card.get("name") or card.get("title") or ""), + keys=str(card.get("keys") or ""), + # AI Dungeon world info uses "value"; story cards use "entry". + entry=str(card.get("entry") or card.get("value") or ""), + notes=str(card.get("notes") or card.get("description") or ""), + ) + ) + + for item in bundle.get("scripts") or []: + if not isinstance(item, dict): + continue + script = models.Script( + name=str(item.get("name") or "Imported Script"), + description=str(item.get("description") or ""), + library_js=str(item.get("library") or item.get("sharedLibrary") or ""), + input_js=str(item.get("input") or item.get("onInput") or ""), + context_js=str(item.get("context") or item.get("onModelContext") or ""), + output_js=str(item.get("output") or item.get("onOutput") or ""), + ) + db.add(script) + db.flush() + scenario.scripts.append(script) + + db.commit() + out = schemas.ScenarioOut.model_validate(scenario).model_dump(mode="json") + return {"scenario": out, "unmapped_keys": unmapped} diff --git a/backend/app/routers/scripts.py b/backend/app/routers/scripts.py new file mode 100644 index 0000000..67f389c --- /dev/null +++ b/backend/app/routers/scripts.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Body, Depends, HTTPException +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db +from ..scripting import run_hook + +router = APIRouter(prefix="/api/scripts", tags=["scripts"]) + +HOOK_FIELDS = {"input": "input_js", "context": "context_js", "output": "output_js"} + + +def get_script_or_404(script_id: int, db: Session) -> models.Script: + script = db.get(models.Script, script_id) + if script is None: + raise HTTPException(404, "Script not found") + return script + + +@router.get("", response_model=list[schemas.ScriptOut]) +def list_scripts(db: Session = Depends(get_db)): + return db.query(models.Script).order_by(models.Script.updated_at.desc()).all() + + +@router.post("", response_model=schemas.ScriptOut, status_code=201) +def create_script(payload: schemas.ScriptCreate, db: Session = Depends(get_db)): + script = models.Script(**payload.model_dump()) + db.add(script) + db.commit() + return script + + +@router.get("/{script_id}", response_model=schemas.ScriptOut) +def get_script(script_id: int, db: Session = Depends(get_db)): + return get_script_or_404(script_id, db) + + +@router.patch("/{script_id}", response_model=schemas.ScriptOut) +def update_script(script_id: int, payload: schemas.ScriptUpdate, db: Session = Depends(get_db)): + script = get_script_or_404(script_id, db) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(script, field, value) + db.commit() + return script + + +@router.delete("/{script_id}", status_code=204) +def delete_script(script_id: int, db: Session = Depends(get_db)): + db.delete(get_script_or_404(script_id, db)) + db.commit() + + +@router.post("/{script_id}/test") +def test_script( + script_id: int, payload: schemas.ScriptTestRequest, db: Session = Depends(get_db) +): + """Dry-run one hook against sample text — no AI call, no persistence.""" + script = get_script_or_404(script_id, db) + result = run_hook( + script.library_js, + getattr(script, HOOK_FIELDS[payload.hook]), + payload.text, + payload.state, + history=[], + story_cards=[], + info={"actionCount": 0, "characterNames": [], "memoryLength": 0, "maxChars": 0}, + ) + return { + "text": result.text, + "stop": result.stop, + "state": result.state, + "storyCards": result.story_cards, + "logs": result.logs, + "error": result.error, + } + + +# ---------- Import / Export ---------- + +@router.get("/{script_id}/export") +def export_script(script_id: int, db: Session = Depends(get_db)): + """JSON bundle matching how AI Dungeon scripts circulate.""" + script = get_script_or_404(script_id, db) + return { + "name": script.name, + "description": script.description, + "library": script.library_js, + "input": script.input_js, + "context": script.context_js, + "output": script.output_js, + } + + +@router.post("/import", response_model=schemas.ScriptOut, status_code=201) +def import_script(bundle: dict = Body(...), db: Session = Depends(get_db)): + """Accepts our export bundle; tolerates *_js key names too.""" + def pick(*keys: str) -> str: + for key in keys: + value = bundle.get(key) + if isinstance(value, str): + return value + return "" + + script = models.Script( + name=pick("name") or "Imported Script", + description=pick("description"), + library_js=pick("library", "library_js", "sharedLibrary"), + input_js=pick("input", "input_js", "onInput"), + context_js=pick("context", "context_js", "onModelContext"), + output_js=pick("output", "output_js", "onOutput"), + ) + db.add(script) + db.commit() + return script diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..047e17f --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,57 @@ +import httpx +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +def get_settings(db: Session) -> models.Settings: + settings = db.get(models.Settings, 1) + if settings is None: + settings = models.Settings(id=1) + db.add(settings) + db.commit() + return settings + + +@router.get("", response_model=schemas.SettingsOut) +def read_settings(db: Session = Depends(get_db)): + return get_settings(db) + + +@router.put("", response_model=schemas.SettingsOut) +def update_settings(payload: schemas.SettingsUpdate, db: Session = Depends(get_db)): + settings = get_settings(db) + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(settings, field, value) + db.commit() + return settings + + +@router.post("/test") +async def test_connection(db: Session = Depends(get_db)): + """Hit the endpoint's /models listing as a cheap connectivity check.""" + settings = get_settings(db) + url = settings.endpoint_url.rstrip("/") + "/models" + headers = {} + if settings.api_key: + headers["Authorization"] = f"Bearer {settings.api_key}" + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url, headers=headers) + except httpx.HTTPError as exc: + return {"ok": False, "detail": f"Connection failed: {exc}"} + + if resp.status_code != 200: + return {"ok": False, "detail": f"HTTP {resp.status_code}: {resp.text[:300]}"} + + models_available: list[str] = [] + try: + data = resp.json() + models_available = [m.get("id", "?") for m in data.get("data", [])] + except ValueError: + pass + return {"ok": True, "models": models_available} diff --git a/backend/app/routers/story_cards.py b/backend/app/routers/story_cards.py new file mode 100644 index 0000000..906266e --- /dev/null +++ b/backend/app/routers/story_cards.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from .. import models, schemas +from ..database import get_db + +router = APIRouter(prefix="/api/story-cards", tags=["story-cards"]) + + +@router.get("", response_model=list[schemas.StoryCardOut]) +def list_story_cards( + scenario_id: int | None = None, + adventure_id: int | None = None, + db: Session = Depends(get_db), +): + query = db.query(models.StoryCard) + if scenario_id is not None: + query = query.filter(models.StoryCard.scenario_id == scenario_id) + if adventure_id is not None: + query = query.filter(models.StoryCard.adventure_id == adventure_id) + return query.order_by(models.StoryCard.id).all() + + +@router.post("", response_model=schemas.StoryCardOut, status_code=201) +def create_story_card(payload: schemas.StoryCardCreate, db: Session = Depends(get_db)): + if (payload.scenario_id is None) == (payload.adventure_id is None): + raise HTTPException(422, "Provide exactly one of scenario_id or adventure_id") + owner_model = models.Scenario if payload.scenario_id else models.Adventure + owner_id = payload.scenario_id or payload.adventure_id + if db.get(owner_model, owner_id) is None: + raise HTTPException(404, "Owner not found") + card = models.StoryCard(**payload.model_dump()) + db.add(card) + db.commit() + return card + + +@router.patch("/{card_id}", response_model=schemas.StoryCardOut) +def update_story_card( + card_id: int, payload: schemas.StoryCardUpdate, db: Session = Depends(get_db) +): + card = db.get(models.StoryCard, card_id) + if card is None: + raise HTTPException(404, "Story card not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(card, field, value) + db.commit() + return card + + +@router.delete("/{card_id}", status_code=204) +def delete_story_card(card_id: int, db: Session = Depends(get_db)): + card = db.get(models.StoryCard, card_id) + if card is None: + raise HTTPException(404, "Story card not found") + db.delete(card) + db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..66f0ef6 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,265 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class ORMModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +# ---------- Story cards ---------- + +class StoryCardBase(BaseModel): + type: str = "" + name: str = "" + keys: str = "" + entry: str = "" + notes: str = "" + + +class StoryCardCreate(StoryCardBase): + scenario_id: int | None = None + adventure_id: int | None = None + + +class StoryCardUpdate(BaseModel): + type: str | None = None + name: str | None = None + keys: str | None = None + entry: str | None = None + notes: str | None = None + + +class StoryCardOut(ORMModel, StoryCardBase): + id: int + scenario_id: int | None + adventure_id: int | None + + +# ---------- Scenarios ---------- + +class ScenarioBase(BaseModel): + title: str = "Untitled Scenario" + description: str = "" + prompt: str = "" + memory: str = "" + authors_note: str = "" + ai_instructions: str = "" + tags: str = "" + + +class ScenarioCreate(ScenarioBase): + pass + + +class ScenarioUpdate(BaseModel): + title: str | None = None + description: str | None = None + prompt: str | None = None + memory: str | None = None + authors_note: str | None = None + ai_instructions: str | None = None + tags: str | None = None + script_ids: list[int] | None = None + + +class ScenarioOut(ORMModel, ScenarioBase): + id: int + created_at: datetime + updated_at: datetime + story_cards: list[StoryCardOut] = [] + scripts: list["ScriptOut"] = [] + + +class ScenarioListItem(ORMModel): + id: int + title: str + description: str + tags: str + updated_at: datetime + + +# ---------- Adventures ---------- + +class AdventureCreate(BaseModel): + scenario_id: int | None = None + title: str | None = None + # ${Placeholder} values collected from the player at start (AI Dungeon behavior). + placeholders: dict[str, str] = {} + + +class AdventureUpdate(BaseModel): + title: str | None = None + memory: str | None = None + authors_note: str | None = None + ai_instructions: str | None = None + story_summary: str | None = None + auto_summarize: bool | None = None + memory_bank_enabled: bool | None = None + + +class ActionOut(ORMModel): + id: int + adventure_id: int + index: int + type: str + text: str + reasoning: str | None = None + created_at: datetime + + +class ActionUpdate(BaseModel): + text: str + + +class ActionCreate(BaseModel): + type: Literal["do", "say", "story", "continue"] + text: str = "" + + +class AdventureOut(ORMModel): + id: int + scenario_id: int | None + title: str + memory: str + authors_note: str + ai_instructions: str + story_summary: str + auto_summarize: bool + memory_bank_enabled: bool + created_at: datetime + updated_at: datetime + story_cards: list[StoryCardOut] = [] + actions: list[ActionOut] = [] + + +# ---------- Memory bank (Phase 6) ---------- + +class MemoryOut(ORMModel): + id: int + adventure_id: int + text: str + pinned: bool + forgotten: bool + embedded: bool # model property: embedding vector present + use_count: int + last_used_at: datetime | None + source_start: int | None + source_end: int | None + created_at: datetime + + +class MemoryCreate(BaseModel): + text: str + + +class MemoryUpdate(BaseModel): + text: str | None = None + pinned: bool | None = None + forgotten: bool | None = None + + +class AdventureListItem(ORMModel): + id: int + scenario_id: int | None + scenario_title: str | None = None + title: str + updated_at: datetime + action_count: int = 0 + + +# ---------- Scripts ---------- + +class ScriptBase(BaseModel): + name: str = "Untitled Script" + description: str = "" + library_js: str = "" + input_js: str = "" + context_js: str = "" + output_js: str = "" + + +class ScriptCreate(ScriptBase): + pass + + +class ScriptUpdate(BaseModel): + name: str | None = None + description: str | None = None + library_js: str | None = None + input_js: str | None = None + context_js: str | None = None + output_js: str | None = None + + +class ScriptOut(ORMModel, ScriptBase): + id: int + created_at: datetime + updated_at: datetime + + +class ScriptTestRequest(BaseModel): + hook: Literal["input", "context", "output"] + text: str = "" + state: dict = {} + + +class AdventureScriptOut(ORMModel): + id: int + adventure_id: int + position: int + enabled: bool + name: str + description: str + library_js: str + input_js: str + context_js: str + output_js: str + + +class AdventureScriptUpdate(BaseModel): + enabled: bool | None = None + library_js: str | None = None + input_js: str | None = None + context_js: str | None = None + output_js: str | None = None + + +# ---------- Settings ---------- + +class SettingsOut(ORMModel): + endpoint_url: str + api_key: str + model: str + api_mode: str + temperature: float + max_output_tokens: int + reasoning_max_tokens: int + context_token_budget: int + narrator_prompt: str + stream: bool + summary_model: str + embedding_model: str + memory_bank_capacity: int + memory_top_k: int + + +ScenarioOut.model_rebuild() + + +class SettingsUpdate(BaseModel): + endpoint_url: str | None = None + api_key: str | None = None + model: str | None = None + api_mode: str | None = None + temperature: float | None = None + max_output_tokens: int | None = None + reasoning_max_tokens: int | None = None + context_token_budget: int | None = None + narrator_prompt: str | None = None + stream: bool | None = None + summary_model: str | None = None + embedding_model: str | None = None + memory_bank_capacity: int | None = None + memory_top_k: int | None = None diff --git a/backend/app/scripting/__init__.py b/backend/app/scripting/__init__.py new file mode 100644 index 0000000..d8814e6 --- /dev/null +++ b/backend/app/scripting/__init__.py @@ -0,0 +1,4 @@ +from .engine import HookResult, run_hook +from .pipeline import ScriptPipeline + +__all__ = ["HookResult", "ScriptPipeline", "run_hook"] diff --git a/backend/app/scripting/engine.py b/backend/app/scripting/engine.py new file mode 100644 index 0000000..91caec8 --- /dev/null +++ b/backend/app/scripting/engine.py @@ -0,0 +1,143 @@ +"""AI Dungeon-compatible script execution in an embedded QuickJS sandbox. + +Each hook run is fully isolated (fresh Context), capped at 16 MB memory and +2 seconds CPU, with no filesystem/network/process access (QuickJS has none by +default). Scripts follow the AI Dungeon contract: define a `modifier(text)` +and call it as the last line; its return value `{ text, stop }` is the result. +""" + +import json +from dataclasses import dataclass, field + +import quickjs + +MEMORY_LIMIT = 16 * 1024 * 1024 +TIME_LIMIT_SECONDS = 2 +HISTORY_WINDOW = 100 # recent actions exposed as `history` + +# Globals per the official docs: text, state, history, storyCards, info, +# log/console.log, story card functions, plus legacy worldInfo aliases. +PRELUDE = """ +"use strict"; +var __logs = []; +var state = __DATA__.state; +var text = __DATA__.text; +var history = __DATA__.history; +var storyCards = __DATA__.storyCards; +var info = __DATA__.info; + +function log(msg) { + __logs.push(typeof msg === "string" ? msg : JSON.stringify(msg)); +} +var console = { log: log }; + +function addStoryCard(keys, entry, type) { + for (var i = 0; i < storyCards.length; i++) { + if (storyCards[i].keys === keys) return false; + } + storyCards.push({ id: null, keys: keys || "", entry: entry || "", type: type || "" }); + return storyCards.length - 1; +} +function updateStoryCard(index, keys, entry, type) { + var card = storyCards[index]; + if (!card) throw new Error("Story card not found"); + card.keys = keys; + card.entry = entry; + card.type = type; +} +function removeStoryCard(index) { + if (!storyCards[index]) throw new Error("Story card not found"); + storyCards.splice(index, 1); +} + +// Legacy aliases used by older AI Dungeon scripts. +var worldInfo = storyCards; +var worldEntries = storyCards; +function addWorldEntry(keys, entry) { return addStoryCard(keys, entry, ""); } +function updateWorldEntry(index, keys, entry) { + var card = storyCards[index]; + if (!card) throw new Error("World entry not found"); + card.keys = keys; + card.entry = entry; +} +function removeWorldEntry(index) { return removeStoryCard(index); } +""" + +COLLECT = """ +JSON.stringify({ + result: (typeof __result === "undefined" || __result === null) ? null : __result, + state: state, + storyCards: storyCards, + logs: __logs +}) +""" + + +@dataclass +class HookResult: + text: str + stop: bool = False + state: dict = field(default_factory=dict) + story_cards: list = field(default_factory=list) + logs: list = field(default_factory=list) + error: str | None = None + + +def run_hook( + library_js: str, + hook_js: str, + text: str, + state: dict, + history: list[dict], + story_cards: list[dict], + info: dict, +) -> HookResult: + """Run one modifier hook. Never raises: failures come back as .error with + text/state/cards unchanged, so a bad script can't break a turn.""" + unchanged = HookResult(text=text, state=state, story_cards=story_cards) + source = f"{library_js}\n;\n{hook_js}" if library_js.strip() else hook_js + if not source.strip(): + return unchanged + + data = { + "state": state, + "text": text, + "history": history[-HISTORY_WINDOW:], + "storyCards": story_cards, + "info": info, + } + try: + ctx = quickjs.Context() + ctx.set_memory_limit(MEMORY_LIMIT) + ctx.set_time_limit(TIME_LIMIT_SECONDS) + ctx.eval(f"var __DATA__ = {json.dumps(data)};") + ctx.eval(PRELUDE) + ctx.eval(f"var __SRC__ = {json.dumps(source)};") + # Indirect eval keeps the script in global scope, so `modifier(text)` as the + # script's final expression statement becomes the completion value. + ctx.eval("var __result = (0, eval)(__SRC__);") + collected = json.loads(ctx.eval(COLLECT)) + except quickjs.JSException as exc: + unchanged.error = f"Script error: {exc}" + return unchanged + except Exception as exc: # memory limit, invalid JSON state, engine faults + unchanged.error = f"Script execution failed: {exc}" + return unchanged + + result = collected.get("result") + new_text, stop = text, False + if isinstance(result, dict): + if isinstance(result.get("text"), str): + new_text = result["text"] + stop = bool(result.get("stop")) + elif isinstance(result, str): + new_text = result + + new_state = collected.get("state") + return HookResult( + text=new_text, + stop=stop, + state=new_state if isinstance(new_state, dict) else {}, + story_cards=collected.get("storyCards") or [], + logs=collected.get("logs") or [], + ) diff --git a/backend/app/scripting/pipeline.py b/backend/app/scripting/pipeline.py new file mode 100644 index 0000000..9b677ac --- /dev/null +++ b/backend/app/scripting/pipeline.py @@ -0,0 +1,95 @@ +"""Runs an adventure's enabled scripts through a turn's hook points, applying +state and story-card mutations back to the database after each hook.""" + +from sqlalchemy.orm import Session + +from .. import models +from .engine import run_hook + +MAX_STORY_CARDS = 5000 # AI Dungeon's per-adventure sanity cap + + +class ScriptPipeline: + def __init__(self, adventure: models.Adventure, db: Session): + self.adventure = adventure + self.db = db + self.logs: list[str] = [] + self.errors: list[str] = [] + + @property + def message(self) -> str | None: + state = self.adventure.script_state + msg = state.get("message") if isinstance(state, dict) else None + return msg if isinstance(msg, str) and msg.strip() else None + + def _history(self) -> list[dict]: + return [ + {"text": a.text, "rawText": a.text, "type": a.type} + for a in self.adventure.actions + ] + + def _cards(self) -> list[dict]: + return [ + {"id": c.id, "keys": c.keys, "entry": c.entry, "type": c.type} + for c in self.adventure.story_cards + ] + + def _info(self) -> dict: + return { + "actionCount": len(self.adventure.actions), + "characterNames": [], + "memoryLength": len(self.adventure.memory), + "maxChars": 0, + } + + def _apply_cards(self, returned: list) -> None: + existing = {c.id: c for c in self.adventure.story_cards} + seen_ids = set() + for item in returned: + if not isinstance(item, dict): + continue + card_id = item.get("id") + keys = str(item.get("keys") or "") + entry = str(item.get("entry") or "") + card_type = str(item.get("type") or "") + if card_id in existing: + seen_ids.add(card_id) + card = existing[card_id] + card.keys, card.entry, card.type = keys, entry, card_type + elif len(existing) + len(seen_ids) < MAX_STORY_CARDS: + self.db.add( + models.StoryCard( + adventure_id=self.adventure.id, + keys=keys, entry=entry, type=card_type, + ) + ) + for card_id, card in existing.items(): + if card_id not in seen_ids: + self.db.delete(card) + + def run(self, hook: str, text: str) -> tuple[str, bool]: + """Chain `hook` across all enabled scripts. Returns (text, stop).""" + state = self.adventure.script_state if isinstance(self.adventure.script_state, dict) else {} + for script in self.adventure.scripts: + hook_js = getattr(script, f"{hook}_js") + if not script.enabled or not hook_js.strip(): + continue + result = run_hook( + script.library_js, hook_js, text, state, + self._history(), self._cards(), self._info(), + ) + if result.error: + self.errors.append(f"{script.name} ({hook}): {result.error}") + continue # a broken script never breaks the turn + self.logs.extend(f"[{script.name}/{hook}] {line}" for line in result.logs) + self._apply_cards(result.story_cards) + state = result.state + self.adventure.script_state = state + self.db.commit() + text = result.text + if result.stop: + return text, True + return text, False + + def report(self) -> dict: + return {"logs": self.logs, "errors": self.errors, "message": self.message} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e621f4b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +pydantic>=2.7 +httpx>=0.27 +tiktoken>=0.7 +quickjs>=1.19 diff --git a/backend/seed_demo.py b/backend/seed_demo.py new file mode 100644 index 0000000..0639da4 --- /dev/null +++ b/backend/seed_demo.py @@ -0,0 +1,250 @@ +"""Seed a demo scenario + adventure with sample scripts for testing. + +Run from the backend folder: .venv\\Scripts\\python.exe seed_demo.py +Safe to rerun: it deletes any previous rows titled "[Demo] ..." first. +""" + +from app import models, migrations +from app.database import SessionLocal, engine + +# create_all + user_version stamp; plain create_all would leave a fresh DB at +# version 0 and the server would replay every ALTER TABLE migration on start. +migrations.bootstrap(engine) + +DEMO_PREFIX = "[Demo]" + +# --------------------------------------------------------------------------- +# Sample scripts — AI Dungeon contract: define modifier(text), call it last. +# --------------------------------------------------------------------------- + +DICE_ROLLER = dict( + name=f"{DEMO_PREFIX} Dice Roller", + description=( + "Input hook + shared library. Type '!roll 2d6' or '!roll d20' in a Do/Say/" + "Story action and the command is replaced with the rolled result." + ), + library_js="""\ +// Shared library: available to every hook of this script. +function rollDice(count, sides) { + var total = 0, rolls = []; + for (var i = 0; i < count; i++) { + var r = Math.floor(Math.random() * sides) + 1; + rolls.push(r); + total += r; + } + return { total: total, rolls: rolls }; +} +""", + input_js="""\ +const modifier = (text) => { + // Replace every "!roll NdS" (N optional) with the roll result. + var out = text.replace(/!roll\\s+(\\d*)d(\\d+)/gi, function (m, n, s) { + var count = parseInt(n || "1", 10); + var sides = parseInt(s, 10); + var res = rollDice(count, sides); + log("Rolled " + count + "d" + sides + ": [" + res.rolls.join(", ") + "] = " + res.total); + return "(rolled " + count + "d" + sides + ": " + res.total + ")"; + }); + return { text: out }; +}; +modifier(text); +""", +) + +TURN_TRACKER = dict( + name=f"{DEMO_PREFIX} Turn & HP Tracker", + description=( + "Demonstrates persistent state. Counts turns; '!hp -3' or '!hp +5' in input " + "adjusts HP (starts at 20). Current stats appear in state.message." + ), + input_js="""\ +const modifier = (text) => { + if (state.hp === undefined) state.hp = 20; + state.turns = (state.turns || 0) + 1; + + var out = text.replace(/!hp\\s*([+-]\\d+)/gi, function (m, delta) { + state.hp += parseInt(delta, 10); + return ""; + }); + + state.message = "Turn " + state.turns + " | HP: " + state.hp + "/20"; + log(state.message); + + if (state.hp <= 0) { + // stop:true ends the turn before the AI is called. + return { text: out + "\\n\\nYou have fallen. (HP reached 0 — turn stopped by script.)", stop: true }; + } + return { text: out }; +}; +modifier(text); +""", +) + +CONTEXT_INSPECTOR = dict( + name=f"{DEMO_PREFIX} Context Inspector", + description=( + "Context hook: logs the size of the assembled context each turn and appends " + "a style directive. Check the logs/context in the action's context snapshot." + ), + context_js="""\ +const modifier = (text) => { + log("Context size: " + text.length + " chars, actions so far: " + info.actionCount + + ", story cards: " + storyCards.length); + // Anything returned here replaces what is sent to the model. + return { text: text + "\\n[Style: keep the response under three paragraphs.]" }; +}; +modifier(text); +""", +) + +OUTPUT_POLISH = dict( + name=f"{DEMO_PREFIX} Output Polish + Card Discovery", + description=( + "Output hook: trims a trailing incomplete sentence from the AI reply, and " + "auto-creates a story card the first time the ghost Vharos is mentioned." + ), + output_js="""\ +const modifier = (text) => { + var out = text; + + // Drop a trailing sentence fragment (no ending punctuation). + var m = out.match(/^([\\s\\S]*[.!?"'\\u2026])[^.!?"'\\u2026]*$/); + if (m && m[1].length > 40) { + if (m[1].length < out.length) log("Trimmed incomplete final sentence."); + out = m[1]; + } + + // Demonstrate script-created story cards. + if (/vharos/i.test(out)) { + var added = addStoryCard( + "Vharos, ghost, spirit", + "Vharos was the crypt's architect, now a restless ghost bound to the amulet he was buried with. He speaks in echoes and cannot lie.", + "character" + ); + if (added !== false) log("Vharos mentioned — story card created."); + } + + return { text: out }; +}; +modifier(text); +""", +) + +SCRIPTS = [DICE_ROLLER, TURN_TRACKER, CONTEXT_INSPECTOR, OUTPUT_POLISH] + +# --------------------------------------------------------------------------- +# Scenario content +# --------------------------------------------------------------------------- + +SCENARIO = dict( + title=f"{DEMO_PREFIX} The Sunken Crypt of Vharos", + description=( + "A short dungeon-crawl demo scenario with story cards and one of each " + "script hook, for testing the app end to end." + ), + prompt=( + "Rain hammers the moors as you descend the moss-slick steps beneath the " + "ruined chapel. Your torch gutters in the stale air. Below, the Sunken " + "Crypt of Vharos waits — its iron door ajar, as if someone (or something) " + "expected you. Mira's warning rings in your ears: bring back the Ember " + "Amulet before nightfall, or the village of Hollowmere burns.\n\n" + "You stand before the iron door, water pooling around your boots." + ), + memory=( + "The player is an adventurer hired by Mira, blacksmith of Hollowmere, to " + "retrieve the Ember Amulet from the Sunken Crypt of Vharos before " + "nightfall. The crypt is flooded, dark, and haunted. Tone: classic D&D " + "dungeon crawl, dangerous but fair." + ), + authors_note="Keep scenes tense and grounded; offer clear choices; consequences matter.", + ai_instructions=( + "Write in second person, present tense. End each response at a moment " + "where the player can act." + ), + tags="demo, dungeon, fantasy, short", +) + +STORY_CARDS = [ + dict( + type="character", + name="Mira the Blacksmith", + keys="Mira, blacksmith", + entry=( + "Mira is Hollowmere's blacksmith: broad-shouldered, gray-braided, " + "practical. She hired the player and paid half up front. She knows " + "more about the crypt than she has admitted." + ), + notes="Secretly a descendant of Vharos.", + ), + dict( + type="location", + name="The Sunken Crypt", + keys="crypt, tomb, Vharos", + entry=( + "A flooded burial complex beneath a ruined chapel. Knee-deep black " + "water, collapsed pillars, and phosphorescent moss. Three chambers: " + "the Drowned Hall, the Ossuary, and the sealed Reliquary where the " + "Ember Amulet rests." + ), + notes="", + ), + dict( + type="item", + name="The Ember Amulet", + keys="amulet, ember", + entry=( + "A fist-sized garnet on a bronze chain that glows like a coal. It " + "keeps Hollowmere's protective hearth-ward burning. Touching it bare-" + "handed brands the flesh but does no lasting harm." + ), + notes="", + ), +] + +# --------------------------------------------------------------------------- + +db = SessionLocal() +try: + # Remove earlier demo rows so reruns stay clean. + for adv in db.query(models.Adventure).filter(models.Adventure.title.like(f"{DEMO_PREFIX}%")): + db.delete(adv) + for sc in db.query(models.Scenario).filter(models.Scenario.title.like(f"{DEMO_PREFIX}%")): + db.delete(sc) + for s in db.query(models.Script).filter(models.Script.name.like(f"{DEMO_PREFIX}%")): + db.delete(s) + db.commit() + + # Script library + scripts = [models.Script(**s) for s in SCRIPTS] + db.add_all(scripts) + + # Scenario with cards and scripts attached + scenario = models.Scenario(**SCENARIO) + scenario.scripts = scripts + db.add(scenario) + db.flush() + for card in STORY_CARDS: + db.add(models.StoryCard(scenario_id=scenario.id, **card)) + + # Adventure created from the scenario, mirroring POST /api/adventures + adventure = models.Adventure( + scenario_id=scenario.id, + title=scenario.title, + memory=scenario.memory, + authors_note=scenario.authors_note, + ai_instructions=scenario.ai_instructions, + ) + db.add(adventure) + db.flush() + for card in STORY_CARDS: + db.add(models.StoryCard(adventure_id=adventure.id, **card)) + for position, s in enumerate(SCRIPTS): + db.add(models.AdventureScript(adventure_id=adventure.id, position=position, **s)) + db.add(models.Action(adventure_id=adventure.id, index=0, type="start", text=scenario.prompt)) + db.commit() + + print(f"Scenario id={scenario.id}: {scenario.title}") + print(f"Adventure id={adventure.id}: {adventure.title}") + print(f"Scripts: {', '.join(s.name for s in scripts)}") +finally: + db.close() diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d937833 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e004457 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + AI D&D + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ae2196a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1643 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@uiw/react-codemirror": "^4.25.10", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.0.tgz", + "integrity": "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.5", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz", + "integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz", + "integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.10", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz", + "integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.10", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "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==", + "dev": true, + "license": "MIT" + }, + "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/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/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/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/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..175a306 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/theme-one-dark": "^6.1.3", + "@uiw/react-codemirror": "^4.25.10", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..01af37f --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,24 @@ +import { NavLink, Outlet } from 'react-router-dom' + +export default function App() { + return ( + <> + + + + ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..5b37a8b --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,115 @@ +async function request(path, options = {}) { + const resp = await fetch(`/api${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + if (!resp.ok) { + let detail = resp.statusText + try { + const body = await resp.json() + detail = body.detail || detail + } catch { /* non-JSON error body */ } + throw new Error(detail) + } + if (resp.status === 204) return null + return resp.json() +} + +// POSTs to an SSE endpoint and dispatches events: {type: 'player'|'chunk'|'done'|'error', ...} +async function streamSSE(path, payload, onEvent, signal) { + const resp = await fetch(`/api${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal, + }) + if (!resp.ok) { + let detail = resp.statusText + try { detail = (await resp.json()).detail || detail } catch { /* non-JSON */ } + throw new Error(detail) + } + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split('\n\n') + buffer = events.pop() // keep incomplete tail + for (const block of events) { + for (const line of block.split('\n')) { + if (line.startsWith('data:')) onEvent(JSON.parse(line.slice(5))) + } + } + } +} + +export const api = { + // Scenarios + listScenarios: () => request('/scenarios'), + getScenario: (id) => request(`/scenarios/${id}`), + createScenario: (data) => request('/scenarios', { method: 'POST', body: JSON.stringify(data) }), + updateScenario: (id, data) => request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteScenario: (id) => request(`/scenarios/${id}`, { method: 'DELETE' }), + + // Adventures + listAdventures: () => request('/adventures'), + getAdventure: (id) => request(`/adventures/${id}`), + createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }), + updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }), + updateAction: (advId, actionId, text) => + request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }), + deleteAction: (advId, actionId) => + request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }), + + sendAction: (advId, payload, handlers, signal) => + streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal), + retry: (advId, handlers, signal) => streamSSE(`/adventures/${advId}/retry`, {}, handlers, signal), + exportAdventure: (id) => request(`/adventures/${id}/export`), + importAdventure: (bundle) => request('/adventures/import', { method: 'POST', body: JSON.stringify(bundle) }), + undo: (advId) => request(`/adventures/${advId}/undo`, { method: 'POST' }), + getAdventureContext: (advId) => request(`/adventures/${advId}/context`), + getActionContext: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}/context`), + + // Memory bank + listMemories: (advId) => request(`/adventures/${advId}/memories`), + createMemory: (advId, text) => + request(`/adventures/${advId}/memories`, { method: 'POST', body: JSON.stringify({ text }) }), + updateMemory: (advId, memoryId, data) => + request(`/adventures/${advId}/memories/${memoryId}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteMemory: (advId, memoryId) => + request(`/adventures/${advId}/memories/${memoryId}`, { method: 'DELETE' }), + + listAdventureScripts: (advId) => request(`/adventures/${advId}/scripts`), + updateAdventureScript: (advId, scriptId, data) => + request(`/adventures/${advId}/scripts/${scriptId}`, { method: 'PATCH', body: JSON.stringify(data) }), + + // Scripts + listScripts: () => request('/scripts'), + getScript: (id) => request(`/scripts/${id}`), + createScript: (data) => request('/scripts', { method: 'POST', body: JSON.stringify(data) }), + updateScript: (id, data) => request(`/scripts/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteScript: (id) => request(`/scripts/${id}`, { method: 'DELETE' }), + testScript: (id, data) => request(`/scripts/${id}/test`, { method: 'POST', body: JSON.stringify(data) }), + exportScript: (id) => request(`/scripts/${id}/export`), + importScript: (bundle) => request('/scripts/import', { method: 'POST', body: JSON.stringify(bundle) }), + + // Scenario import/export + exportScenario: (id) => request(`/scenarios/${id}/export`), + importScenario: (bundle) => request('/scenarios/import', { method: 'POST', body: JSON.stringify(bundle) }), + + // Story cards + createStoryCard: (data) => request('/story-cards', { method: 'POST', body: JSON.stringify(data) }), + updateStoryCard: (id, data) => request(`/story-cards/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteStoryCard: (id) => request(`/story-cards/${id}`, { method: 'DELETE' }), + + // Debug + getDebugRequests: () => request('/debug/requests'), + + // Settings + getSettings: () => request('/settings'), + updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }), + testConnection: () => request('/settings/test', { method: 'POST' }), +} diff --git a/frontend/src/components.jsx b/frontend/src/components.jsx new file mode 100644 index 0000000..ef9a376 --- /dev/null +++ b/frontend/src/components.jsx @@ -0,0 +1,113 @@ +import { useState } from 'react' + +export function downloadJSON(obj, filename) { + const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function pickJSONFile() { + return new Promise((resolve, reject) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json,application/json' + input.onchange = () => { + const file = input.files[0] + if (!file) return reject(new Error('No file selected')) + const reader = new FileReader() + reader.onload = () => { + try { resolve(JSON.parse(reader.result)) } + catch { reject(new Error('Not valid JSON')) } + } + reader.onerror = () => reject(new Error('Could not read file')) + reader.readAsText(file) + } + input.click() + }) +} + +// Unique ${Placeholder} names, in order of first appearance, across the given texts. +export function extractPlaceholders(...texts) { + const names = [] + for (const text of texts) { + for (const match of (text || '').matchAll(/\$\{([^}]+)\}/g)) { + const name = match[1].trim() + if (name && !names.includes(name)) names.push(name) + } + } + return names +} + +export function PlaceholderModal({ title, names, onSubmit, onCancel }) { + const [values, setValues] = useState(Object.fromEntries(names.map((n) => [n, '']))) + + const submit = (e) => { + e.preventDefault() + onSubmit(values) + } + + return ( +
+
e.stopPropagation()} onSubmit={submit}> +

{title}

+

This scenario asks a few questions before you begin.

+ {names.map((name, i) => ( + + ))} +
+ + +
+
+
+ ) +} + +export function Field({ label, value, onChange, textarea, rows, placeholder }) { + return ( +