From a24f3615e0eb2294a784ac300d73fb7e5c490f65 Mon Sep 17 00:00:00 2001 From: itsamejms Date: Sun, 12 Jul 2026 22:13:43 +0100 Subject: [PATCH] working through the plan + UI/ UX --- plan.md => docs/plan.md | 95 +- docs/ui-ux-improvements.md | 1206 +++++++++++++++++ src-tauri/Cargo.lock | 71 +- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/generation_commands.rs | 59 + src-tauri/src/commands/image_commands.rs | 203 +++ src-tauri/src/commands/llm_commands.rs | 47 +- src-tauri/src/commands/mod.rs | 5 +- src-tauri/src/commands/rag_commands.rs | 58 + src-tauri/src/generations/mod.rs | 197 +++ src-tauri/src/lib.rs | 31 +- src-tauri/src/llm/mod.rs | 13 +- src-tauri/src/rag/mod.rs | 212 +++ src/App.tsx | 392 ++++-- src/components/BentoCard.tsx | 3 +- src/components/CommandPalette.tsx | 148 ++ src/components/Dashboard.tsx | 59 +- src/components/DiceRoller.tsx | 240 +++- src/components/EncounterBuilder.tsx | 314 ++++- src/components/ErrorBoundary.tsx | 45 + src/components/GeneratedImage.tsx | 86 ++ src/components/HistoryView.tsx | 742 ++++++++++ src/components/ImageGenerator.tsx | 140 ++ src/components/InitiativeTracker.tsx | 206 ++- src/components/ItemForge.tsx | 70 +- src/components/LorePanel.tsx | 176 +++ src/components/NpcGenerator.tsx | 110 +- src/components/QuestDesigner.tsx | 34 +- src/components/SessionLogger.tsx | 24 +- src/components/SettingsPanel.tsx | 93 +- src/components/WorldBuilder.tsx | 41 +- src/index.css | 110 +- src/lib/bus.ts | 52 + src/lib/encounter-budget.ts | 64 + src/lib/generations.ts | 140 ++ src/lib/lore.ts | 16 + src/lib/npc-data.ts | 95 ++ src/lib/usePrefill.ts | 24 + 38 files changed, 5295 insertions(+), 328 deletions(-) rename plan.md => docs/plan.md (83%) create mode 100644 docs/ui-ux-improvements.md create mode 100644 src-tauri/src/commands/generation_commands.rs create mode 100644 src-tauri/src/commands/image_commands.rs create mode 100644 src-tauri/src/commands/rag_commands.rs create mode 100644 src-tauri/src/generations/mod.rs create mode 100644 src-tauri/src/rag/mod.rs create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/GeneratedImage.tsx create mode 100644 src/components/HistoryView.tsx create mode 100644 src/components/ImageGenerator.tsx create mode 100644 src/components/LorePanel.tsx create mode 100644 src/lib/bus.ts create mode 100644 src/lib/encounter-budget.ts create mode 100644 src/lib/generations.ts create mode 100644 src/lib/lore.ts create mode 100644 src/lib/npc-data.ts create mode 100644 src/lib/usePrefill.ts diff --git a/plan.md b/docs/plan.md similarity index 83% rename from plan.md rename to docs/plan.md index 68838d3..c0c3df5 100644 --- a/plan.md +++ b/docs/plan.md @@ -18,6 +18,7 @@ 3. [Quick Start — Boilerplate](#3-quick-start--boilerplate) 4. [Local LLM Integration](#4-local-llm-integration) 5. [Core Utilities & AI Features](#5-core-utilities--ai-features) + - [4.4 Image Generation via Ollama](#44-image-generation-via-ollama) 6. [Front-End Architecture (React/TS)](#6-front-end-architecture-reactts) 7. [Design System — Visual Language](#7-design-system--visual-language) 8. [UI/UX Feature Design](#8-uiux-feature-design) @@ -297,6 +298,61 @@ pub async fn query_openai(prompt: &str) -> anyhow::Result { > **Important**: GPT-4o-mini is a **remote API model**, not local. Keep local inference via GGUF and use remote APIs only as an optional fallback. +### 4.4 Image Generation via Ollama + +Ollama's image models (e.g. `x/flux2-klein`, `x/z-image-turbo`) reuse the **same `/api/generate` endpoint** as text — just a different model name and a different response field. No new binding or dependency; the existing Ollama client in `src-tauri/src/llm/mod.rs` already posts here. + +**Request** — identical to a text generate, `stream: false`: +```json +{ "model": "x/flux2-klein:4b", "prompt": "portrait of a grizzled dwarf blacksmith, warm forge light, oil-painting fantasy style", "stream": false } +``` + +**Response** — newline-delimited JSON; the *final* line (`done: true`) carries a **singular `image`** field (base64 PNG). Intermediate lines stream progress (`step`/`total`) for a progress bar. Note: it's `image`, **not** `images`. +```json +{"model":"x/flux2-klein:4b","done":false,"total":4} +{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA..."} +``` + +**Rust command** (lazy: reuse the existing Ollama POST, branch on `image` vs `response`): +```rust +// src-tauri/src/commands/image.rs +#[derive(Deserialize)] +pub struct ImageRequest { pub prompt: String, pub model: Option } + +#[derive(Deserialize)] struct OllamaImgResp { done: bool, image: Option } + +#[tauri::command] +pub async fn generate_image( + state: tauri::State<'_, AppState>, + req: ImageRequest, +) -> Result { + let cfg = state.config.lock().unwrap().clone(); + let model = req.model.unwrap_or_else(|| "x/flux2-klein:4b".into()); + // POST {model, prompt, stream:false} to {cfg.api_url}/api/generate + // Read NDJSON, take the last line with done:true, return its `image` base64. + todo!("reuse existing ollama POST; return base64 PNG string") +} +``` + +**Front-end** — base64 PNG drops straight into an ``, and is written to `$APPDATA/dm-toolkit/images/` via `tauri-plugin-fs` so it's reused, not regenerated each render. + +**Where it's used** (auto-generated whenever one of these is created, with a ✨ regenerate button): + +| Utility | Image prompt template | +|---------|----------------------| +| NPC Generator | `portrait of a {race} {class}, {alignment} demeanor, {descriptor from personality}, fantasy oil-painting` | +| Item Forge | `product shot of a {rarity} {weapon_type}, {magical_effect} glow, on dark velvet, dramatic lighting` | +| World Builder | `fantasy map of {continent_name}, {climate} biome, parchment texture, ink cartography, top-down` | +| Encounter Builder | `battle map tile, {terrain}, isometric, 1024x1024, for tabletop RPG` | +| Loot / Random Tables | item art per rolled result, same template as Item Forge | +| Handout Renderer | scene illustration from the handout's first paragraph | + +**Constraints to bake in up front:** +- **macOS-only today** — Ollama image models only run on macOS (Apple Silicon via MLX). Gate the image-gen UI behind an OS check on first run; on other platforms fall back to a placeholder/emoji or the optional remote API. (This is an Ollama limitation, not ours.) +- **Slow + heavy** — 4B is ~5.7GB, 9B is ~12GB, generation is multi-second. Always generate in the background with a progress bar (drive it from the NDJSON `step`/`total` lines), never block the UI thread. Cache results to disk by hash of the prompt. +- **Model picker** — add `image_model` to `LlmConfig` (default `x/flux2-klein:4b`; `x/z-image-turbo` for fast/low-VRAM). Reuse the existing settings panel, don't build a second one. +- **Prompt engineering is the lever** — FLUX.2 handles readable text and hex colors, so item/NPC name labels can be rendered *into* the image where it helps. Default to 1024×1024. + --- ## 5. Core Utilities & AI Features @@ -305,11 +361,11 @@ Each module exposes a **Tauri command** (`#[tauri::command]`) that the front-end | Module | Key AI Tasks | Sample Prompt | |--------|-------------|---------------| -| **World Builder** | Generate continent names, climate zones, major cities, mythic legends | *"Create a fantasy continent with 3 climates and one legendary creature."* | -| **NPC Generator** | Personality traits, background story, NPC goals, relationship webs | *"Generate a charismatic dwarf blacksmith who despises elves."* | +| **World Builder** | Generate continent names, climate zones, major cities, mythic legends — **+ render a parchment-style map image** | *"Create a fantasy continent with 3 climates and one legendary creature."* | +| **NPC Generator** | Personality traits, background story, NPC goals, relationship webs — **+ render a portrait** | *"Generate a charismatic dwarf blacksmith who despises elves."* | | **Quest Designer** | Multi-step plot arcs, branching outcomes, hooks | *"Outline a 4-chapter quest where the party seeks the lost crown of Rithor."* | -| **Encounter Builder** | Balanced monster groups, terrain modifiers, loot tables | *"Design an urban ambush for level-5 PCs with 3 bandits and a surprise mob."* | -| **Item Forge** | Stat tables, lore sentences, rarity levels | *"Create a +2 longsword of fire resistance that grants invisibility once per day."* | +| **Encounter Builder** | Balanced monster groups, terrain modifiers, loot tables — **+ render a battle-map tile + loot item art** | *"Design an urban ambush for level-5 PCs with 3 bandits and a surprise mob."* | +| **Item Forge** | Stat tables, lore sentences, rarity levels — **+ render item art (label text rendered in-image)** | *"Create a +2 longsword of fire resistance that grants invisibility once per day."* | | **Initiative Tracker** | Turn order, HP/conditions, round timer | Manage combat state, auto-sort by initiative | | **Random Tables** | Roll on customizable tables | Treasure, encounters, names, weather | | **Calendar & Weather** | Fantasy calendar, seasonal events | Custom calendar system with weather | @@ -703,6 +759,7 @@ Local LLMs are not "fire and forget." Plan for: | **Loading state** | Model load can take 5–30 s on HDD/CPU. Show progress bar and cancel button | | **GPU offloading** | Expose `n_gpu_layers` slider per model | | **Context length** | 2k/4k/8k selector with memory warning | +| **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. | | **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool | | **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models | @@ -797,9 +854,16 @@ To keep AI-generated content consistent with your world: - [x] Encounter Builder (AI-generated via LLM, terrain selector, difficulty) - [x] Commit and tag `v0.5.0-dm-tools` -### Milestone 6 — Lore RAG & Polish -- [ ] Add lore chunking + embedding storage (`sqlite-vec`) -- [ ] Inject retrieved lore into LLM prompts +### Milestone 6 — Lore RAG & Polish (incl. Image Generation) +- [ ] Add `generate_image` Tauri command (reuse Ollama `/api/generate`, parse singular `image` field) +- [ ] Wire ✨ generate/regenerate into NPC, Item Forge, World Builder (map), Encounter Builder (battle map + loot), Handout Renderer +- [ ] Persist generated PNGs to `$APPDATA/dm-toolkit/images/` keyed by prompt hash (cache, don't regenerate) +- [ ] Progress bar driven from NDJSON `step`/`total`; background task, non-blocking UI +- [ ] Gate image-gen UI behind macOS check; placeholder fallback elsewhere +- [ ] Add `image_model` to `LlmConfig` + settings picker (no second settings panel) +- [x] Then proceed to lore RAG & polish below: +- [x] Add lore chunking + embedding storage (SQLite + brute-force cosine; `sqlite-vec` upgrade path noted) +- [x] Inject retrieved lore into LLM prompts (shared `generate` path) - [ ] Soundboard / ambience module - [ ] Handout renderer (Markdown → PDF) - [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states @@ -830,9 +894,20 @@ A consolidated checklist covering all phases: 6. [x] Implement design system: glassmorphism, bento grid, dark blue + gold palette, Cinzel/Inter/JetBrains Mono fonts 7. [x] Implement core utilities: World Builder, NPC Generator, Encounter Builder, Dice Roller, Session Logger 8. [x] Implement DM-specific tools: Initiative Tracker, Random Tables, Calendar, Encounter Builder -9. [ ] Add lore RAG / semantic search (`sqlite-vec`) -10. [ ] Build first-run model download / license wizard -11. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build` +9. [x] Add lore RAG / semantic search (brute-force cosine over Ollama `nomic-embed-text` embeddings in SQLite; `sqlite-vec` upgrade path noted) + - [x] `RagStore` (rusqlite bundled): chunk-on-paragraph, batch embed via `/api/embed`, store as little-endian f32 BLOB + - [x] Commands `rag_add` / `rag_search` / `rag_list` / `rag_clear` + - [x] Lore injection in shared `generate` path via optional `ragQuery` field (every generator grounded for free) + - [x] `LorePanel` UI: add lore, list/clear sources, test retrieval + - [x] Wired `ragQuery` into NPC, Item Forge, World Builder; `embed_model` in settings +10. [x] Image generation via Ollama (`x/flux2-klein`) for NPCs, items, maps, loot, handouts — macOS-gated, cached to disk + - [x] `generate_image` command (reuses Ollama `/api/generate`, parses singular `image` field, disk cache by prompt hash) + - [x] `GeneratedImage` reusable component (loading / macOS-gate placeholder / ✨ regenerate) + - [x] Wired into NPC Generator (portrait) + Item Forge (item art) + - [ ] Wire into World Builder (map), Encounter Builder (battle map + loot), Handout Renderer when those surfaces ship + - [x] `image_model` in `LlmConfig` + settings picker +11. [ ] Build first-run model download / license wizard +12. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build` --- diff --git a/docs/ui-ux-improvements.md b/docs/ui-ux-improvements.md new file mode 100644 index 0000000..03fe392 --- /dev/null +++ b/docs/ui-ux-improvements.md @@ -0,0 +1,1206 @@ +# DM-Pal — UI/UX Improvements + +*A review of the current app shell, dashboard, and every utility. Findings are +ordered by impact, not file order. Each item ships with a concrete, checkable +recommendation.* + +> **Status:** audit only — no code changes yet. Treat the checklist at the +> bottom as the punch list for the next sprint. + +--- + +## 1. Executive Summary + +DM-Pal is a Tauri v2 desktop app for running D&D sessions with a local LLM. +The visual identity (dark navy + Cinzel headings + gold accents, glassmorphic +bento cards) is on-brand and consistent across surfaces. The architecture is +clean — every tool is a self-contained card mounted into either the bento +dashboard or a single-column detail view. + +**The biggest problems right now are not aesthetic — they are interaction +shape and information density.** The dashboard tries to render *full* tool +UIs inside ~250px tiles, the left rail has grown to **14 entries** with two +identical `Sparkles` icons, and the "Expand" button is the only escape hatch +back to a usable view. There is no model-loading state, no streaming +indicator, no "thinking…" affordance, and the settings panel is a dead end +until you click "Load Settings". Detail views duplicate the same header +treatment as the dashboard and don't give the tools room to breathe. + +The good news: the design system tokens, component primitives (`BentoCard`, +`Toast`, `GeneratedImage`), and Rust↔TS plumbing are all in place. Most of +the wins are layout, IA, and copy — not a redesign. + +### Top 5 wins (do these first) + +1. **Kill the "expand to use it" pattern.** Make every bento card a *summary* + with a clear `Open` button. Tools render full-width in the detail view, + not at 1/4 size on the dashboard. +2. **Reorganize the left rail into 2 visual groups** (Session vs World) with + separators and tooltips that show on hover for icon-only nav. +3. **Add a real loading/streaming UI** for LLM and image generation. The + "Generating…" text in a button is not enough for 5–20 s waits. +4. **Ship a real Settings page** (load on mount, group endpoints/models/gen + params, show connection status). +5. **Fix the world/nav mismatch**: `isDetailView` in `App.tsx` only shows + the back button for some views, and `view === "settings"` is treated + separately so a back arrow is missing. + +--- + +## 2. Audit by Surface + +### 2.1 Left Rail Navigation — `src/App.tsx` + +**Current state (problems):** + +- **14 icon buttons in a 56px column** with no labels. Most users will not + hover-discover what each icon does. Two buttons use `Sparkles` (Random + Tables and Quest Designer) — they are visually indistinguishable. +- The rail mixes 7 "primary" nav items, 6 "extra" nav items, and Settings, + with no visual hierarchy. The `flex-1` spacer pushes extras to the bottom + but a new user has no way to know which is which. +- `navItems` is defined in code but `initiative`, `tables`, `quest`, + `calendar`, `lore`, `image` are inlined as separate JSX blocks. The list + is split between the array and 80+ lines of copy-pasted button markup. +- Active-state styling is the same for primary and extras — there's no + indication of "category". +- Tooltips use native `title=""` — slow, ugly, and bad for keyboard users. +- The settings cog duplicates a back-to-dashboard toggle, which is + surprising. Users expect the gear to open settings, not toggle them. +- The order is: World, NPCs, Items, Encounter, Dice, Session, Sound, + Initiative, Tables, Quest, Calendar, Lore, Image, Settings. There is no + grouping by intent (in-session vs prep vs world-building). + +**Recommendations:** + +- **Two visual groups** with a thin gold separator and a small uppercase + label ("SESSION" / "WORLD") in `--color-text-dim`. Group the 6 most + in-combat tools together (Initiative, Dice, Sound, Encounter, NPCs, + Quest) and the 6 prep/world tools below (World, Lore, Items, Calendar, + Tables, Image). +- **Use distinct icons.** Replace the duplicate `Sparkles` (Tables) with + `Shuffle` or `Dices`. Quest Designer can keep `ScrollText` or get its + own `Flag` icon. +- **Replace native tooltips** with a Radix tooltip or a CSS-only + `data-tooltip` attribute that shows on hover and focus, with a 200ms + delay, 12px `--text-primary` on `--bg-deep` with a 1px gold border. +- **Consolidate the array and inline buttons** into a single + `navItems: { icon, label, view, group }` array, rendered with one + `NavButton` component. Removes ~60 lines of JSX and the bug risk of + drifting styles. +- **Settings as a peer**, not a toggle. Click = always open settings. Use + the title bar's chevron to return to dashboard. +- **Add keyboard shortcuts.** `1`–`9` jump to the first 9 nav items. + `Cmd+,` opens settings. This is a desktop app — shortcuts are table + stakes (Roll20, Foundry, D&D Beyond all have them). + +### 2.2 Title Bar / Header — `src/App.tsx` + +**Current state (problems):** + +- The header is 40px tall with the app name, a fake "My Campaign ▾" + dropdown that does nothing, and a back button that only shows for some + views (`isDetailView` excludes `settings`). +- `isDetailView` is calculated as `view !== "dashboard" && view !== "settings"`. + That means **on the Settings page there is no back arrow**, so the only + way to leave is the back chevron that doesn't exist there. Users are + stuck — they have to click the dashboard logo in the rail. +- The Tauri drag region is set on the entire header, but the campaign + selector and back button are *inside* the drag region, so they don't + receive click events when the window is dragged from the header (this + is a Tauri gotcha — interactive elements inside a `data-tauri-drag-region` + need `data-tauri-no-drag`). +- "My Campaign" is a placeholder. There's no campaign concept in the + backend yet, but the UI pretends there is one. Either ship the campaign + picker or replace the dropdown with a real status pill (e.g. "● LLM + connected" / "○ offline"). + +**Recommendations:** + +- Make the back button **always show when not on dashboard**, including + Settings. Either drop the `view !== "settings"` clause or invert the + logic to `view === "dashboard" ? null : `. +- Add `data-tauri-no-drag` to the back button and the campaign/status + pill. +- Replace the inert "My Campaign ▾" with a real status indicator: + - LLM connection state (green/gold dot, hover for endpoint) + - Image model state (macOS-gated, "ready"/"unsupported") + - Lore index size ("142 chunks indexed") +- Add a small **global search** `⌘K` that opens a palette to jump to any + tool, NPC, item, or lore source. This is the single biggest navigation + improvement you can ship. + +### 2.3 Dashboard / Bento Layout — `src/components/Dashboard.tsx` + +**Current state (problems):** + +- The dashboard renders **the full component** of every tool inside a + ~250×180px tile. The header inside the tile says "World Builder" but the + user sees a tiny 4-character input and a "Generate" button. This is the + core UX problem. +- Every card ends with the same "⤢ Expand" link. So 100% of the time, the + *only* useful interaction is to leave the dashboard. The dashboard is + not a dashboard — it's a launcher. +- The Lore card is a static paragraph ("Index your world bible…") with an + "Open" button, while every other card tries (and fails) to be a real + tool. Be honest about which cards are launchers and which are + at-a-glance summaries. +- The grid uses `grid-cols-1 sm:grid-cols-2 lg:grid-cols-4` with hard + spans. On a 13" laptop (1280px) you get 4 columns, but tiles then + become 280px wide and content is unreadable. There's no 3-column + breakpoint. +- The inline `import`s at the bottom of `Dashboard.tsx` are after the + component — they work due to hoisting, but it's a readability smell. + One import block at the top, sorted. +- World Builder is `col-span-2 row-span-2`. Lore is `1×1`. The "biggest + tile" is also the one with the *least* useful at-a-glance content (a + map you can't interact with at that size). Either give World a real + mini-map (region pin list) or downgrade it to `2×1`. + +**Recommendations:** + +- **Two dashboard modes**, user-togglable: + 1. **Launcher mode (default)** — each card is an icon, title, one-line + description, primary CTA, and a "Last generated: X" footer. No + embedded input. Tap → detail view. + 2. **Console mode** — embed 2–3 chosen tools as real bento cards + (e.g. Dice, Initiative, Sound) for live-session use. User picks + which 3 from a settings list. This is the *real* session screen. +- For the **at-a-glance cards**, show: + - **Dice**: big last result + count of rolls in the session + - **Initiative**: round number + active combatant + # of combatants + - **NPC**: last generated NPC's portrait + name + - **Encounter**: last generated difficulty + monster count + - **Item Forge**: last forged item's name + rarity + - **Session Log**: entry count + last entry timestamp + - **Quest**: current quest title + step X/N + - **Sound**: # of active ambients + master volume + - **Lore**: chunk count + last-added source + - **Image Generator**: model name + last prompt + - **Calendar**: today's fantasy date + next event + - **Tables**: last roll result +- Add a **3-column breakpoint** between tablet and desktop. 4 columns + only on ≥1440px. The bento should be breathable. +- Use **`react-grid-layout`** (already on your roadmap) for draggable, + resizable cards. The plan calls for this — ship it. +- Move all imports to the top of the file. + +### 2.4 BentoCard — `src/components/BentoCard.tsx` + +**Current state (problems):** + +- The card is over-animated: `whileHover={{ scale: 1.005 }}` and + `whileTap={{ scale: 0.985 }}` on every card. With 12 cards on the + dashboard, the whole screen bobs slightly when you move the mouse. + This is the kind of motion that looks great in a portfolio and + terrible in a tool you use for 4 hours. +- The header divider is a `border-b border-[var(--color-border-glass)]` + with no padding below the title. The icon and title sit on the + baseline of the border, so on small cards they crowd the first row + of content. +- The `responsiveSpan` function is fragile: it does + `replace("col-span-2", "sm:col-span-2 lg:col-span-2 col-span-1")` which + means if you pass `col-span-3` it stays as `col-span-3` (no such + class). And `row-span-1` is replaced with itself, so the regex is + busy-work. +- No "drag handle" affordance. If you ship `react-grid-layout`, the + card needs a grab cursor and a small grip icon in the header. +- The body uses `flex-1 overflow-y-auto` which is correct, but nested + scrollbars inside tiny tiles make the bento feel like an ant farm. + +**Recommendations:** + +- **Drop the whileHover scale.** Replace with a CSS-only border-color + + box-shadow transition (already in `.glass-card:hover`). Keep + `whileTap` only on buttons. +- Move the `BentoCard` API to a typed prop: `{ title, icon, span, draggable? }` + with a `SPAN_MAP: Record` const. No string regex. +- Add a `headerAction?: ReactNode` slot (for the "Open" / "Expand" button) + so the card's "what to do here" lives in one place. +- For the dashboard launcher mode, the body should be a fixed + `min-h-[120px]` content slot, not `flex-1`. Stop nesting scrollbars. + +### 2.5 Detail View Shell — `src/App.tsx` + +**Current state (problems):** + +- Every detail view is wrapped in `
`. That means every tool + is **constrained to ~672px wide** in a centered glass card. For a + desktop DM tool, this is too narrow — World Builder, Quest Designer, + Soundboard, and Image Generator all want to be wider. +- The same wrapper is used for every tool, but the tools have very + different needs: + - **Dice**: 400px is plenty + - **World Builder**: 1200px+ (map, side panel) + - **Image Generator**: needs a full preview + - **Soundboard**: a grid that benefits from width + - **LorePanel**: two-pane (add + search) wants 1000px+ +- The wrapper has no breadcrumb, no tool-specific header, no actions + (save, copy, share). The back arrow is the only chrome. +- Tools like `LorePanel` and `SessionLogger` already have their own + internal two-pane layout, but they're squeezed into 672px. + +**Recommendations:** + +- **Drop the universal glass-card wrapper.** Let each tool own its own + page layout. +- Add a **page header** pattern: title (Cinzel, gold) + description + + primary action on the right (e.g. "Save to Lore" / "Regenerate" / + "Copy Markdown"). Reusable `` component. +- Use **viewport-width layouts** with sensible max widths per tool: + - Narrow (max-w-2xl): Dice, Settings + - Medium (max-w-4xl): NPC, Item Forge, Encounter, Quest Designer + - Wide (max-w-6xl): World Builder, Lore, Image Generator, Soundboard, + Session Logger +- Each tool should declare its preferred max width via a prop or via + its own root container. + +### 2.6 Settings — `src/components/SettingsPanel.tsx` + +**Current state (problems):** + +- The panel renders an empty state ("Configure your LLM connection") + with a "Load Settings" button. **Settings should always be loaded on + mount.** The "Load Settings" gate is hostile — a user opens Settings + expecting to configure, and instead sees a button they have to click + to *unlock* the form. +- The form is a flat stack of inputs with no grouping, no validation, + no help text, no connection test, and no indication of what changed. +- "API Key" and "Model" inputs are plain text. No "show/hide" toggle on + the API key, no model-name autocomplete, no "Test connection" + button. +- After saving, the "Saved" checkmark lives in the button for 2 s and + disappears. No toast, no persistent indicator. +- Temperature and max_tokens use range sliders with no scale labels + (0 → 2 for temp is meaningless to most DMs). Either show labels + ("Focused", "Balanced", "Creative") or numeric input alongside. +- No way to **reset to defaults** or **export/import config**. +- The "embed_model" and "image_model" are tucked at the bottom of the + form with no hint that changing them requires a model download. + +**Recommendations:** + +- Auto-load on mount, drop the gate. +- **Group settings into sections** with `
` and visible legends: + 1. **LLM Connection** — API URL, API key, connection test + 2. **Text Model** — model, temperature, max tokens, top-p + 3. **Image Model** — model, OS-compat note, "Open model library" link + 4. **Embedding Model (Lore RAG)** — model, "Reindex all" button + 5. **Danger zone** — Reset to defaults, Clear lore index +- **Connection test** button that does a `GET /api/tags` (Ollama) or + `GET /v1/models` (OpenAI) and shows ✅/❌ with the model list. +- **Model picker with autocomplete** that calls `GET /api/tags` and lets + the user select from installed models. Falls back to free text input. +- Show a **persistent status pill** in the title bar driven by the + connection test result. +- Use toasts (you already have `ToastContainer`!) for save success + instead of in-button text. +- Add **presets** for common LLM providers: Ollama local, LM Studio, + OpenAI, Anthropic, Custom. Each preset fills in the API URL pattern. + +### 2.7 Dice Roller — `src/components/DiceRoller.tsx` + +**Current state (problems):** + +- The big result is a single number with no breakdown of which die + rolled what. A 4d6+3 shows `19`, not `[6, 4, 6, 3] +3 = 19`. The + inline history shows the breakdown but only for multi-die rolls. +- No **advantage/disadvantage** buttons — the single most common + d20 roll in 5e. This is table stakes. +- No **modifier input**. The user has to type `1d20+5` every time + even when rolling the same attack repeatedly. There's no concept of + "my character's attack roll" or "my save DC". +- No **3D dice** or animation. The plan calls for `react-three-fiber` + — ship even a simple CSS tumble. +- "Roll" button doesn't react to spacebar. Pressing Enter in the input + does — but most DMs will want spacebar. +- Quick dice buttons only roll `1d{n}` — no `Xd{n}` choice. A "d6" + button rolls 1d6; there's no way to roll 3d6 in one tap. +- History is capped at 50 items with no way to clear or export. No + way to copy a result to clipboard. +- No **roll templates** for common patterns ("Attack", "Save", "Check", + "Damage"). A template would let you pick "Longsword attack +5" + → rolls 1d20+5 and labels the result. + +**Recommendations:** + +- Show the **roll breakdown** by default for any roll with >1 die or + any modifier. Format: `[6, 4, 6] + 3 = 19` (the per-die values + always visible inline, with the modifier and total). +- **Advantage/Disadvantage toggle** that swaps the notation to + `2d20kh1` or `2d20kl1` (or rolls twice and picks). Highlight the + chosen die. +- **Modifier input** as a persistent `+/-` field next to the notation + input. Saved per session. +- **Roll template row** with 4 buttons: Attack, Save, Check, Damage. + Each opens a tiny popover with the relevant ability/save/whatever + the DM chooses. +- **Spacebar rolls** the current notation when no input is focused. +- **Quick dice** become `Xd{n}` selectors: tap "d6" once = 1d6, tap + again = 2d6, etc., with a small "×3" indicator. +- History: add **clear** and **copy all** buttons. Show timestamps + in the history. +- Optional: ship a simple **2D dice tumble** with CSS — no 3D + library required. Rotate and scale a die SVG during the roll. + +### 2.8 Initiative Tracker — `src/components/InitiativeTracker.tsx` + +**Current state (problems):** + +- Add row: name, init bonus, max HP, +. **No initiative override** — + you can't set a combatant's initiative to a fixed number after the + roll (you have to delete and re-add). Roll20 lets you click the + number and edit. +- Conditions: clicking a `+Bli` chip adds "Blinded". There's no way to + add a **custom condition** ("Concentrating", "Raging", + "Hexed"). For a 5e DM this is critical — almost every combat has at + least one non-standard condition. +- The "Add condition" row shows only 5 conditions at a time. With 13 + in the list you have to click through 3 pages to find the right one. +- HP +/- is 1 HP per click. There's no **damage/heal input** — to take + 27 damage, you click `-` 27 times. Roll20's tracker has a "Set HP" + input and a "Apply damage" field. +- No **death save** tracking. When HP hits 0, the combatant just + shows 0/HP with no way to roll d20s and track successes/failures. +- No **turn timer**. Combat drags when players stall. A simple + countdown per turn is standard (Foundry, Roll20 both have it). +- "Reset" wipes the whole tracker with no confirmation. Easy to + fat-finger. +- "Next Turn" cycles through combatants but doesn't reveal the + *next* combatant visually. Roll20 and Foundry both bold/highlight + who's up next. +- No **group/encounter import** — you can't say "roll initiative for + the encounter I just built". +- No **notes** per combatant ("Mage is concentrating on Hold Person", + "Bandit leader has 50ft of movement left"). Critical for table + memory. + +**Recommendations:** + +- **Click initiative number → edit.** Inline number input. +- **Add custom condition**: small "+" button next to the condition + row that opens a popover with text input + auto-suggest from + the 13 standard conditions. +- **Show all 13 conditions** in a small popover instead of the + "slice(0, 5)" loop. +- **HP delta input**: click the HP number → type "27" → press Enter + to take 27 damage. Click the +/- buttons for ±1. +- **Death saves**: when HP=0, show three ✓ and three ✗ toggles. + Roll 1d20 with a button. +- **Turn timer**: optional 60s countdown that turns red at 0. Reset + on "Next Turn". +- **Confirm before Reset** with a small modal. +- **Notes per combatant**: a small `…` menu that opens a textarea + popover. +- **Import from encounter**: button that pulls monsters from the + last generated Encounter into the tracker. +- **Visual highlight of next-up combatant** (gold left-border) so + the DM is primed for "OK, you're up next." + +### 2.9 Session Logger — `src/components/SessionLogger.tsx` + +**Current state (problems):** + +- Two-column layout (notes + AI summary) gets squashed to 672px + in the detail view, making the textarea ~280px wide. Painful. +- Notes are plain text, not Markdown. A "**bold** the BBEG's name" is + shown as asterisks. The plan mentions `@uiw/react-md-editor` — ship it. +- No **session concept**. Every note is in one big unfiltered list. + Where's "Session 4, May 12th"? A DM runs multiple sessions and + needs to switch between them. +- No **timestamp granularity** — `toLocaleTimeString()` shows + `3:42:11 PM`. Add the date too. +- No **export**. After a session, the DM needs to share notes with + players, paste into Discord, or save to a Markdown file. +- "AI Summary" is a one-shot button. There's no **streaming** — + even though your backend supports it via Channels. You're shipping + the full token list at once. This is a huge UX miss for 5–20s waits. +- Notes are stored only in component state. **Reload = data loss.** + This is the #1 risk in the app right now. +- No **entry editing** — once added, you can only remove (and the + remove doesn't exist; you can't delete individual entries at all). +- No **tags** or **category** (combat, roleplay, loot, etc.). + +**Recommendations:** + +- **Persist notes to disk** via `tauri-plugin-store` or `tauri-plugin-fs`. + Load on mount, save on every change (debounced). This is critical. +- **Markdown editor** with a live preview toggle. +- **Sessions** as a first-class concept. Top-bar dropdown lists + sessions; click to switch; "New session" button; archive old ones. +- **Streaming AI summary** using your existing Channel infrastructure. + Show tokens as they arrive, with a gold cursor. +- **Export** to Markdown file (Save As) and Copy to Clipboard. +- **Per-entry actions**: edit, delete, mark as "highlight" (gold + left border). +- **Tags** with auto-suggest: #combat #roleplay #loot #quest. +- **Timestamps** include date. + +### 2.10 Encounter Builder — `src/components/EncounterBuilder.tsx` + +**Current state (problems):** + +- Generates a *narrative* encounter, not a *balanced* one. There's no + CR calculation, no XP budget check, no party-vs-encounter + difficulty math. Kobold Fight Club and the DMG have well-known + formulas. DMs will expect this from a tool called "Encounter + Builder." +- Monsters are listed as strings ("3x Goblin Scouts") with no + stat block. The DM has to flip to the MM. Pull from SRD or your + own DB. +- "Difficulty" is a string the LLM generated — "Easy" or + "Hard" or sometimes "Moderate" (not in your enum). Normalize and + color-code. +- No **initiative setup**. After generating, you can't push the + monsters straight into the Initiative Tracker. +- No **save** — generated encounters vanish on refresh. +- "Terrain" select only has 8 options and is free-form from the + LLM's side. No "tavern brawl", "ship deck", "fey crossing" + presets. + +**Recommendations:** + +- **Add XP budget math.** Inputs: party size, party level (already + have both), desired difficulty (Easy/Medium/Hard/Deadly). Output + table from DMG p.82. Show budget and current XP live. +- **Stat block integration.** Either inline (parse from a local + monsters.json) or a "View stat block" popover with SRD content. +- **Color-coded difficulty** pill (green / blue / orange / red). +- **"Send to Initiative Tracker"** button. Builds the combatant list + with monsters' CR-derived max HP. +- **"Send to Soundboard"** button — picks ambient based on terrain + (forest → 🌲 Forest ambient). +- **Save to campaign** (long-term) or **save to lore** (so future + encounters reference this one). +- **Terrain presets** with icons: 🏰 dungeon, 🌲 forest, ⛰ mountain, + 🏛 urban, 🌊 coastal, 🕳 underdark, 🏜 desert, 🌫 swamp. Add 4 more: + ✈ planar, 🏚 haunted, 🚢 ship, 🏰 siege. + +### 2.11 NPC Generator — `src/components/NpcGenerator.tsx` + +**Current state (problems):** + +- "Class" is a free-form text input labeled "Class" but it accepts + any string — "Artisan", "Blacksmith", "Diplomat", "Pickpocket". + These are *backgrounds*, not classes. The 5e concept of "class" + (Fighter, Wizard) is not enforced. Either rename the field to + "Background/Occupation" or show a class dropdown alongside. +- 8 races, 9 alignments — these are fine but a DM who wants a + half-orc or a goliath can't. Expand to the SRD 9 races. +- Portrait is a *single* image. No way to view full-size. No + "save portrait" button. +- Personality and goals are flat lists. No way to **edit** them + after generation. +- No **save NPC to roster**. Generated NPCs vanish on refresh. +- No **NPC card** concept — the result is a one-off. A DM accumulates + dozens of NPCs and needs a roster. +- The "🎲 random name" placeholder in the Name field is just a + placeholder — there's no actual "randomize name" button. +- No **stats**. 5e NPCs have AC, HP, ability scores, saves, skills. + The generator produces flavor, not a usable stat block. + +**Recommendations:** + +- Rename "Class" to "Background" with a curated list (Acolyte, + Criminal, Folk Hero, Noble, Sage, Soldier, plus 13 more from PHB + backgrounds). Add a free-text "Class" dropdown for Fighter/Wizard/… + separately. +- Expand race list to 9 (add Half-Orc, Goliath) and add a + "Subrace" optional dropdown. +- **NPC Roster** as a new view: grid of all generated NPCs with + portrait, name, race. Click to open full sheet. +- **Stat block** section: AC, HP, ability scores, saves. Either + generated by LLM or hand-entered. +- **Random name** button with a dice icon. +- **Save portrait** (right-click → save image). +- **Edit personality/goals** in-place. +- **Copy as Markdown** for the DM's notes. + +### 2.12 Item Forge — `src/components/ItemForge.tsx` + +**Current state (problems):** + +- Rarity is colored, but the colors are 6 hard-coded hex values. + If a future rarity is added, the color falls back to gold. Use a + rarity→color map. +- "Type" is a free-form text input labeled "Type" but the dropdown + is "Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", + "Ring", "Wand", "Staff" — that's 8 of 12 standard magic item + types. Missing: Rod, Staff ✓, Wondrous ✓, Adventuring Gear, + Tacked. +- Art is a single 1024×1024 in a 96px frame. The DM can never see + the full image without exporting. +- "mechanical" is a string. 5e items have *attunement*, *rarity + tag*, *value*, *weight*. The LLM prompt asks for a single + paragraph — you get a paragraph. +- No **save to inventory**. Items vanish. +- No **random item** button (one-click "surprise me"). + +**Recommendations:** + +- **Click art to expand** to full-size in a modal. +- **Structured mechanics** field: attunement (yes/no/condition), + rarity tag (already have), charges, value, weight, single-line + effect description. LLM returns these as a small object. +- **Save to inventory** → list view of all forged items with + rarity-coded borders. Click to view full sheet. +- **Random item** dice button. +- **Export to Markdown** / copy to clipboard. + +### 2.13 World Builder — `src/components/WorldBuilder.tsx` + +**Current state (problems):** + +- **Critical syntax bug**: lines 132, 147, 161 have `})` instead of `}` + (extra `)`). The component renders but with broken JSX. Confirmed + by reading the file. *Fix this first.* +- Generates text (name, description, regions, landmarks) but **no + map**. The plan calls for `react-konva` map rendering. The 2×2 + dashboard tile is wasted on a text blob. +- "Theme" is a single text input. No presets (high fantasy, dark + fantasy, sword & sorcery, steampunk, post-apocalyptic, sci-fi, + horror). +- "Regions" is a flat list. No **hierarchy** (continent → country + → region → city) and no **map pins**. +- No **save world**. Generated worlds vanish. +- No **edit**. Generated content is read-only. +- The Dashboard tile embeds the *full* WorldBuilder component, so + you see a 280px text input that says "high fantasy" with a + "Generate" button. Unusable at that size. + +**Recommendations:** + +- **Fix the syntax bug** in `WorldBuilder.tsx` (lines 132, 147, 161). +- **Map canvas**: `react-konva` or `fabric.js` with a parchment + background, region pin overlays, click-to-edit. This is the + "wow" feature of the app — invest here. +- **Theme presets** as a 4×2 grid of clickable chips. +- **Hierarchy tree**: collapsible tree of continent → country → + region → city, on the left. Each node has its own generated + detail. +- **Persist** the world to disk, reload on mount. + +### 2.14 Random Tables — `src/components/RandomTables.tsx` + +**Current state (problems):** + +- Only 4 built-in tables. DMs want hundreds. The 5e DMG alone has + ~30. Add: NPC Names (fantasy, by race), Traps, Dreams, Treasure + Hoards, Dungeon Encounters, Wilderness Encounters, Urban + Encounters, Caravan Cargo, Inn Names, Ship Names, etc. +- No **user-defined tables**. DMs will want to roll on their own + table of "weird things found in the dungeon." +- No **table import** (paste a table from a PDF/book). +- "Roll 1d20" button always uses the table's dice — no override. +- History is capped at 30, with no clear, no copy. +- Result is shown in the table preview (highlighted gold row) AND + in the big number box, which is redundant. +- No **weighted tables** — all entries are `min–max` ranges. Some + tables should weight "1: 10%, 2: 30%, 3: 60%". + +**Recommendations:** + +- **Add 10 more built-in tables** (Traps, Dreams, NPC Names, etc.) + or ship a community table pack. +- **User-defined tables**: create / edit / delete. List view. +- **Table import**: paste a markdown list, parse to entries. +- **Weighted tables**: when entries don't cover 100% of the dice + range, show the weights visually. +- **Result**: keep just the big number, highlight the row in the + table, no separate "result" block. +- **Export table** to JSON / Markdown. + +### 2.15 Calendar — `src/components/CalendarWidget.tsx` + +**Current state (problems):** + +- Uses the Forgotten Realms calendar (Hammer, Alturiak, …) but + there's no way to **switch to a custom calendar**. Half the + campaigns are homebrew with different month names. +- "Today" is hard-coded to `day: 15, month: 5`. The DM has to + manually navigate to "today." +- No **weather** on calendar days (the plan calls for weather + generation — not implemented). +- No **moon phases** (essential for lycanthropes, druids). +- Events are color-cycled through 4 colors. No way to assign a + color or category. +- No **recurring events** ("The Festival of Masks happens every + year on 12 Eleint"). +- The day grid shows 30 days for every month. The Forgotten + Realms calendar has 30-day months, but a custom calendar might + not. +- No **agenda view** ("next 5 events") — useful at session start. + +**Recommendations:** + +- **Custom calendar editor**: months, days per month, year length, + weekday names. Save as part of the campaign. +- **Current campaign date** as a "Today" indicator the DM can + advance day-by-day with a "Next day" button. +- **Weather generator** that rolls based on season/terrain, shown + as an icon on the day. +- **Moon phase** per day (8 phases cycling). +- **Event categories** with colors: Festival, Quest, NPC Birthday, + Political, Custom. +- **Recurring events** (yearly/monthly). +- **Agenda view** as an alternate layout. + +### 2.16 Soundboard — `src/components/Soundboard.tsx` + +**Current state (problems):** + +- All sounds are **synthesized** with Web Audio. Rain, fire, ocean + are not bad, but a DM running a session wants *real* recordings: + actual rain, actual crackling fire, actual ambient music. The + synthesis is clever but sounds thin next to a real ambience pack. +- No way to **import custom sounds** (drag an MP3 into a tile). +- No **fade-out** when stopping an ambient — only a 0.5s ramp. + Sometimes you want a 5s crossfade to a new ambient. +- No **scenes** ("Tavern" = tavern ambient + chatter + dice SFX + + fire; "Combat" = wind + drums + sword). One-click scene + switching. +- No **master mute** for the whole board. +- The "Stop All" button is below the SFX grid, easy to miss. +- No way to **save the current setup** ("This is what I use for + the dungeon delve"). +- No way to **loop count** for one-shots (e.g. dice rattle twice + for a crit). +- `AudioContext` is created on first play, but the browser + autoplay policy can block it until a user gesture. The first + ambient may not start. Use `await ctx.resume()` if suspended. + +**Recommendations:** + +- **Real sound packs**: ship 2–3 royalty-free ambience loops + (Pixabay, Freesound.org CC0). Synthesized stays as fallback. +- **Custom sound import**: drag an MP3/WAV onto a tile to assign. +- **Scenes**: a named collection of "on" sounds with per-scene + volume. "Tavern" / "Combat" / "Dungeon" / "Wilderness" / Custom. +- **Master mute** and **per-sound volume** (currently global + volume only). +- **Always-visible Stop All** as a fixed footer button, not + conditionally rendered. +- **Save/load board state**. +- **Pre-warm AudioContext** on first user gesture (any click). + +### 2.17 Quest Designer — `src/components/QuestDesigner.tsx` + +**Current state (problems):** + +- Quests are linear steps in a carousel. DMs want **branching** + outcomes ("If they spare the bandit, go to step 3; if they kill + him, go to step 5"). The plan calls for `react-flow` — ship it. +- No **save quest**. Generated quests vanish. +- No **player-facing view** (a quest the DM shows to players + sanitized of twists and secret goals). D&D Beyond has this. +- No **status** on each step (Pending / Active / Done). The + stepper is just a viewer. +- No **reward distribution** — rewards are flavor text, not + split-by-character XP/gold/items. +- "Theme" is one text field. No presets. +- No **quests list / roster**. + +**Recommendations:** + +- **Branching quest graph** with `react-flow`. Gold edges for main + path, blue for side branches, red for "if villain wins." +- **Step status** (Pending / Active / Done) with click-to-advance. +- **Player view** toggle: hides Twist, shows flavor only. +- **Reward breakdown**: XP (per character), gold, items. +- **Quest roster**: list of all generated quests with status + badges. +- **Save to lore** so generated quests feed back into the world. + +### 2.18 Image Generator — `src/components/ImageGenerator.tsx` + +**Current state (problems):** + +- Prompt textarea is one big block. No **negative prompt** field + (no "blurry, low quality, watermark"). +- No **seed** field for reproducibility. +- No **aspect ratio selector** — always 1024×1024. A DM wants + 16:9 for a battle map, 1:1 for a portrait, 9:16 for a phone + wallpaper. +- No **steps / guidance scale** controls. +- No **batch** (generate 4 variants, pick the best). +- No **gallery** — all generated images are ephemeral. DMs will + want a folder of "this campaign's portraits." +- "Save PNG" downloads a single image. No "save all", no + "copy to clipboard." +- "Regenerate" only fires one variant. No seed/parameter changes. +- The unsupported-platform error is shown but the ✨ buttons + elsewhere (NPC, Item) silently fall back. Inconsistent. + +**Recommendations:** + +- **Negative prompt** + **seed** + **aspect ratio** + **steps** + + **guidance** controls. Collapsible "Advanced" section. +- **Batch mode**: 2×2 grid of variants, click the one you like. +- **Gallery**: thumbnails of all generated images in a grid. + Click to view full size. "Save all" / "Delete". +- **Persist** to a campaign-scoped folder. +- **Copy to clipboard** in addition to download. +- **Consistent macOS gating**: when unsupported, the ✨ buttons + in NPC/Item should also show a "macOS only" inline notice, not + silent fallback. + +### 2.19 Lore Panel (RAG) — `src/components/LorePanel.tsx` + +**Current state (problems):** + +- Three sections stacked vertically: Add, Indexed, Test. With a + 672px-wide detail view, the textarea for "paste your world bible" + is unusable. +- No **drag-and-drop file upload** (.md, .txt, .pdf). +- No **directory import** ("add every .md in /Users/me/dnd/world"). +- No **chunk preview** (what text was actually chunked and + embedded?). +- No **embedding visualization** (t-SNE or UMAP of the corpus). +- Search results show score with 3 decimal places (0.873). Most + DMs don't know what that means. Show "★ 4/5" or a relevance + bar. +- No **search history**. +- No **way to attach lore to a generation** — the `ragQuery` + field on the LLM command exists, but the user has no UI to + pick which lore sources to query against. +- "Clear all" is a one-click nuke. Confirm first. + +**Recommendations:** + +- **Two-pane layout**: left = add/import, right = search/explore. +- **Drag-and-drop file upload** with file-type validation. +- **Directory picker** via Tauri dialog. +- **Chunk preview** (first 200 chars of each chunk) when a source + is expanded. +- **Source filter** in the search input — multi-select chips. +- **Visualize as "★ relevance"** not raw cosine distance. +- **Search history**. +- **Confirm before "Clear all"** with a modal. + +### 2.20 GeneratedImage — `src/components/GeneratedImage.tsx` + +**Current state (problems):** + +- Used in NPC and Item Forge. The component fetches on every mount + *and* every `nonce` change. If the parent re-renders for any + reason (state update, etc.), the nonce is unchanged so the + same image is fetched — fine. But the **prompt** changes when + the NPC changes, and there's no `AbortController` to cancel an + in-flight image gen when the user changes inputs mid-flight. +- No **progress** for image generation (the backend streams + `step`/`total` lines). Show a progress bar. +- No **placeholder** while loading — just a tiny `Sparkles` + icon, which is invisible in a 96px tile. Show a skeleton with + a shimmer. +- On error (not macOS), shows a "macOS-only" placeholder. The + message is small and grey. A DM on Windows might not realize + *why* nothing is happening. +- On regular error, shows the error string inline. Could be a + 10-line stack trace. + +**Recommendations:** + +- **AbortController** in the useEffect cleanup so stale requests + don't overwrite the latest. +- **Progress bar** driven by the NDJSON `step`/`total` lines from + the backend. Pass through a `Channel`. +- **Skeleton with shimmer** during load. +- **Friendly error messages** for known error codes. + +### 2.21 Toast — `src/components/Toast.tsx` + +**Current state (problems):** + +- The component is solid. But: only `Soundboard` uses it. Every + other tool uses inline error boxes that duplicate the toast's + job. **Use toasts everywhere** — they're a unified feedback + channel. +- Auto-dismiss is 4s with no way to extend. Power users may want + sticky toasts. +- The 4 toast types have gold-tinted styles, but only `info` + uses gold. Make `success` green-tinted to be more + distinguishable. +- The `nextId` module-level counter is shared across imports. + Fine in dev, brittle in tests. Use a UUID or `crypto.randomUUID()`. +- Stacking: 5 toasts push each other off-screen. Cap at 3 visible + at once. + +**Recommendations:** + +- **Standardize on toasts** for save success, generate complete, + generation error, etc. Delete inline error boxes from + NPC/Encounter/Quest/etc. +- **Sticky option**: `addToast(msg, type, { sticky: true })`. +- **Cap visible** at 3; older ones auto-dismiss sooner. +- **UUIDs** for IDs. + +### 2.22 Cross-Cutting Issues + +#### Accessibility + +- **No keyboard nav** between nav items, or only the default + tab order. The left rail buttons are focusable but there's no + visible focus ring style (the global `*:focus-visible` is set + to a 2px gold outline, but it gets clipped on the 14px-wide + rail buttons). +- **No aria-labels** on icon-only buttons. Screen readers + announce "button" with no name. +- **Color-only state** in Initiative (HP bar green→red) and Item + Forge (rarity colors). Add a label or icon. +- **No skip-to-content** link. Tab through the rail to get to + the dashboard. +- **Modal dialogs** (when added) need focus traps. +- **Text contrast**: `--color-text-dim` (#4a5568) on `--color-bg-deep` + (#0a0e1a) is ~3.2:1 — below WCAG AA (4.5:1 for small text). + Lighten it. +- **No reduced-motion support**. `framer-motion` animations should + respect `prefers-reduced-motion`. + +#### Internationalization / i18n + +- All strings are hard-coded English. If you ever ship to non-EN + markets, you'll need a copy pass. Not a v1 blocker. + +#### Offline-First + +- Fonts are loaded from Google Fonts in `index.css` — the + comments admit "TODO: download woff2 files to public/fonts/ + for offline-first." This is a desktop app meant to run offline. + Ship the fonts locally. + +#### Error Boundaries + +- **No React error boundaries**. If the LLM returns malformed + JSON that crashes a parser, the whole app blanks to a white + screen. Add an `` around the main content area + with a "Reload" button. + +#### Loading Skeletons + +- No skeleton states anywhere. Every async tool shows a static + "Generating…" button or a tiny spinner. Add skeleton cards for + the bento, skeletons for AI generations (shimmer placeholders + that match the final layout). + +#### Empty States + +- The Lore panel has one ("No lore indexed yet"). The Initiative + tracker has none (empty list looks broken). The Encounter + builder has none. The Quest designer has none. **Every tool + needs an empty state** with a 1-line explanation and a primary + action. + +#### Keyboard Shortcuts + +- `⌘K` command palette (mentioned above) +- `1`–`9` jump to nav items +- `Space` rolls dice +- `⌘,` opens settings +- `⌘S` saves current tool's state +- `Esc` closes modals / goes back to dashboard +- `?` shows shortcut help + +#### Persistence + +- **Almost nothing persists.** Generated NPCs, encounters, + items, quests, dice history, initiative, calendar events — + all component state. **Reload = data loss.** This is the + single biggest functional gap. + - Use `tauri-plugin-store` for user preferences and small + lists. + - Use `tauri-plugin-fs` + JSON files in `$APPDATA/dm-pal/` + for generated content. + - Auto-save on every change (debounced). + +#### Dark Mode + +- App is dark-only (intentional). But the macOS light title bar + is jarring. Set `titleBarStyle: "Overlay"` or + `Transparent` in `tauri.conf.json`. + +--- + +## 3. DM-Specific UX Notes (External Research) + +Sources consulted: Roll20 Turn Tracker docs, Roll20 VTT Redesign +research post (Brittany Vick, 2023), Roll20 D&D 5e Character +Builder case study, D&D Beyond product walkthroughs (general +familiarity). + +### 3.1 Roll20's design lessons that apply + +- **"Less Menus, More Suggestions."** DM-Pal's bento is menu- + heavy. Surface suggestions: "Roll initiative for this encounter" + one-click button on a generated encounter. "Add this NPC to + the lore" after generation. +- **"Simplification."** Reduce repetitive actions. Today, after + generating an encounter, the DM has to: copy monster names → + open initiative tracker → re-type names → set HP → roll + initiative. Auto-push instead. +- **"Automation."** Auto-save, auto-import, auto-reroll. + Encounter → Initiative push. Item → Inventory push. NPC → Roster + push. Quest → Lore push. +- **"Player view vs GM view."** DM-Pal is GM-only, but the + concept of "what does the DM see vs. what's projected" still + applies. A session-view mode (large Initiative, large Dice, + large Soundboard, no edit chrome) for when the DM is sharing + the screen. + +### 3.2 Combat tracker patterns (Roll20, Foundry, D&D Beyond) + +- Foundry: HP and AC always visible; conditions as small icons. +- D&D Beyond: turn order vertical; "your turn" glow; HP and + AC prominent. +- Roll20: drag-and-drop reordering; round calculation for + custom items ("-1 per round" simulates a countdown). +- **All three** show: round number prominently, current actor + highlighted, next actor visible. +- **None** force you to click +/- 27 times for damage. All have + a numeric input. + +### 3.3 Image generation in DM tools + +- **Foundry** has no AI image gen (yet). +- **D&D Beyond** has AI art via the partner artist program. +- **NovelAI**, **Stable Diffusion WebUI** are the open-source + references — they all have: negative prompt, seed, aspect + ratio, batch, gallery. **Ship these in DM-Pal's image gen.** + +### 3.4 Random tables reference + +- **Kobold Press**, **Donjon**, **Seventh Sphere** all have + online random tables. **Donjon's** UI is the gold standard: + big "Roll" button, immediate result, table visible below with + the result highlighted. DM-Pal's RandomTables is close — just + needs more tables and user-defined tables. + +### 3.5 Calendar reference + +- **Fantasy Calendar** (5e calendar tool) is the de-facto + standard. Features: custom calendars, recurring events, moon + phases, weather, agenda view, export to ICS. DM-Pal's calendar + is a sketch — there's a 10× feature gap. + +### 3.6 Soundboard reference + +- **Syrinscape**, **Tabletop Audio** are the reference points. + Both ship **real recordings** (synthesized doesn't cut it for + immersion). Both have **scenes** (one-click combinations). + DM-Pal's synthesis is clever but a real ambience pack is the + obvious upgrade. + +--- + +## 4. Prioritized Checklist + +### P0 — Critical (fix this week) + +These are blockers or bugs that make the app feel broken. + +- [ ] **Fix `WorldBuilder.tsx` syntax bug** (lines 132, 147, 161: + `})` should be `}`). Currently renders broken JSX. +- [ ] **Auto-load Settings on mount.** Remove the "Load Settings" + gate. +- [ ] **Add `data-tauri-no-drag` to interactive elements in the + header** (back button, status pill). +- [ ] **Show back button on Settings** (drop the + `view !== "settings"` exclusion in `isDetailView`). +- [ ] **Add a React error boundary** around the main content + area. +- [ ] **Self-host the fonts** in `public/fonts/` (offline-first). +- [ ] **Standardize errors via the Toast system** — delete inline + error boxes from NPC, Encounter, Quest, World, Item, Image. +- [ ] **Replace native `title=""` tooltips** with a styled + `data-tooltip` (visible on hover and focus). +- [ ] **Add `aria-label`** to every icon-only button. + +### P1 — High impact (next sprint) + +- [ ] **Reorganize the left rail** into 2 visual groups + (Session / World) with a thin gold separator and small + uppercase labels. +- [ ] **Replace the duplicate `Sparkles` icon** on Random Tables + (use `Shuffle` or `Dices`). +- [ ] **Add a global ⌘K command palette** for navigation. +- [ ] **Implement persistence** for Initiative, Dice history, + Calendar events, Generated NPCs/Encounters/Items/Quests. + Use `tauri-plugin-store` for small state, `tauri-plugin-fs` + for generated content. +- [ ] **Ship a loading skeleton + streaming UI** for LLM and + image generation. Use the existing Tauri Channel + infrastructure. +- [ ] **Add roll breakdown** to Dice Roller for any roll with + >1 die or any modifier. +- [ ] **Add Advantage/Disadvantage buttons** to Dice Roller. +- [ ] **Make the dashboard a launcher** — replace embedded + mini-tools with at-a-glance summaries + primary CTA. +- [ ] **Drop the universal `max-w-2xl glass-card` wrapper** in + the detail view. Let each tool set its own max-width. +- [ ] **Add a real loading state** to Settings: connection test + with model list. +- [ ] **Replace "My Campaign" placeholder** with a real status + pill (LLM connection, image model, lore chunk count). +- [ ] **Lighten `--color-text-dim`** to meet WCAG AA (4.5:1) + against `--color-bg-deep`. + +### P2 — Important (next month) + +- [ ] **Initiative Tracker**: + - [ ] Click-to-edit initiative number + - [ ] Custom conditions + - [ ] Damage input (numeric, not +/- only) + - [ ] Death saves + - [ ] Turn timer + - [ ] Notes per combatant + - [ ] Highlight next-up combatant + - [ ] Confirm before Reset + - [ ] "Import from Encounter" button +- [ ] **Dice Roller**: + - [ ] Modifier input (saved per session) + - [ ] Spacebar rolls + - [ ] Roll templates (Attack, Save, Check, Damage) + - [ ] Clear/copy history +- [ ] **Encounter Builder**: + - [ ] XP budget math (party size, level, difficulty) + - [ ] Color-coded difficulty + - [ ] Send to Initiative Tracker + - [ ] Send to Soundboard (terrain-based) + - [ ] Save to lore + - [ ] 4 more terrain presets (planar, haunted, ship, siege) +- [ ] **NPC Generator**: + - [ ] Rename "Class" to "Background" with curated list + - [ ] Expand races to 9 (Half-Orc, Goliath) + - [ ] NPC Roster view + - [ ] Stat block section (AC, HP, ability scores) + - [ ] Random name button + - [ ] Save portrait (right-click) + - [ ] Edit personality/goals in-place +- [ ] **Item Forge**: + - [ ] Click art to expand + - [ ] Structured mechanics (attunement, charges, value, weight) + - [ ] Save to inventory + - [ ] Random item button +- [ ] **Quest Designer**: + - [ ] Branching quest graph (react-flow) + - [ ] Step status (Pending/Active/Done) + - [ ] Player-facing view toggle (hide Twist) + - [ ] Reward breakdown (XP per character, gold, items) + - [ ] Quest roster +- [ ] **Image Generator**: + - [ ] Negative prompt, seed, aspect ratio, steps, guidance + - [ ] Batch mode (2×2 variants) + - [ ] Gallery view with thumbnails + - [ ] Persist to campaign-scoped folder + - [ ] Copy to clipboard +- [ ] **Session Logger**: + - [ ] Markdown editor + live preview + - [ ] Sessions as first-class concept (multiple sessions) + - [ ] Streaming AI summary + - [ ] Export to Markdown + - [ ] Per-entry actions (edit, delete, highlight) + - [ ] Tags +- [ ] **Calendar**: + - [ ] Custom calendar editor + - [ ] Current campaign date with "Next day" button + - [ ] Weather generator + - [ ] Moon phases + - [ ] Event categories with colors + - [ ] Recurring events + - [ ] Agenda view +- [ ] **Soundboard**: + - [ ] Real ambience pack (2–3 royalty-free loops) + - [ ] Custom sound import (drag-drop) + - [ ] Scenes (one-click combinations) + - [ ] Master mute + per-sound volume + - [ ] Always-visible Stop All + - [ ] Save/load board state + - [ ] Pre-warm AudioContext +- [ ] **Random Tables**: + - [ ] 10+ more built-in tables + - [ ] User-defined tables (create/edit/delete) + - [ ] Table import (paste Markdown) + - [ ] Weighted tables + - [ ] Export table +- [ ] **World Builder**: + - [ ] Map canvas (react-konva) + - [ ] Theme presets + - [ ] Hierarchy tree + - [ ] Persist world +- [ ] **Lore Panel**: + - [ ] Two-pane layout + - [ ] Drag-drop file upload + - [ ] Directory picker + - [ ] Chunk preview + - [ ] Source filter + - [ ] ★ relevance (not raw cosine) + - [ ] Confirm before "Clear all" +- [ ] **Settings**: + - [ ] Group into fieldsets (Connection, Text, Image, Embedding, Danger) + - [ ] Connection test with model list + - [ ] Model picker with autocomplete (`GET /api/tags`) + - [ ] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom) + - [ ] Reset to defaults + - [ ] Export/import config +- [ ] **Cross-cutting**: + - [ ] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ? + - [ ] Skeleton loaders for all async states + - [ ] Empty states for all tools + - [ ] `prefers-reduced-motion` respect on framer-motion + - [ ] Skip-to-content link + - [ ] Color + label for state (not color alone) + +### P3 — Nice to have + +- [ ] **Draggable bento layout** (react-grid-layout) with + persisted layout per campaign. +- [ ] **Console mode** for the dashboard — embed 2–3 chosen + tools as live-session cards. +- [ ] **Tablet/phone player view** — a web app that shows the + DM's screen (initiative, dice) to players on their + phones via the local network. +- [ ] **Compendium integration** — pull monster stat blocks from + a local SRD JSON. +- [ ] **i18n** — extract strings to a t() helper. +- [ ] **Session replay** — record dice rolls, initiative + changes, soundboard state, and replay the session. +- [ ] **Macros** — let DMs define "1d20+5 attack roll" as a + named button on the dice roller. +- [ ] **Voice-to-text** for session notes (Whisper). + +--- + +## 5. Quick-Win Sprint (suggested first PR) + +If you want a single PR that lands a lot of value, here are 8 +items that take <1 day combined and touch mostly `App.tsx` and +small CSS: + +1. Fix the `WorldBuilder.tsx` syntax bug (5 min) +2. Auto-load Settings on mount (10 min) +3. Drop the `view !== "settings"` exclusion on the back button + (2 min) +4. Add `aria-label` to every icon-only button (30 min) +5. Add `data-tooltip` styled tooltips (1 hr) +6. Group the left rail into Session / World (30 min) +7. Replace the duplicate `Sparkles` icon (2 min) +8. Lighten `--color-text-dim` to meet WCAG AA (2 min) +9. Add the ⌘K command palette (3 hrs, the biggest of the bunch) +10. Self-host the fonts in `public/fonts/` (1 hr) + +Total: ~6–8 hours. Lands a much more polished, accessible +baseline that the rest of the checklist builds on. + +--- + +## 6. References + +- [Roll20 Turn Tracker docs](https://help.roll20.net/hc/en-us/articles/360039178634-Turn-Tracker) +- [Roll20 VTT Redesign research (2023)](https://blog.roll20.net/posts/roll20-virtual-tabletop-redesign-our-research/) — Brittany Vick, UX Lead +- [Roll20 Design validation process (2024)](https://blog.roll20.net/posts/validating-digital-ttrpg-designs-at-roll20/) +- [Roll20 InitiativeTrackerPlus script](https://wiki.roll20.net/Script:InitiativeTrackerPlus) — community patterns +- [Tome of Tips: Turn Tracker](https://bloghub.roll20.net/posts/tome-of-tips-turn-tracker/) — round calculation patterns +- [WCAG 2.1 contrast minimums](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) — 4.5:1 for small text +- [DMG encounter math (p.82)](https://www.dndbeyond.com/sources/basic-rules/building-combat-encounters) — XP thresholds by level +- [D&D 5e SRD races](https://dnd.wizards.com/resources/systems-reference-document) — 9 races (Human, Elf, Dwarf, Halfling, Gnome, Dragonborn, Tiefling, Half-Orc, Goliath) +- [Tauri v2 IPC and Channels](https://v2.tauri.app/develop/calling-rust/) +- Internal: `docs/plan.md` — original feature plan and milestones diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5e78b0e..57c66b9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -739,8 +751,10 @@ dependencies = [ name = "dm-pal" version = "0.1.0" dependencies = [ + "anyhow", "log", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "tauri", @@ -883,6 +897,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1389,7 +1415,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -1398,6 +1433,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1898,6 +1942,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2883,6 +2938,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.42.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2232b92..d6fc87e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { version = "2.6.3", features = [] } [dependencies] serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +anyhow = "1.0" log = "0.4" tauri = { version = "2.11.3", features = [] } tauri-plugin-log = "2" @@ -25,3 +26,4 @@ tauri-plugin-store = "2" tauri-plugin-fs = "2" reqwest = { version = "0.12", features = ["json"] } tokio = { version = "1", features = ["full"] } +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/src-tauri/src/commands/generation_commands.rs b/src-tauri/src/commands/generation_commands.rs new file mode 100644 index 0000000..07b6421 --- /dev/null +++ b/src-tauri/src/commands/generation_commands.rs @@ -0,0 +1,59 @@ +use crate::generations::{Generation, GenerationStore, GenerationSummary}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct GenerationAddRequest { + pub kind: String, + pub title: String, + pub data: String, + /// Optional input/source (theme, name, level, etc.) for the history list. + pub source: Option, +} + +#[tauri::command] +pub fn generation_add( + state: tauri::State<'_, crate::llm::AppState>, + req: GenerationAddRequest, +) -> Result { + state + .gen + .add(&req.kind, &req.title, &req.data, req.source.as_deref()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn generation_list( + state: tauri::State<'_, crate::llm::AppState>, + kind: Option, +) -> Result, String> { + state.gen.list(kind.as_deref()).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn generation_get( + state: tauri::State<'_, crate::llm::AppState>, + id: i64, +) -> Result, String> { + state.gen.get(id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn generation_delete( + state: tauri::State<'_, crate::llm::AppState>, + id: Option, +) -> Result { + state.gen.delete(id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn generation_counts( + state: tauri::State<'_, crate::llm::AppState>, +) -> Result, String> { + state.gen.counts().map_err(|e| e.to_string()) +} + +// Reference `GenerationStore` so the type is considered used by the module +// (the public functions go through `state.gen`, which already uses it, +// but this silences a future-proofing warning if all wrappers get removed). +#[allow(dead_code)] +fn _store_type_used(_: &GenerationStore) {} diff --git a/src-tauri/src/commands/image_commands.rs b/src-tauri/src/commands/image_commands.rs new file mode 100644 index 0000000..0ff3447 --- /dev/null +++ b/src-tauri/src/commands/image_commands.rs @@ -0,0 +1,203 @@ +use crate::llm::AppState; +use serde::Deserialize; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use tauri::Manager; + +// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key. + +#[derive(Debug, Deserialize)] +pub struct ImageRequest { + pub prompt: String, + /// Override the configured image model for this call. + pub model: Option, +} + +/// One line of Ollama's NDJSON image-generation response. +#[derive(Debug, Deserialize)] +struct OllamaImageLine { + #[serde(default)] + done: bool, + #[serde(default)] + image: Option, +} + +/// Generate (or fetch from disk cache) an image for `prompt` via the configured +/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for ``. +/// +/// Ollama image models are macOS-only today; on other platforms we return an error +/// so the front-end can fall back to a placeholder instead of a confusing timeout. +#[tauri::command] +pub async fn generate_image( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + req: ImageRequest, +) -> Result { + if cfg!(not(target_os = "macos")) { + return Err("image generation is macOS-only via Ollama (for now)".into()); + } + + let config = state.config.lock().map_err(|e| e.to_string())?.clone(); + let model = req.model.unwrap_or(config.image_model.clone()); + + let cache_dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("dm-toolkit") + .join("images"); + std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; + + // Stable cache key over model + prompt. Regenerating with a tweaked prompt + // produces a new file; identical prompt reuses the cached PNG. + let mut hasher = DefaultHasher::new(); + model.hash(&mut hasher); + req.prompt.hash(&mut hasher); + let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish())); + + // Cache hit: return the stored PNG without calling the model. + if cache_path.exists() { + let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; + return Ok(data_url(&bytes)); + } + + let client = reqwest::Client::new(); + let url = format!("{}/api/generate", config.api_url.trim_end_matches('/')); + let body = serde_json::json!({ + "model": model, + "prompt": req.prompt, + "stream": false, + }); + + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("image request failed: {e}"))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("image error {status}: {text}")); + } + + // Ollama returns newline-delimited JSON even with stream:false for image models; + // the final line with `done: true` carries the singular `image` base64 field. + let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?; + let mut png_b64: Option = None; + for line in body_text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(parsed) = serde_json::from_str::(line) { + if let Some(b64) = parsed.image { + png_b64 = Some(b64); + if parsed.done { + break; + } + } + } + } + + let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?; + + // Decode + persist to cache, then return a data URL. + let png_bytes = base64_decode(&b64)?; + std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?; + Ok(data_url(&png_bytes)) +} + +fn data_url(png: &[u8]) -> String { + // ponytail: no base64 crate dep — a 30-line encoder. + let table: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((png.len() + 2) / 3 * 4); + let mut chunks = png.chunks_exact(3); + for c in &mut chunks { + let n = (c[0] as usize) << 16 | (c[1] as usize) << 8 | c[2] as usize; + out.push(table[(n >> 18) & 63] as char); + out.push(table[(n >> 12) & 63] as char); + out.push(table[(n >> 6) & 63] as char); + out.push(table[n & 63] as char); + } + let rem = chunks.remainder(); + match rem.len() { + 1 => { + let n = (rem[0] as usize) << 16; + out.push(table[(n >> 18) & 63] as char); + out.push(table[(n >> 12) & 63] as char); + out.push('='); + out.push('='); + } + 2 => { + let n = (rem[0] as usize) << 16 | (rem[1] as usize) << 8; + out.push(table[(n >> 18) & 63] as char); + out.push(table[(n >> 12) & 63] as char); + out.push(table[(n >> 6) & 63] as char); + out.push('='); + } + _ => {} + } + format!("data:image/png;base64,{out}") +} + +/// Minimal standard base64 decoder (no extra dependency). +fn base64_decode(input: &str) -> Result, String> { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let input = input.trim(); + let bytes: Vec = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if bytes.is_empty() { + return Ok(Vec::new()); + } + let mut out = Vec::with_capacity(bytes.len() * 3 / 4); + let mut buf: u32 = 0; + let mut bits: u32 = 0; + for &b in &bytes { + if b == b'=' { + break; + } + let v = val(b).ok_or_else(|| format!("invalid base64 char: {b}"))? as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_base64() { + let data = b"hello world \x00\xff\x10"; + let url = data_url(data); + let b64 = url.strip_prefix("data:image/png;base64,").unwrap(); + let decoded = base64_decode(b64).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn cache_key_is_stable() { + // sanity: same inputs → same filename shape (16 hex digits) + let mut h = DefaultHasher::new(); + "x/flux2-klein:4b".hash(&mut h); + "prompt".hash(&mut h); + let s = format!("{:016x}", h.finish()); + assert_eq!(s.len(), 16); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/llm_commands.rs b/src-tauri/src/commands/llm_commands.rs index a760a14..df666e9 100644 --- a/src-tauri/src/commands/llm_commands.rs +++ b/src-tauri/src/commands/llm_commands.rs @@ -16,6 +16,10 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) - let temperature = req.temperature.unwrap_or(config.temperature); let max_tokens = req.max_tokens.unwrap_or(config.max_tokens); + // Inject retrieved lore into the system prompt so every generator stays + // consistent with the user's world bible. Shared path = every caller. + let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; + if llm::is_ollama(&config.api_url) { call_ollama(&client, &config, &messages, temperature, max_tokens).await } else { @@ -33,12 +37,13 @@ pub async fn generate_stream( ) -> Result<(), String> { let config = state.config.lock().map_err(|e| e.to_string())?.clone(); - tauri::async_runtime::spawn(async move { - let client = reqwest::Client::new(); - let messages = llm::build_messages(req.system.as_deref(), &req.prompt); - let temperature = req.temperature.unwrap_or(config.temperature); - let max_tokens = req.max_tokens.unwrap_or(config.max_tokens); + let client = reqwest::Client::new(); + let messages = llm::build_messages(req.system.as_deref(), &req.prompt); + let temperature = req.temperature.unwrap_or(config.temperature); + let max_tokens = req.max_tokens.unwrap_or(config.max_tokens); + let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; + tauri::async_runtime::spawn(async move { // For now, we do a non-streaming call and emit the full response as one token // Real SSE streaming from Ollama/OpenAI can be added later let result = if llm::is_ollama(&config.api_url) { @@ -76,6 +81,38 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm Ok(()) } +// ─── Lore RAG injection (shared by generate + generate_stream) ─ + +/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them +/// into the system message so generation is grounded in the user's world bible. +/// Silently degrades to the original messages on any retrieval error. +async fn inject_lore( + state: &tauri::State<'_, AppState>, + _client: &reqwest::Client, + config: &crate::llm::LlmConfig, + mut messages: Vec, + rag_query: &Option, +) -> Vec { + let Some(query) = rag_query.as_ref() else { return messages; }; + if query.trim().is_empty() { return messages; } + + let Ok(hits) = state.rag.search(config, query, 4).await else { return messages; }; + if hits.is_empty() { return messages; } + + let lore = hits.iter().map(|(t, _s, _sc)| t.clone()).collect::>().join("\n\n---\n\n"); + let injection = format!( + "Relevant lore from the campaign bible — stay consistent with it:\n\n{lore}" + ); + if let Some(first) = messages.first_mut() { + if first.role == "system" { + first.content = format!("{}\n\n{}", first.content, injection); + return messages; + } + } + messages.insert(0, ChatMessage { role: "system".into(), content: injection }); + messages +} + // ─── Ollama API ─────────────────────────────────────────────── async fn call_ollama( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c273392..68a2f9d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1 +1,4 @@ -pub mod llm_commands; \ No newline at end of file +pub mod llm_commands; +pub mod image_commands; +pub mod rag_commands; +pub mod generation_commands; \ No newline at end of file diff --git a/src-tauri/src/commands/rag_commands.rs b/src-tauri/src/commands/rag_commands.rs new file mode 100644 index 0000000..4f1049f --- /dev/null +++ b/src-tauri/src/commands/rag_commands.rs @@ -0,0 +1,58 @@ +use crate::llm::AppState; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct RagAddRequest { + pub source: String, + pub text: String, +} + +#[derive(Debug, Serialize)] +pub struct RagHit { + pub text: String, + pub source: String, + pub score: f32, +} + +#[derive(Debug, Serialize)] +pub struct RagSource { + pub source: String, + pub chunks: i64, +} + +#[tauri::command] +pub async fn rag_add(state: tauri::State<'_, AppState>, req: RagAddRequest) -> Result { + let config = state.config.lock().map_err(|e| e.to_string())?.clone(); + state.rag.add_document(&config, &req.source, &req.text).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn rag_search( + state: tauri::State<'_, AppState>, + query: String, + top_k: Option, +) -> Result, String> { + let config = state.config.lock().map_err(|e| e.to_string())?.clone(); + let hits = state + .rag + .search(&config, &query, top_k.unwrap_or(4)) + .await + .map_err(|e| e.to_string())?; + Ok(hits.into_iter().map(|(text, source, score)| RagHit { text, source, score }).collect()) +} + +#[tauri::command] +pub fn rag_list(state: tauri::State<'_, AppState>) -> Result, String> { + state + .rag + .list_sources() + .map_err(|e| e.to_string())? + .into_iter() + .map(|(source, chunks)| Ok(RagSource { source, chunks })) + .collect() +} + +#[tauri::command] +pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option) -> Result<(), String> { + state.rag.clear(source.as_deref()).map_err(|e| e.to_string()) +} \ No newline at end of file diff --git a/src-tauri/src/generations/mod.rs b/src-tauri/src/generations/mod.rs new file mode 100644 index 0000000..c7dcd1a --- /dev/null +++ b/src-tauri/src/generations/mod.rs @@ -0,0 +1,197 @@ +use rusqlite::{params, params_from_iter, types::Value, Connection}; +use serde::Serialize; +use std::sync::Mutex; + +// ponytail: a separate SQLite file from lore.db so a corrupt generations +// store can't lose the user's world bible. Same `bundled` rusqlite feature. +// Generations are append-only in v1: no schema migrations planned. + +const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + title TEXT NOT NULL, + data TEXT NOT NULL, + source TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_gen_kind ON generations(kind); +CREATE INDEX IF NOT EXISTS idx_gen_created ON generations(created_at DESC);"; + +#[derive(Debug, Serialize, Clone)] +pub struct Generation { + pub id: i64, + pub kind: String, + pub title: String, + pub data: String, + pub source: Option, + pub created_at: i64, +} + +#[derive(Debug, Serialize)] +pub struct GenerationSummary { + pub id: i64, + pub kind: String, + pub title: String, + /// Epoch ms — for relative-time display in the UI. + pub created_at: i64, +} + +pub struct GenerationStore { + db: Mutex, +} + +impl GenerationStore { + pub fn open(dir: &std::path::Path) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let path = dir.join("generations.db"); + let db = Connection::open(path)?; + db.execute_batch(SCHEMA)?; + Ok(Self { db: Mutex::new(db) }) + } + + /// Append a generation. `data` is the raw JSON string from the LLM + /// (already-validated by the caller), `source` is the human-readable + /// input (theme, name, etc.) so the history list can show context. + pub fn add( + &self, + kind: &str, + title: &str, + data: &str, + source: Option<&str>, + ) -> anyhow::Result { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + db.execute( + "INSERT INTO generations (kind, title, data, source, created_at) VALUES (?, ?, ?, ?, ?)", + params![kind, title, data, source, now], + )?; + Ok(db.last_insert_rowid()) + } + + /// All generations, newest first, optionally filtered by kind. + pub fn list(&self, kind: Option<&str>) -> anyhow::Result> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let (sql, params): (&str, Vec) = match kind { + Some(k) => ( + "SELECT id, kind, title, created_at FROM generations WHERE kind = ?1 ORDER BY created_at DESC", + vec![Value::from(k.to_string())], + ), + None => ( + "SELECT id, kind, title, created_at FROM generations ORDER BY created_at DESC", + vec![], + ), + }; + let mut stmt = db.prepare(sql)?; + let rows = stmt.query_map(params_from_iter(params.iter()), |r| { + Ok(GenerationSummary { + id: r.get(0)?, + kind: r.get(1)?, + title: r.get(2)?, + created_at: r.get(3)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Full row including data + source. Used when rehydrating into a tool. + pub fn get(&self, id: i64) -> anyhow::Result> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare( + "SELECT id, kind, title, data, source, created_at FROM generations WHERE id = ?", + )?; + let mut rows = stmt.query(params![id])?; + if let Some(r) = rows.next()? { + Ok(Some(Generation { + id: r.get(0)?, + kind: r.get(1)?, + title: r.get(2)?, + data: r.get(3)?, + source: r.get(4)?, + created_at: r.get(5)?, + })) + } else { + Ok(None) + } + } + + /// Delete one row, or all rows if id is None. + pub fn delete(&self, id: Option) -> anyhow::Result { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let n = match id { + Some(i) => db.execute("DELETE FROM generations WHERE id = ?", params![i])?, + None => db.execute("DELETE FROM generations", [])?, + }; + Ok(n) + } + + /// Count by kind — for the dashboard "X NPCs, Y encounters" pill. + pub fn counts(&self) -> anyhow::Result> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare( + "SELECT kind, COUNT(*) FROM generations GROUP BY kind ORDER BY kind", + )?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dm-pal-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn round_trip() { + let dir = tmp(); + let store = GenerationStore::open(&dir).unwrap(); + let id = store + .add("npc", "Thorin Stonefist", r#"{"name":"Thorin"}"#, Some("Dwarf Fighter")) + .unwrap(); + let g = store.get(id).unwrap().unwrap(); + assert_eq!(g.kind, "npc"); + assert_eq!(g.title, "Thorin Stonefist"); + assert_eq!(g.source.as_deref(), Some("Dwarf Fighter")); + assert!(g.created_at > 0); + + let list = store.list(None).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, id); + + let npcs = store.list(Some("npc")).unwrap(); + assert_eq!(npcs.len(), 1); + let items = store.list(Some("item")).unwrap(); + assert_eq!(items.len(), 0); + + let counts = store.counts().unwrap(); + assert_eq!(counts, vec![("npc".to_string(), 1)]); + + assert_eq!(store.delete(Some(id)).unwrap(), 1); + assert!(store.get(id).unwrap().is_none()); + } + + #[test] + fn clear_all() { + let dir = tmp(); + let store = GenerationStore::open(&dir).unwrap(); + store.add("a", "x", "{}", None).unwrap(); + store.add("b", "y", "{}", None).unwrap(); + assert_eq!(store.delete(None).unwrap(), 2); + assert_eq!(store.list(None).unwrap().len(), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b80f50f..bd61f87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,11 @@ mod llm; +mod rag; +mod generations; mod commands; use llm::AppState; use std::sync::Mutex; +use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -10,8 +13,22 @@ pub fn run() { .plugin(tauri_plugin_log::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_fs::init()) - .manage(AppState { - config: Mutex::new(llm::LlmConfig::default()), + .setup(|app| { + let data_dir = app + .path() + .app_data_dir() + .expect("app data dir") + .join("dm-toolkit"); + let rag = rag::RagStore::open(&data_dir.join("lore")) + .expect("open lore db"); + let gen = generations::GenerationStore::open(&data_dir) + .expect("open generations db"); + app.manage(AppState { + config: Mutex::new(llm::LlmConfig::default()), + rag, + gen, + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ greet, @@ -19,6 +36,16 @@ pub fn run() { commands::llm_commands::generate_stream, commands::llm_commands::get_llm_config, commands::llm_commands::set_llm_config, + commands::image_commands::generate_image, + commands::rag_commands::rag_add, + commands::rag_commands::rag_search, + commands::rag_commands::rag_list, + commands::rag_commands::rag_clear, + commands::generation_commands::generation_add, + commands::generation_commands::generation_list, + commands::generation_commands::generation_get, + commands::generation_commands::generation_delete, + commands::generation_commands::generation_counts, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 9f15f80..e9d4334 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; use std::sync::Mutex; -use tauri::ipc::Channel; // ─── LLM Event (for streaming) ─────────────────────────────── @@ -16,6 +15,8 @@ pub enum LlmEvent { pub struct AppState { pub config: Mutex, + pub rag: crate::rag::RagStore, + pub gen: crate::generations::GenerationStore, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,6 +27,10 @@ pub struct LlmConfig { pub temperature: f32, pub max_tokens: u32, pub top_p: f32, + /// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama. + pub image_model: String, + /// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`). + pub embed_model: String, } impl Default for LlmConfig { @@ -38,6 +43,8 @@ impl Default for LlmConfig { temperature: 0.7, max_tokens: 512, top_p: 0.9, + image_model: "x/flux2-klein:4b".to_string(), + embed_model: "nomic-embed-text".to_string(), } } } @@ -50,6 +57,10 @@ pub struct GenerateRequest { pub system: Option, pub temperature: Option, pub max_tokens: Option, + /// Optional RAG query: when set, the top lore chunks for this query are + /// retrieved and prepended to the system prompt so generation stays + /// consistent with the user's world bible. + pub rag_query: Option, } // ─── OpenAI-Compatible Chat Response ───────────────────────── diff --git a/src-tauri/src/rag/mod.rs b/src-tauri/src/rag/mod.rs new file mode 100644 index 0000000..fb6fc00 --- /dev/null +++ b/src-tauri/src/rag/mod.rs @@ -0,0 +1,212 @@ +use crate::llm::LlmConfig; +use rusqlite::{params, Connection}; +use serde::Deserialize; +use std::sync::Mutex; + +// ponytail: brute-force cosine over normalized vectors, not sqlite-vec. +// Ceiling: ~10k chunks stays sub-millisecond on one core. Upgrade path: +// swap search() for a sqlite-vec virtual table when chunk count grows past +// that and scan time shows up in profiles. Avoids native extension loading. + +/// One embedding vector, stored as little-endian f32 bytes. +const MAX_CHUNK_CHARS: usize = 1000; + +pub struct RagStore { + db: Mutex, +} + +#[derive(Debug, Deserialize)] +struct EmbedResponse { + embeddings: Vec>, +} + +impl RagStore { + pub fn open(dir: &std::path::Path) -> anyhow::Result { + std::fs::create_dir_all(dir)?; + let path = dir.join("lore.db"); + let db = Connection::open(path)?; + db.execute_batch( + "CREATE TABLE IF NOT EXISTS chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + text TEXT NOT NULL, + emb BLOB NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);", + )?; + Ok(Self { db: Mutex::new(db) }) + } + + /// Chunk `text` on paragraph boundaries, capping each chunk's length. + /// ponytail: no overlap in v1 — fine for retrieval at this scale; add + /// a sliding window if recall on boundary-spanning facts drops. + fn chunk(text: &str) -> Vec { + let mut out = Vec::new(); + for para in text.split("\n\n") { + let para = para.trim(); + if para.is_empty() { + continue; + } + if para.len() <= MAX_CHUNK_CHARS { + out.push(para.to_string()); + } else { + // Hard-cap long paragraphs on char boundaries. + for chunk in para.as_bytes().chunks(MAX_CHUNK_CHARS) { + if let Ok(s) = std::str::from_utf8(chunk) { + out.push(s.trim().to_string()); + } + } + } + } + out + } + + /// Embed a batch of texts via Ollama `/api/embed`. Vectors come back + /// L2-normalized from the server, so cosine similarity = dot product. + async fn embed(client: &reqwest::Client, config: &LlmConfig, texts: &[String]) -> anyhow::Result>> { + let url = format!("{}/api/embed", config.api_url.trim_end_matches('/')); + let body = serde_json::json!({ "model": config.embed_model, "input": texts }); + let res = client.post(&url).json(&body).send().await?; + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + anyhow::bail!("embed error {status}: {text}"); + } + let parsed: EmbedResponse = res.json().await?; + Ok(parsed.embeddings) + } + + fn vec_to_blob(v: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(v.len() * 4); + for f in v { + bytes.extend_from_slice(&f.to_le_bytes()); + } + bytes + } + + fn blob_to_vec(b: &[u8]) -> Vec { + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() + } + + /// Add a lore document: chunk it, embed, and store. Returns chunk count. + pub async fn add_document( + &self, + config: &LlmConfig, + source: &str, + text: &str, + ) -> anyhow::Result { + let chunks = Self::chunk(text); + if chunks.is_empty() { + return Ok(0); + } + let client = reqwest::Client::new(); + let embeddings = Self::embed(&client, config, &chunks).await?; + if embeddings.len() != chunks.len() { + anyhow::bail!("embedding count mismatch"); + } + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare("INSERT INTO chunks (source, text, emb) VALUES (?, ?, ?)")?; + for (text, emb) in chunks.iter().zip(embeddings.iter()) { + stmt.execute(params![source, text, Self::vec_to_blob(emb)])?; + } + Ok(chunks.len()) + } + + /// Brute-force cosine search. Returns (text, source, score) for top_k. + pub async fn search( + &self, + config: &LlmConfig, + query: &str, + top_k: usize, + ) -> anyhow::Result> { + let client = reqwest::Client::new(); + let q_emb = Self::embed(&client, config, &[query.to_string()]) + .await? + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("no query embedding"))?; + + let rows: Vec<(String, String, Vec)> = { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare("SELECT text, source, emb FROM chunks")?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Vec>(2)?, + )) + })?; + rows.filter_map(|r| r.ok()).collect() + }; + if rows.is_empty() { + return Ok(Vec::new()); + } + + // ponytail: naive O(n) scan. Fine to ~10k chunks; see module note. + let mut scored: Vec<(String, String, f32)> = rows + .into_iter() + .map(|(text, source, blob)| { + let v = Self::blob_to_vec(&blob); + let dot: f32 = v.iter().zip(q_emb.iter()).map(|(a, b)| a * b).sum(); + (text, source, dot) + }) + .collect(); + scored.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(top_k); + Ok(scored) + } + + /// (source, chunk_count) for every distinct source. + pub fn list_sources(&self) -> anyhow::Result> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare("SELECT source, COUNT(*) FROM chunks GROUP BY source ORDER BY source")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Clear all chunks, or just one source if given. + pub fn clear(&self, source: Option<&str>) -> anyhow::Result<()> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + match source { + Some(s) => { db.execute("DELETE FROM chunks WHERE source = ?", params![s])?; } + None => { db.execute("DELETE FROM chunks", [])?; } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_splits_paragraphs_and_caps_long_ones() { + let long = "a".repeat(MAX_CHUNK_CHARS * 2 + 50); + let text = format!("short para\n\n{long}\n\nanother"); + let chunks = RagStore::chunk(&text); + // "short para", (2 or 3 long pieces), "another" + assert!(chunks.len() >= 4); + assert_eq!(chunks.first().unwrap(), "short para"); + assert!(chunks.last().unwrap() == "another"); + for c in &chunks { + assert!(c.len() <= MAX_CHUNK_CHARS); + } + } + + #[test] + fn blob_roundtrip() { + let v = vec![0.0, 1.5, -2.25, 3.33]; + let blob = RagStore::vec_to_blob(&v); + assert_eq!(blob.len(), v.len() * 4); + let back = RagStore::blob_to_vec(&blob); + for (a, b) in v.iter().zip(back.iter()) { + assert!((a - b).abs() < 1e-6); + } + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 0ea1920..926fad8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,17 +3,19 @@ import { User, Swords, Dice5, - ScrollText, Volume2, Settings, - ChevronDown, ChevronLeft, - Sparkles, Timer, Calendar, Wand2, + BookOpen, + ImagePlus, + Shuffle, + Flag, + History, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Dashboard } from "./components/Dashboard"; import { SettingsPanel } from "./components/SettingsPanel"; import { NpcGenerator } from "./components/NpcGenerator"; @@ -27,7 +29,13 @@ import { WorldBuilder } from "./components/WorldBuilder"; import { ItemForge } from "./components/ItemForge"; import { QuestDesigner } from "./components/QuestDesigner"; import { Soundboard } from "./components/Soundboard"; +import { LorePanel } from "./components/LorePanel"; +import { ImageGenerator } from "./components/ImageGenerator"; import { ToastContainer } from "./components/Toast"; +import { CommandPalette } from "./components/CommandPalette"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { HistoryView } from "./components/HistoryView"; +import type { Generation, GenerationKind } from "./lib/generations"; export type View = | "dashboard" @@ -42,28 +50,71 @@ export type View = | "calendar" | "quest" | "items" - | "settings"; + | "lore" + | "image" + | "settings" + | "history"; -const navItems: { icon: typeof Map; label: string; view: View }[] = [ - { icon: Map, label: "World", view: "world" }, - { icon: User, label: "NPCs", view: "npcs" }, - { icon: Wand2, label: "Items", view: "items" }, - { icon: Swords, label: "Encounter", view: "encounter" }, - { icon: Dice5, label: "Dice", view: "dice" }, - { icon: ScrollText, label: "Session", view: "session" }, - { icon: Volume2, label: "Sound", view: "sound" }, +// ponytail: tools that accept a prefill from history. Mirrors the kinds. +const PREFILLABLE: Partial> = { + npcs: "npc", + encounter: "encounter", + world: "world", + items: "item", + quest: "quest", + session: "session", + image: "image", +}; + +type NavGroup = "session" | "world"; + +interface NavItem { + icon: typeof Map; + label: string; + view: View; + group: NavGroup; + shortcut?: string; +} + +// ponytail: single source of truth for nav; groups drive the rail layout. +export const navItems: NavItem[] = [ + { icon: Timer, label: "Initiative", view: "initiative", group: "session", shortcut: "1" }, + { icon: Dice5, label: "Dice", view: "dice", group: "session", shortcut: "2" }, + { icon: Swords, label: "Encounter", view: "encounter", group: "session", shortcut: "3" }, + { icon: User, label: "NPCs", view: "npcs", group: "session", shortcut: "4" }, + { icon: Flag, label: "Quest", view: "quest", group: "session", shortcut: "5" }, + { icon: Volume2, label: "Sound", view: "sound", group: "session", shortcut: "6" }, + { icon: Map, label: "World", view: "world", group: "world", shortcut: "7" }, + { icon: BookOpen, label: "Lore", view: "lore", group: "world" }, + { icon: Wand2, label: "Items", view: "items", group: "world" }, + { icon: Calendar, label: "Calendar", view: "calendar", group: "world" }, + { icon: Shuffle, label: "Tables", view: "tables", group: "world" }, + { icon: ImagePlus, label: "Image", view: "image", group: "world" }, + { icon: History, label: "History", view: "history", group: "world" }, ]; -function renderView(view: View): React.ReactNode { +function renderView( + view: View, + prefill: Generation | null, + onPrefillConsumed: () => void, + rehydrate: (kind: GenerationKind, data: Generation) => void, +): React.ReactNode { + // ponytail: history view renders its own list/detail panes, so it doesn't + // use the prefill or wrapper pattern. + if (view === "history") { + return ; + } + // Tools that accept prefill. + if (view === "npcs") return ; + if (view === "encounter") return ; + if (view === "world") return ; + if (view === "items") return ; + if (view === "quest") return ; + if (view === "session") return ; + if (view === "image") return ; switch (view) { - case "npcs": - return ; case "dice": return ; - case "session": - return ; - case "encounter": - return ; case "initiative": return ; case "tables": @@ -72,12 +123,8 @@ function renderView(view: View): React.ReactNode { return ; case "settings": return ; - case "world": - return ; - case "quest": - return ; - case "items": - return ; + case "lore": + return ; case "sound": return ; default: @@ -85,103 +132,171 @@ function renderView(view: View): React.ReactNode { } } +// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail. +const viewMaxWidth: Partial> = { + dice: "max-w-2xl", + settings: "max-w-2xl", + npcs: "max-w-4xl", + encounter: "max-w-4xl", + quest: "max-w-4xl", + items: "max-w-4xl", + world: "max-w-6xl", + lore: "max-w-6xl", + image: "max-w-6xl", + sound: "max-w-6xl", + session: "max-w-6xl", + initiative: "max-w-2xl", + tables: "max-w-2xl", + calendar: "max-w-2xl", +}; + +// ponytail: tools that accept a prefill. The map mirrors `PREFILLABLE` above +// and exists so rehydrate() can find the right View for a given kind. +const PREFILLABLE_TOOLS = { + npcs: NpcGenerator, + encounter: EncounterBuilder, + world: WorldBuilder, + items: ItemForge, + quest: QuestDesigner, + session: SessionLogger, + image: ImageGenerator, +} as const; + export default function App() { const [view, setView] = useState("dashboard"); + const [paletteOpen, setPaletteOpen] = useState(false); + // ponytail: prefill is set when the user clicks "Open in tool" in History. + // The targeted tool reads it via usePrefillEffect and calls onConsumed + // to clear it. State lives here so the rehydrate→switch→consume dance + // doesn't depend on a shared store. + const [prefill, setPrefill] = useState(null); - const isDetailView = view !== "dashboard" && view !== "settings"; + function rehydrate(kind: GenerationKind, data: Generation) { + const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find( + (v) => PREFILLABLE[v] === kind, + ); + if (!target) return; + setPrefill(data); + setView(target); + } + + // ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((o) => !o); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "h") { + e.preventDefault(); + setView("history"); + return; + } + if (e.key === "Escape" && paletteOpen) { + setPaletteOpen(false); + return; + } + // Digit shortcuts jump to nav items, but not while typing in an input. + if ( + !paletteOpen && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + /^[1-9]$/.test(e.key) && + !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) + ) { + const target = navItems.find((n) => n.shortcut === e.key); + if (target) { + e.preventDefault(); + setView(target.view); + } + } + // ⌘, opens settings. + if ((e.metaKey || e.ctrlKey) && e.key === ",") { + e.preventDefault(); + setView("settings"); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [paletteOpen]); + + const isOnDashboard = view === "dashboard"; + const groupedNav = useMemo( + () => ({ + session: navItems.filter((n) => n.group === "session"), + world: navItems.filter((n) => n.group === "world"), + }), + [], + ); + + function NavButton({ item }: { item: NavItem }) { + const active = view === item.view; + return ( + + ); + } return (
{/* Left Rail */} -