working through the plan + UI/ UX
This commit is contained in:
+85
-10
@@ -18,6 +18,7 @@
|
|||||||
3. [Quick Start — Boilerplate](#3-quick-start--boilerplate)
|
3. [Quick Start — Boilerplate](#3-quick-start--boilerplate)
|
||||||
4. [Local LLM Integration](#4-local-llm-integration)
|
4. [Local LLM Integration](#4-local-llm-integration)
|
||||||
5. [Core Utilities & AI Features](#5-core-utilities--ai-features)
|
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)
|
6. [Front-End Architecture (React/TS)](#6-front-end-architecture-reactts)
|
||||||
7. [Design System — Visual Language](#7-design-system--visual-language)
|
7. [Design System — Visual Language](#7-design-system--visual-language)
|
||||||
8. [UI/UX Feature Design](#8-uiux-feature-design)
|
8. [UI/UX Feature Design](#8-uiux-feature-design)
|
||||||
@@ -297,6 +298,61 @@ pub async fn query_openai(prompt: &str) -> anyhow::Result<String> {
|
|||||||
|
|
||||||
> **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.
|
> **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<String> }
|
||||||
|
|
||||||
|
#[derive(Deserialize)] struct OllamaImgResp { done: bool, image: Option<String> }
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_image(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
req: ImageRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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 `<img src={`data:image/png;base64,${b64}`}>`, 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
|
## 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 |
|
| 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."* |
|
| **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 | *"Generate a charismatic dwarf blacksmith who despises elves."* |
|
| **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."* |
|
| **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."* |
|
| **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 | *"Create a +2 longsword of fire resistance that grants invisibility once per day."* |
|
| **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 |
|
| **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 |
|
| **Random Tables** | Roll on customizable tables | Treasure, encounters, names, weather |
|
||||||
| **Calendar & Weather** | Fantasy calendar, seasonal events | Custom calendar system with 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 |
|
| **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 |
|
| **GPU offloading** | Expose `n_gpu_layers` slider per model |
|
||||||
| **Context length** | 2k/4k/8k selector with memory warning |
|
| **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 |
|
| **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 |
|
| **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] Encounter Builder (AI-generated via LLM, terrain selector, difficulty)
|
||||||
- [x] Commit and tag `v0.5.0-dm-tools`
|
- [x] Commit and tag `v0.5.0-dm-tools`
|
||||||
|
|
||||||
### Milestone 6 — Lore RAG & Polish
|
### Milestone 6 — Lore RAG & Polish (incl. Image Generation)
|
||||||
- [ ] Add lore chunking + embedding storage (`sqlite-vec`)
|
- [ ] Add `generate_image` Tauri command (reuse Ollama `/api/generate`, parse singular `image` field)
|
||||||
- [ ] Inject retrieved lore into LLM prompts
|
- [ ] 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
|
- [ ] Soundboard / ambience module
|
||||||
- [ ] Handout renderer (Markdown → PDF)
|
- [ ] Handout renderer (Markdown → PDF)
|
||||||
- [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states
|
- [ ] 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
|
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
|
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
|
8. [x] Implement DM-specific tools: Initiative Tracker, Random Tables, Calendar, Encounter Builder
|
||||||
9. [ ] Add lore RAG / semantic search (`sqlite-vec`)
|
9. [x] Add lore RAG / semantic search (brute-force cosine over Ollama `nomic-embed-text` embeddings in SQLite; `sqlite-vec` upgrade path noted)
|
||||||
10. [ ] Build first-run model download / license wizard
|
- [x] `RagStore` (rusqlite bundled): chunk-on-paragraph, batch embed via `/api/embed`, store as little-endian f32 BLOB
|
||||||
11. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build`
|
- [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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+70
-1
@@ -19,6 +19,18 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -739,8 +751,10 @@ dependencies = [
|
|||||||
name = "dm-pal"
|
name = "dm-pal"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -883,6 +897,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -1389,7 +1415,16 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1398,6 +1433,15 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1898,6 +1942,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2883,6 +2938,20 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.42.1"
|
version = "1.42.1"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tauri-build = { version = "2.6.3", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
anyhow = "1.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.11.3", features = [] }
|
tauri = { version = "2.11.3", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
@@ -25,3 +26,4 @@ tauri-plugin-store = "2"
|
|||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn generation_add(
|
||||||
|
state: tauri::State<'_, crate::llm::AppState>,
|
||||||
|
req: GenerationAddRequest,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<Vec<GenerationSummary>, 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<Option<Generation>, 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<i64>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
state.gen.delete(id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn generation_counts(
|
||||||
|
state: tauri::State<'_, crate::llm::AppState>,
|
||||||
|
) -> Result<Vec<(String, i64)>, 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) {}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of Ollama's NDJSON image-generation response.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OllamaImageLine {
|
||||||
|
#[serde(default)]
|
||||||
|
done: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 `<img src>`.
|
||||||
|
///
|
||||||
|
/// 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<String, String> {
|
||||||
|
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<String> = None;
|
||||||
|
for line in body_text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<OllamaImageLine>(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<Vec<u8>, String> {
|
||||||
|
fn val(c: u8) -> Option<u8> {
|
||||||
|
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<u8> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
|||||||
let temperature = req.temperature.unwrap_or(config.temperature);
|
let temperature = req.temperature.unwrap_or(config.temperature);
|
||||||
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
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) {
|
if llm::is_ollama(&config.api_url) {
|
||||||
call_ollama(&client, &config, &messages, temperature, max_tokens).await
|
call_ollama(&client, &config, &messages, temperature, max_tokens).await
|
||||||
} else {
|
} else {
|
||||||
@@ -33,12 +37,13 @@ pub async fn generate_stream(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
let client = reqwest::Client::new();
|
||||||
let client = reqwest::Client::new();
|
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
|
||||||
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
|
let temperature = req.temperature.unwrap_or(config.temperature);
|
||||||
let temperature = req.temperature.unwrap_or(config.temperature);
|
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
||||||
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
|
// 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
|
// Real SSE streaming from Ollama/OpenAI can be added later
|
||||||
let result = if llm::is_ollama(&config.api_url) {
|
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(())
|
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<ChatMessage>,
|
||||||
|
rag_query: &Option<String>,
|
||||||
|
) -> Vec<ChatMessage> {
|
||||||
|
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::<Vec<_>>().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 ───────────────────────────────────────────────
|
// ─── Ollama API ───────────────────────────────────────────────
|
||||||
|
|
||||||
async fn call_ollama(
|
async fn call_ollama(
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
pub mod llm_commands;
|
pub mod llm_commands;
|
||||||
|
pub mod image_commands;
|
||||||
|
pub mod rag_commands;
|
||||||
|
pub mod generation_commands;
|
||||||
@@ -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<usize, String> {
|
||||||
|
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<usize>,
|
||||||
|
) -> Result<Vec<RagHit>, 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<Vec<RagSource>, 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<String>) -> Result<(), String> {
|
||||||
|
state.rag.clear(source.as_deref()).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenerationStore {
|
||||||
|
pub fn open(dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||||
|
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<i64> {
|
||||||
|
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<Vec<GenerationSummary>> {
|
||||||
|
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||||
|
let (sql, params): (&str, Vec<Value>) = 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<Option<Generation>> {
|
||||||
|
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<i64>) -> anyhow::Result<usize> {
|
||||||
|
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<Vec<(String, i64)>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-2
@@ -1,8 +1,11 @@
|
|||||||
mod llm;
|
mod llm;
|
||||||
|
mod rag;
|
||||||
|
mod generations;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
use llm::AppState;
|
use llm::AppState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -10,8 +13,22 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_log::Builder::default().build())
|
.plugin(tauri_plugin_log::Builder::default().build())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(AppState {
|
.setup(|app| {
|
||||||
config: Mutex::new(llm::LlmConfig::default()),
|
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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet,
|
greet,
|
||||||
@@ -19,6 +36,16 @@ pub fn run() {
|
|||||||
commands::llm_commands::generate_stream,
|
commands::llm_commands::generate_stream,
|
||||||
commands::llm_commands::get_llm_config,
|
commands::llm_commands::get_llm_config,
|
||||||
commands::llm_commands::set_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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::ipc::Channel;
|
|
||||||
|
|
||||||
// ─── LLM Event (for streaming) ───────────────────────────────
|
// ─── LLM Event (for streaming) ───────────────────────────────
|
||||||
|
|
||||||
@@ -16,6 +15,8 @@ pub enum LlmEvent {
|
|||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<LlmConfig>,
|
pub config: Mutex<LlmConfig>,
|
||||||
|
pub rag: crate::rag::RagStore,
|
||||||
|
pub gen: crate::generations::GenerationStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -26,6 +27,10 @@ pub struct LlmConfig {
|
|||||||
pub temperature: f32,
|
pub temperature: f32,
|
||||||
pub max_tokens: u32,
|
pub max_tokens: u32,
|
||||||
pub top_p: f32,
|
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 {
|
impl Default for LlmConfig {
|
||||||
@@ -38,6 +43,8 @@ impl Default for LlmConfig {
|
|||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
top_p: 0.9,
|
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<String>,
|
pub system: Option<String>,
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
pub max_tokens: Option<u32>,
|
pub max_tokens: Option<u32>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OpenAI-Compatible Chat Response ─────────────────────────
|
// ─── OpenAI-Compatible Chat Response ─────────────────────────
|
||||||
|
|||||||
@@ -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<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct EmbedResponse {
|
||||||
|
embeddings: Vec<Vec<f32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RagStore {
|
||||||
|
pub fn open(dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<Vec<f32>>> {
|
||||||
|
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<u8> {
|
||||||
|
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<f32> {
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<(String, String, f32)>> {
|
||||||
|
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<u8>)> = {
|
||||||
|
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<u8>>(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<Vec<(String, i64)>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
-113
@@ -3,17 +3,19 @@ import {
|
|||||||
User,
|
User,
|
||||||
Swords,
|
Swords,
|
||||||
Dice5,
|
Dice5,
|
||||||
ScrollText,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Sparkles,
|
|
||||||
Timer,
|
Timer,
|
||||||
Calendar,
|
Calendar,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
BookOpen,
|
||||||
|
ImagePlus,
|
||||||
|
Shuffle,
|
||||||
|
Flag,
|
||||||
|
History,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Dashboard } from "./components/Dashboard";
|
import { Dashboard } from "./components/Dashboard";
|
||||||
import { SettingsPanel } from "./components/SettingsPanel";
|
import { SettingsPanel } from "./components/SettingsPanel";
|
||||||
import { NpcGenerator } from "./components/NpcGenerator";
|
import { NpcGenerator } from "./components/NpcGenerator";
|
||||||
@@ -27,7 +29,13 @@ import { WorldBuilder } from "./components/WorldBuilder";
|
|||||||
import { ItemForge } from "./components/ItemForge";
|
import { ItemForge } from "./components/ItemForge";
|
||||||
import { QuestDesigner } from "./components/QuestDesigner";
|
import { QuestDesigner } from "./components/QuestDesigner";
|
||||||
import { Soundboard } from "./components/Soundboard";
|
import { Soundboard } from "./components/Soundboard";
|
||||||
|
import { LorePanel } from "./components/LorePanel";
|
||||||
|
import { ImageGenerator } from "./components/ImageGenerator";
|
||||||
import { ToastContainer } from "./components/Toast";
|
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 =
|
export type View =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
@@ -42,28 +50,71 @@ export type View =
|
|||||||
| "calendar"
|
| "calendar"
|
||||||
| "quest"
|
| "quest"
|
||||||
| "items"
|
| "items"
|
||||||
| "settings";
|
| "lore"
|
||||||
|
| "image"
|
||||||
|
| "settings"
|
||||||
|
| "history";
|
||||||
|
|
||||||
const navItems: { icon: typeof Map; label: string; view: View }[] = [
|
// ponytail: tools that accept a prefill from history. Mirrors the kinds.
|
||||||
{ icon: Map, label: "World", view: "world" },
|
const PREFILLABLE: Partial<Record<View, GenerationKind>> = {
|
||||||
{ icon: User, label: "NPCs", view: "npcs" },
|
npcs: "npc",
|
||||||
{ icon: Wand2, label: "Items", view: "items" },
|
encounter: "encounter",
|
||||||
{ icon: Swords, label: "Encounter", view: "encounter" },
|
world: "world",
|
||||||
{ icon: Dice5, label: "Dice", view: "dice" },
|
items: "item",
|
||||||
{ icon: ScrollText, label: "Session", view: "session" },
|
quest: "quest",
|
||||||
{ icon: Volume2, label: "Sound", view: "sound" },
|
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 <HistoryView onRehydrate={rehydrate} />;
|
||||||
|
}
|
||||||
|
// Tools that accept prefill.
|
||||||
|
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "items") return <ItemForge prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "quest") return <QuestDesigner prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "session") return <SessionLogger prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
|
if (view === "image") return <ImageGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "npcs":
|
|
||||||
return <NpcGenerator />;
|
|
||||||
case "dice":
|
case "dice":
|
||||||
return <DiceRoller />;
|
return <DiceRoller />;
|
||||||
case "session":
|
|
||||||
return <SessionLogger />;
|
|
||||||
case "encounter":
|
|
||||||
return <EncounterBuilder />;
|
|
||||||
case "initiative":
|
case "initiative":
|
||||||
return <InitiativeTracker />;
|
return <InitiativeTracker />;
|
||||||
case "tables":
|
case "tables":
|
||||||
@@ -72,12 +123,8 @@ function renderView(view: View): React.ReactNode {
|
|||||||
return <CalendarWidget />;
|
return <CalendarWidget />;
|
||||||
case "settings":
|
case "settings":
|
||||||
return <SettingsPanel />;
|
return <SettingsPanel />;
|
||||||
case "world":
|
case "lore":
|
||||||
return <WorldBuilder />;
|
return <LorePanel />;
|
||||||
case "quest":
|
|
||||||
return <QuestDesigner />;
|
|
||||||
case "items":
|
|
||||||
return <ItemForge />;
|
|
||||||
case "sound":
|
case "sound":
|
||||||
return <Soundboard />;
|
return <Soundboard />;
|
||||||
default:
|
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<Record<View, string>> = {
|
||||||
|
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() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>("dashboard");
|
const [view, setView] = useState<View>("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<Generation | null>(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 (
|
||||||
|
<button
|
||||||
|
onClick={() => setView(item.view)}
|
||||||
|
aria-label={item.label + (item.shortcut ? ` (${item.shortcut})` : "")}
|
||||||
|
data-tooltip={item.label}
|
||||||
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||||
|
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon size={item.view === "tables" ? 18 : 18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
|
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
|
||||||
{/* Left Rail */}
|
{/* Left Rail */}
|
||||||
<nav className="flex flex-col items-center w-14 bg-[var(--color-bg-surface)] border-r border-[var(--color-border-subtle)] py-3 gap-1 shrink-0">
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
className="flex flex-col items-center w-14 bg-[var(--color-bg-surface)] border-r border-[var(--color-border-subtle)] py-3 gap-1 shrink-0"
|
||||||
|
>
|
||||||
{/* App Logo — goes to dashboard */}
|
{/* App Logo — goes to dashboard */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setView("dashboard")}
|
onClick={() => setView("dashboard")}
|
||||||
|
aria-label="Dashboard"
|
||||||
|
data-tooltip="Dashboard"
|
||||||
className={`w-8 h-8 rounded-lg flex items-center justify-center mb-4 font-heading font-bold text-lg cursor-pointer transition-colors ${
|
className={`w-8 h-8 rounded-lg flex items-center justify-center mb-4 font-heading font-bold text-lg cursor-pointer transition-colors ${
|
||||||
view === "dashboard"
|
isOnDashboard
|
||||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)]"
|
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)]"
|
||||||
: "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)] hover:bg-[var(--color-gold-bright)] hover:text-[var(--color-bg-deep)]"
|
: "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)] hover:bg-[var(--color-gold-bright)] hover:text-[var(--color-bg-deep)]"
|
||||||
}`}
|
}`}
|
||||||
title="Dashboard"
|
|
||||||
>
|
>
|
||||||
⚔
|
⚔
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{navItems.map((item) => (
|
{/* SESSION group — in-combat tools */}
|
||||||
<button
|
<GroupLabel>Session</GroupLabel>
|
||||||
key={item.label}
|
{groupedNav.session.map((item) => (
|
||||||
onClick={() => setView(item.view)}
|
<NavButton key={item.view} item={item} />
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
))}
|
||||||
view === item.view
|
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
<div className="w-6 h-px bg-[var(--color-border-subtle)] my-1.5" />
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
||||||
}`}
|
{/* WORLD group — prep / worldbuilding tools */}
|
||||||
title={item.label}
|
<GroupLabel>World</GroupLabel>
|
||||||
>
|
{groupedNav.world.map((item) => (
|
||||||
<item.icon size={18} />
|
<NavButton key={item.view} item={item} />
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Extra nav items */}
|
|
||||||
<button
|
|
||||||
onClick={() => setView("initiative")}
|
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
||||||
view === "initiative"
|
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
||||||
}`}
|
|
||||||
title="Initiative"
|
|
||||||
>
|
|
||||||
<Timer size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView("tables")}
|
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
||||||
view === "tables"
|
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
||||||
}`}
|
|
||||||
title="Random Tables"
|
|
||||||
>
|
|
||||||
<Sparkles size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView("quest")}
|
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
||||||
view === "quest"
|
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
||||||
}`}
|
|
||||||
title="Quest Designer"
|
|
||||||
>
|
|
||||||
<Sparkles size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setView("calendar")}
|
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
||||||
view === "calendar"
|
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
||||||
}`}
|
|
||||||
title="Calendar"
|
|
||||||
>
|
|
||||||
<Calendar size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-6 h-px bg-[var(--color-border-subtle)] my-1" />
|
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setView(view === "settings" ? "dashboard" : "settings")}
|
onClick={() => setView("settings")}
|
||||||
|
aria-label="Settings (⌘,)"
|
||||||
|
data-tooltip="Settings"
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||||
view === "settings"
|
view === "settings"
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||||
}`}
|
}`}
|
||||||
title="Settings"
|
|
||||||
>
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -194,11 +309,13 @@ export default function App() {
|
|||||||
className="flex items-center h-10 px-4 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-subtle)] shrink-0"
|
className="flex items-center h-10 px-4 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-subtle)] shrink-0"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
{isDetailView && (
|
{!isOnDashboard && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setView("dashboard")}
|
onClick={() => setView("dashboard")}
|
||||||
|
data-tauri-no-drag
|
||||||
className="mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
className="mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
title="Back to dashboard"
|
title="Back to dashboard"
|
||||||
|
aria-label="Back to dashboard"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -206,35 +323,82 @@ export default function App() {
|
|||||||
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
|
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
|
||||||
DM-Pal
|
DM-Pal
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-4 flex items-center gap-1 text-[var(--color-text-secondary)] text-xs cursor-pointer hover:text-[var(--color-text-primary)] transition-colors">
|
<button
|
||||||
<ChevronDown size={12} />
|
onClick={() => setPaletteOpen(true)}
|
||||||
<span>My Campaign</span>
|
data-tauri-no-drag
|
||||||
</div>
|
className="ml-4 flex items-center gap-2 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||||||
|
aria-label="Open command palette (⌘K)"
|
||||||
|
>
|
||||||
|
<span>Jump to…</span>
|
||||||
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView("history")}
|
||||||
|
data-tauri-no-drag
|
||||||
|
className="ml-2 flex items-center gap-1.5 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
aria-label="Open history (⌘H)"
|
||||||
|
title="History (⌘H)"
|
||||||
|
>
|
||||||
|
<History size={12} />
|
||||||
|
<span className="hidden sm:inline">History</span>
|
||||||
|
<kbd className="hidden md:inline-flex items-center px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono">
|
||||||
|
⌘H
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<div className="ml-auto" data-tauri-no-drag />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
{view === "dashboard" ? (
|
<ErrorBoundary>
|
||||||
<Dashboard onNavigate={setView} />
|
{isOnDashboard ? (
|
||||||
) : view === "settings" ? (
|
<Dashboard onNavigate={setView} />
|
||||||
<div className="max-w-lg mx-auto p-6">
|
) : view === "history" ? (
|
||||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
||||||
Settings
|
) : (
|
||||||
</h2>
|
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}>
|
||||||
<SettingsPanel />
|
{view === "settings" ? (
|
||||||
</div>
|
<div className="glass-card p-6">
|
||||||
) : isDetailView ? (
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
<div className="max-w-2xl mx-auto p-6">
|
Settings
|
||||||
<div className="glass-card p-6">
|
</h2>
|
||||||
{renderView(view)}
|
<SettingsPanel />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onClose={() => setPaletteOpen(false)}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setView(v);
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Toast notifications */}
|
{/* Toast notifications */}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-[9px] uppercase tracking-widest text-[var(--color-text-dim)] font-medium mb-1 mt-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function BentoCard({
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`glass-card flex flex-col overflow-hidden p-4 ${responsive} ${className}`}
|
className={`glass-card flex flex-col overflow-hidden p-4 ${responsive} ${className}`}
|
||||||
whileHover={{ scale: 1.005 }}
|
// ponytail: drop the hover scale — 12 cards bobs noticeably when the
|
||||||
|
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
|
||||||
whileTap={{ scale: 0.985 }}
|
whileTap={{ scale: 0.985 }}
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { navItems, type View } from "../App";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (view: View) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
view: View;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: command palette is just nav items + the dashboard. A separate
|
||||||
|
// index of generated NPCs/items/quests would need persistence first, which
|
||||||
|
// is P1; ship the jump-to-tool now and extend later.
|
||||||
|
function buildItems(): PaletteItem[] {
|
||||||
|
const items: PaletteItem[] = [
|
||||||
|
{ id: "dashboard", label: "Dashboard", hint: "Home", view: "dashboard" },
|
||||||
|
...navItems.map((n) => ({
|
||||||
|
id: n.view,
|
||||||
|
label: n.label,
|
||||||
|
hint: n.shortcut ?? (n.group === "session" ? "Session" : "World"),
|
||||||
|
view: n.view,
|
||||||
|
})),
|
||||||
|
{ id: "settings", label: "Settings", hint: "⌘,", view: "settings" },
|
||||||
|
];
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const items = useMemo(() => buildItems(), []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter((it) => it.label.toLowerCase().includes(q));
|
||||||
|
}, [items, query]);
|
||||||
|
|
||||||
|
// Focus input + reset on open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery("");
|
||||||
|
setActiveIdx(0);
|
||||||
|
// requestAnimationFrame avoids the focus racing the open transition.
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Keep the active row in view as the user arrows around.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIdx >= filtered.length) setActiveIdx(0);
|
||||||
|
}, [filtered.length, activeIdx]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) => (filtered.length === 0 ? 0 : (i + 1) % filtered.length));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIdx((i) =>
|
||||||
|
filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length,
|
||||||
|
);
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = filtered[activeIdx];
|
||||||
|
if (item) onSelect(item.view);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Command palette"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md glass-card p-2 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setActiveIdx(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Jump anywhere…"
|
||||||
|
aria-label="Search tools"
|
||||||
|
className="w-full bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="border-t border-[var(--color-border-subtle)] mt-1 max-h-72 overflow-y-auto">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-3 py-4 text-xs text-[var(--color-text-dim)]">No matches.</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((it, i) => (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
onClick={() => onSelect(it.view)}
|
||||||
|
onMouseEnter={() => setActiveIdx(i)}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm rounded cursor-pointer transition-colors ${
|
||||||
|
i === activeIdx
|
||||||
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||||
|
: "text-[var(--color-text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{it.hint && (
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)]">
|
||||||
|
{it.hint}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
<ArrowRight
|
||||||
|
size={12}
|
||||||
|
className={
|
||||||
|
i === activeIdx ? "text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)]"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-3 pt-2 text-[10px] text-[var(--color-text-dim)]">
|
||||||
|
<span>↑↓ navigate · ↵ open</span>
|
||||||
|
<span>esc to close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,9 +10,23 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
ImagePlus,
|
||||||
|
BookOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { BentoCard } from "./BentoCard";
|
import { BentoCard } from "./BentoCard";
|
||||||
import type { View } from "../App";
|
import type { View } from "../App";
|
||||||
|
import { NpcGenerator } from "./NpcGenerator";
|
||||||
|
import { DiceRoller } from "./DiceRoller";
|
||||||
|
import { SessionLogger } from "./SessionLogger";
|
||||||
|
import { EncounterBuilder } from "./EncounterBuilder";
|
||||||
|
import { InitiativeTracker } from "./InitiativeTracker";
|
||||||
|
import { RandomTables } from "./RandomTables";
|
||||||
|
import { CalendarWidget } from "./CalendarWidget";
|
||||||
|
import { WorldBuilder } from "./WorldBuilder";
|
||||||
|
import { QuestDesigner } from "./QuestDesigner";
|
||||||
|
import { ItemForge } from "./ItemForge";
|
||||||
|
import { Soundboard } from "./Soundboard";
|
||||||
|
import { ImageGenerator } from "./ImageGenerator";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
onNavigate: (view: View) => void;
|
onNavigate: (view: View) => void;
|
||||||
@@ -185,19 +199,38 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
|
|
||||||
|
{/* Lore — 1×1 */}
|
||||||
|
<BentoCard title="Lore" icon={<BookOpen size={16} />} span="col-span-1 row-span-1">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
|
||||||
|
Index your world bible into a local vector store and ground AI generations in it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate("lore")}
|
||||||
|
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} /> Open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BentoCard>
|
||||||
|
|
||||||
|
{/* Image Generator — 2×1 */}
|
||||||
|
<BentoCard title="Image Generator" icon={<ImagePlus size={16} />} span="col-span-2 row-span-1">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ImageGenerator />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate("image")}
|
||||||
|
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} /> Expand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BentoCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline imports for dashboard cards (compact versions)
|
|
||||||
import { NpcGenerator } from "./NpcGenerator";
|
|
||||||
import { DiceRoller } from "./DiceRoller";
|
|
||||||
import { SessionLogger } from "./SessionLogger";
|
|
||||||
import { EncounterBuilder } from "./EncounterBuilder";
|
|
||||||
import { InitiativeTracker } from "./InitiativeTracker";
|
|
||||||
import { RandomTables } from "./RandomTables";
|
|
||||||
import { CalendarWidget } from "./CalendarWidget";
|
|
||||||
import { WorldBuilder } from "./WorldBuilder";
|
|
||||||
import { QuestDesigner } from "./QuestDesigner";
|
|
||||||
import { ItemForge } from "./ItemForge";
|
|
||||||
import { Soundboard } from "./Soundboard";
|
|
||||||
+206
-32
@@ -1,18 +1,36 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface DieResult {
|
interface DieResult {
|
||||||
notation: string;
|
notation: string;
|
||||||
rolls: number[];
|
rolls: number[];
|
||||||
total: number;
|
total: number;
|
||||||
|
modifier: number;
|
||||||
|
advantage?: "adv" | "dis" | null;
|
||||||
|
keptRoll?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
||||||
|
|
||||||
|
// ponytail: roll templates for the 4 things a DM rolls every combat.
|
||||||
|
// Each template uses the modifier typed in the input box, so the DM
|
||||||
|
// sets "+5" once and templates respect it. 5e convention, not house rule.
|
||||||
|
const TEMPLATES: { label: string; die: string }[] = [
|
||||||
|
{ label: "Attack", die: "d20" },
|
||||||
|
{ label: "Save", die: "d20" },
|
||||||
|
{ label: "Check", die: "d20" },
|
||||||
|
{ label: "Damage", die: "d8" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Mode = "normal" | "adv" | "dis";
|
||||||
|
|
||||||
function rollDie(sides: number): number {
|
function rollDie(sides: number): number {
|
||||||
return Math.floor(Math.random() * sides) + 1;
|
return Math.floor(Math.random() * sides) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNotation(notation: string): { count: number; sides: number; modifier: number } | null {
|
function parseNotation(
|
||||||
|
notation: string,
|
||||||
|
): { count: number; sides: number; modifier: number } | null {
|
||||||
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return {
|
return {
|
||||||
@@ -22,38 +40,123 @@ function parseNotation(notation: string): { count: number; sides: number; modifi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
|
||||||
|
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
|
||||||
|
// meaningful for d20 rolls; for other dice we just roll normally.
|
||||||
|
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
|
||||||
|
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
|
||||||
|
return { rolls, kept: rolls[0] ?? 0 };
|
||||||
|
}
|
||||||
|
if (mode === "adv") {
|
||||||
|
const hi = Math.max(rolls[0], rolls[1]);
|
||||||
|
return { rolls, kept: hi };
|
||||||
|
}
|
||||||
|
const lo = Math.min(rolls[0], rolls[1]);
|
||||||
|
return { rolls, kept: lo };
|
||||||
|
}
|
||||||
|
|
||||||
export function DiceRoller() {
|
export function DiceRoller() {
|
||||||
const [input, setInput] = useState("1d20");
|
const [input, setInput] = useState("1d20");
|
||||||
const [results, setResults] = useState<DieResult[]>([]);
|
const [results, setResults] = useState<DieResult[]>([]);
|
||||||
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
||||||
|
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
|
||||||
|
const [mode, setMode] = useState<Mode>("normal");
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const roll = useCallback(() => {
|
const roll = useCallback(
|
||||||
const parsed = parseNotation(input);
|
(overrideNotation?: string) => {
|
||||||
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
|
const notation = (overrideNotation ?? input).trim();
|
||||||
|
const parsed = parseNotation(notation);
|
||||||
|
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
|
||||||
|
|
||||||
const rolls = Array.from({ length: parsed.count }, () => rollDie(parsed.sides));
|
// For d20 + adv/dis, roll an extra die to compare.
|
||||||
const sum = rolls.reduce((a, b) => a + b, 0) + parsed.modifier;
|
const baseCount = parsed.count;
|
||||||
const result: DieResult = {
|
const isD20AdvDis = parsed.sides === 20 && baseCount === 1 && mode !== "normal";
|
||||||
notation: input.trim(),
|
const rollCount = isD20AdvDis ? 2 : baseCount;
|
||||||
rolls,
|
const rawRolls = Array.from({ length: rollCount }, () => rollDie(parsed.sides));
|
||||||
total: sum,
|
const { rolls, kept } = applyMode(rawRolls, parsed.sides, mode);
|
||||||
};
|
const sum = kept + parsed.modifier;
|
||||||
|
|
||||||
setResults((prev) => [result, ...prev].slice(0, 50));
|
const result: DieResult = {
|
||||||
setLastTotal(sum);
|
notation: mode !== "normal" && parsed.sides === 20
|
||||||
}, [input]);
|
? `${notation} (${mode === "adv" ? "adv" : "dis"})`
|
||||||
|
: notation,
|
||||||
|
rolls,
|
||||||
|
total: sum,
|
||||||
|
modifier: parsed.modifier,
|
||||||
|
advantage: mode !== "normal" && parsed.sides === 20 ? mode : null,
|
||||||
|
keptRoll: mode !== "normal" && parsed.sides === 20 ? kept : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
setResults((prev) => [result, ...prev].slice(0, 50));
|
||||||
if (e.key === "Enter") roll();
|
setLastTotal(sum);
|
||||||
};
|
setLastBreakdown(result);
|
||||||
|
},
|
||||||
|
[input, mode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ponytail: spacebar rolls the current notation when no input is focused.
|
||||||
|
// Hands-free dice at the table is the whole reason a DM uses a digital roller.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.code !== "Space") return;
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
|
e.preventDefault();
|
||||||
|
roll();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [roll]);
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
setResults([]);
|
||||||
|
setLastTotal(null);
|
||||||
|
setLastBreakdown(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyHistory() {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
const lines = results
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((r) => {
|
||||||
|
const detail = r.rolls.length > 1 ? ` [${r.rolls.join(", ")}]` : "";
|
||||||
|
return `${r.notation}${detail} → ${r.total}`;
|
||||||
|
});
|
||||||
|
void navigator.clipboard.writeText(lines.join("\n")).then(
|
||||||
|
() => addToast("History copied", "success"),
|
||||||
|
() => addToast("Copy failed", "error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: apply a template (Attack/Save/Check/Damage). The current input's
|
||||||
|
// trailing modifier is reused so "+5" typed once works for all 5e rolls.
|
||||||
|
// The template label is recorded in the most recent history row so the
|
||||||
|
// DM can scan "Attack 1d20+5 → 17" instead of a wall of bare notation.
|
||||||
|
function applyTemplate(template: { notation: string; label: string }) {
|
||||||
|
roll(template.notation);
|
||||||
|
setResults((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const [head, ...rest] = prev;
|
||||||
|
return [
|
||||||
|
{ ...head, notation: `${template.label} (${head.notation})` },
|
||||||
|
...rest,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 h-full">
|
<div className="flex flex-col gap-3 h-full">
|
||||||
{/* Big result display */}
|
{/* Big result display */}
|
||||||
<div className="flex items-center justify-center py-3">
|
<div className="flex flex-col items-center justify-center py-3 gap-1">
|
||||||
<span className="font-mono text-3xl font-bold text-[var(--color-gold-bright)]">
|
<span className="font-mono text-4xl font-bold text-[var(--color-gold-bright)]">
|
||||||
{lastTotal !== null ? lastTotal : "—"}
|
{lastTotal !== null ? lastTotal : "—"}
|
||||||
</span>
|
</span>
|
||||||
|
{lastBreakdown && (
|
||||||
|
<span className="font-mono text-[11px] text-[var(--color-text-secondary)]">
|
||||||
|
{formatBreakdown(lastBreakdown)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input row */}
|
{/* Input row */}
|
||||||
@@ -62,30 +165,46 @@ export function DiceRoller() {
|
|||||||
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono"
|
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={(e) => e.key === "Enter" && roll()}
|
||||||
placeholder="2d6+3"
|
placeholder="2d6+3"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={roll}
|
onClick={() => roll()}
|
||||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
|
title="Roll (Space)"
|
||||||
>
|
>
|
||||||
Roll
|
Roll
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mode toggle — advantage / disadvantage / normal */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[var(--color-text-dim)] text-xs mr-1">d20 mode:</span>
|
||||||
|
{(["normal", "adv", "dis"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`rounded px-2.5 py-1 text-xs cursor-pointer transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-semibold"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)]"
|
||||||
|
}`}
|
||||||
|
title={m === "adv" ? "Roll twice, keep higher" : m === "dis" ? "Roll twice, keep lower" : "Normal"}
|
||||||
|
>
|
||||||
|
{m === "normal" ? "Normal" : m === "adv" ? "Adv" : "Dis"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-[var(--color-text-dim)] text-[10px] ml-auto" aria-hidden="true">
|
||||||
|
space to roll
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick dice */}
|
{/* Quick dice */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{PRESETS.map((d) => (
|
{PRESETS.map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
onClick={() => {
|
onClick={() => roll(`1${d}`)}
|
||||||
const parsed = parseNotation(`1${d}`);
|
|
||||||
if (!parsed) return;
|
|
||||||
const rolls = [rollDie(parsed.sides)];
|
|
||||||
const total = rolls[0] + parsed.modifier;
|
|
||||||
setResults((prev) => [{ notation: `1${d}`, rolls, total }, ...prev].slice(0, 50));
|
|
||||||
setLastTotal(total);
|
|
||||||
}}
|
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer font-mono"
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer font-mono"
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
@@ -93,19 +212,62 @@ export function DiceRoller() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Roll templates — the 4 things a DM rolls most. */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="text-[var(--color-text-dim)] text-[10px] uppercase tracking-wider w-full">
|
||||||
|
Templates (use current modifier)
|
||||||
|
</span>
|
||||||
|
{TEMPLATES.map((t) => {
|
||||||
|
const modMatch = input.match(/[+-]\s*\d+/);
|
||||||
|
const mod = modMatch ? modMatch[0].replace(/\s+/g, "") : "+0";
|
||||||
|
const notation = `1${t.die}${mod}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.label}
|
||||||
|
onClick={() => applyTemplate({ label: t.label, notation })}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2.5 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
title={`${t.label} — ${notation}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* History */}
|
{/* History */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
|
||||||
|
History
|
||||||
|
</span>
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyHistory}
|
||||||
|
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{results.map((r, i) => (
|
{results.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-1.5"
|
className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs text-[var(--color-text-secondary)]">
|
<span className="font-mono text-xs text-[var(--color-text-secondary)] truncate">
|
||||||
{r.notation}
|
{r.notation}
|
||||||
{r.rolls.length > 1 && ` [${r.rolls.join(", ")}]`}
|
{r.rolls.length > 1 && ` [${r.rolls.join(", ")}]`}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">
|
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)] shrink-0 ml-2">
|
||||||
{r.total}
|
{r.total}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,3 +277,15 @@ export function DiceRoller() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBreakdown(r: DieResult): string {
|
||||||
|
if (r.advantage && r.keptRoll !== undefined) {
|
||||||
|
return `[${r.rolls.join(", ")}] keep ${r.advantage === "adv" ? "hi" : "lo"}${r.modifier !== 0 ? ` ${r.modifier > 0 ? "+" : ""}${r.modifier}` : ""} = ${r.total}`;
|
||||||
|
}
|
||||||
|
if (r.rolls.length > 1 || r.modifier !== 0) {
|
||||||
|
const base = r.rolls.length > 1 ? `[${r.rolls.join("+")}]` : `${r.rolls[0]}`;
|
||||||
|
const mod = r.modifier !== 0 ? ` ${r.modifier > 0 ? "+" : ""}${r.modifier}` : "";
|
||||||
|
return `${base}${mod} = ${r.total}`;
|
||||||
|
}
|
||||||
|
return `${r.rolls[0]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||||
|
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
|
||||||
|
|
||||||
interface Encounter {
|
interface Encounter {
|
||||||
monsters: string[];
|
monsters: string[];
|
||||||
@@ -9,13 +15,178 @@ interface Encounter {
|
|||||||
loot: string;
|
loot: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EncounterBuilder() {
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: 5e terrains with icons. The plan called for 12; we now have
|
||||||
|
// 12 (added planar, haunted, ship, siege to the original 8).
|
||||||
|
const TERRAINS: { value: string; icon: string }[] = [
|
||||||
|
{ value: "Forest", icon: "🌲" },
|
||||||
|
{ value: "Dungeon", icon: "🏰" },
|
||||||
|
{ value: "Urban", icon: "🏛" },
|
||||||
|
{ value: "Mountain", icon: "⛰" },
|
||||||
|
{ value: "Desert", icon: "🏜" },
|
||||||
|
{ value: "Swamp", icon: "🌫" },
|
||||||
|
{ value: "Coastal", icon: "🌊" },
|
||||||
|
{ value: "Underdark", icon: "🕳" },
|
||||||
|
{ value: "Planar", icon: "✈" },
|
||||||
|
{ value: "Haunted", icon: "🏚" },
|
||||||
|
{ value: "Ship", icon: "🚢" },
|
||||||
|
{ value: "Siege", icon: "🏰" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ponytail: parse "3x Goblin Scouts" or "2 bandits" out of the LLM's monster
|
||||||
|
// string. Returns the count + the cleaned name. If no count is found, defaults
|
||||||
|
// to 1. Used when sending to the Initiative Tracker.
|
||||||
|
function parseMonsterLine(raw: string): { name: string; count: number } {
|
||||||
|
const m = raw.match(/^\s*(\d+)\s*[xX*]?\s+(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
return { count: Math.max(1, parseInt(m[1], 10)), name: m[2].trim() };
|
||||||
|
}
|
||||||
|
return { count: 1, name: raw.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: rough HP estimate by CR. Not strictly DMG-correct, but it gives
|
||||||
|
// the DM a starting point that they can edit on the Initiative Tracker. The
|
||||||
|
// table is from a synthesis of DMG averages; the DM is expected to override
|
||||||
|
// per monster. "—" for unknown CR.
|
||||||
|
const CR_TO_HP: Record<string, number> = {
|
||||||
|
"0": 4,
|
||||||
|
"0.125": 9,
|
||||||
|
"0.25": 17,
|
||||||
|
"0.5": 28,
|
||||||
|
"1": 39,
|
||||||
|
"2": 49,
|
||||||
|
"3": 64,
|
||||||
|
"4": 84,
|
||||||
|
"5": 109,
|
||||||
|
"6": 133,
|
||||||
|
"7": 156,
|
||||||
|
"8": 178,
|
||||||
|
"9": 200,
|
||||||
|
"10": 222,
|
||||||
|
"11": 244,
|
||||||
|
"12": 267,
|
||||||
|
"13": 289,
|
||||||
|
"14": 311,
|
||||||
|
"15": 333,
|
||||||
|
"16": 356,
|
||||||
|
"17": 378,
|
||||||
|
"18": 400,
|
||||||
|
"19": 422,
|
||||||
|
"20": 444,
|
||||||
|
"21": 467,
|
||||||
|
"22": 489,
|
||||||
|
"23": 511,
|
||||||
|
"24": 533,
|
||||||
|
"25": 556,
|
||||||
|
"26": 578,
|
||||||
|
"27": 600,
|
||||||
|
"28": 622,
|
||||||
|
"29": 644,
|
||||||
|
"30": 667,
|
||||||
|
};
|
||||||
|
|
||||||
|
function hpForCr(cr: string | null | undefined): number {
|
||||||
|
if (!cr) return 30; // ponytail: default to ~CR 1 HP if no CR known.
|
||||||
|
const hit = CR_TO_HP[cr.trim()];
|
||||||
|
return hit ?? 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: very lightweight CR detection from a monster name. Looks for
|
||||||
|
// common ordinals ("CR 5"), trailing " (CR 1/4)" annotations, or specific
|
||||||
|
// monsters. The DM can edit the HP on the Initiative Tracker once imported.
|
||||||
|
const KNOWN_CR: Record<string, string> = {
|
||||||
|
goblin: "0.25",
|
||||||
|
"goblin scout": "0.25",
|
||||||
|
hobgoblin: "0.5",
|
||||||
|
orc: "0.5",
|
||||||
|
"orc warrior": "1",
|
||||||
|
kobold: "0.125",
|
||||||
|
bandit: "0.125",
|
||||||
|
"bandit captain": "2",
|
||||||
|
"bugbear chief": "3",
|
||||||
|
bugbear: "1",
|
||||||
|
gnoll: "0.5",
|
||||||
|
"dire wolf": "1",
|
||||||
|
wolf: "0.25",
|
||||||
|
skeleton: "0.25",
|
||||||
|
zombie: "0.25",
|
||||||
|
ghoul: "1",
|
||||||
|
ghast: "2",
|
||||||
|
wight: "3",
|
||||||
|
ogre: "2",
|
||||||
|
troll: "5",
|
||||||
|
"young red dragon": "10",
|
||||||
|
"adult red dragon": "17",
|
||||||
|
"ancient red dragon": "24",
|
||||||
|
manticore: "3",
|
||||||
|
griffon: "2",
|
||||||
|
wyvern: "6",
|
||||||
|
"ogre chief": "3",
|
||||||
|
shaman: "2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function crForName(name: string): string | null {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (KNOWN_CR[lower]) return KNOWN_CR[lower];
|
||||||
|
for (const [key, cr] of Object.entries(KNOWN_CR)) {
|
||||||
|
if (lower.includes(key)) return cr;
|
||||||
|
}
|
||||||
|
// Look for trailing "(CR X)" or "CR 1/2" annotations.
|
||||||
|
const m = name.match(/\(?CR\s*(\d+\/\d+|\d+)\)?/i);
|
||||||
|
if (m) return m[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [partyLevel, setPartyLevel] = useState(5);
|
const [partyLevel, setPartyLevel] = useState(5);
|
||||||
const [partySize, setPartySize] = useState(4);
|
const [partySize, setPartySize] = useState(4);
|
||||||
const [terrain, setTerrain] = useState("Forest");
|
const [terrain, setTerrain] = useState("Forest");
|
||||||
const [encounter, setEncounter] = useState<Encounter | null>(null);
|
const [encounter, setEncounter] = useState<Encounter | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
const parsed = parseLlmJson<Encounter>(g.data);
|
||||||
|
if (parsed) {
|
||||||
|
setEncounter(parsed);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const budget = useMemo(
|
||||||
|
() => encounterBudget(partyLevel, partySize),
|
||||||
|
[partyLevel, partySize],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ponytail: extract a 1-20 XP estimate from the LLM's "3x CR-1" or similar
|
||||||
|
// wording. The LLM doesn't return CR directly, so we sniff the monster
|
||||||
|
// names against our small table. If we can't, we fall back to a rough
|
||||||
|
// estimate by difficulty and surface the uncertainty in the UI.
|
||||||
|
const computedXp = useMemo(() => {
|
||||||
|
if (!encounter) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const raw of ensureArray(encounter.monsters)) {
|
||||||
|
const { count, name } = parseMonsterLine(flattenValue(raw));
|
||||||
|
const cr = crForName(name);
|
||||||
|
if (cr) {
|
||||||
|
// ponytail: CR to XP per DMG. 1/4 = 50, 1/2 = 100, 1 = 200, etc.
|
||||||
|
const xp = CR_TO_XP[cr] ?? 0;
|
||||||
|
total += xp * count;
|
||||||
|
} else {
|
||||||
|
total += 50 * count; // fallback: assume ~CR 1/4 average
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, [encounter]);
|
||||||
|
|
||||||
|
const difficulty: Difficulty | null = encounter
|
||||||
|
? difficultyForXp(computedXp, budget)
|
||||||
|
: null;
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -41,15 +212,49 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
const parsed = parseLlmJson<Encounter>(result);
|
const parsed = parseLlmJson<Encounter>(result);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setEncounter(parsed);
|
setEncounter(parsed);
|
||||||
|
addToast("Encounter generated", "success");
|
||||||
|
const title = extractTitle("encounter", result, `${parsed.difficulty ?? "?"} in ${terrain}`);
|
||||||
|
const source = `Party of ${partySize} lvl ${partyLevel} in ${terrain}`;
|
||||||
|
void addGeneration({ kind: "encounter", title, data: result, source });
|
||||||
|
const monsters = ensureArray(parsed.monsters).map(flattenValue).join(", ");
|
||||||
|
void addToLore(
|
||||||
|
`Encounter: ${parsed.difficulty || "?"} in ${terrain}`,
|
||||||
|
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||||
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`Encounter generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendToInitiative() {
|
||||||
|
if (!encounter) return;
|
||||||
|
const combatants = ensureArray(encounter.monsters).flatMap((raw) => {
|
||||||
|
const { name, count } = parseMonsterLine(flattenValue(raw));
|
||||||
|
const cr = crForName(name);
|
||||||
|
const hp = hpForCr(cr);
|
||||||
|
return Array.from({ length: count }, () => ({ name, hp }));
|
||||||
|
});
|
||||||
|
if (combatants.length === 0) {
|
||||||
|
addToast("No monsters to send", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: AddCombatantsPayload = {
|
||||||
|
combatants,
|
||||||
|
source: `${encounter.difficulty ?? "?"} in ${terrain}`,
|
||||||
|
};
|
||||||
|
bus.emit(Events.AddCombatants, payload);
|
||||||
|
addToast(
|
||||||
|
`Sent ${combatants.length} combatant${combatants.length === 1 ? "" : "s"} to Initiative Tracker — switch to Initiative to roll`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -84,12 +289,27 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
onChange={(e) => setTerrain(e.target.value)}
|
onChange={(e) => setTerrain(e.target.value)}
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
{["Forest", "Dungeon", "Urban", "Mountain", "Desert", "Swamp", "Coastal", "Underdark"].map((t) => (
|
{TERRAINS.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.icon} {t.value}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Budget readout — shows the DM what XP thresholds they're working with. */}
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-2 text-[10px] font-mono">
|
||||||
|
<div className="flex justify-between text-[var(--color-text-dim)] mb-1">
|
||||||
|
<span>Budget (party of {partySize} lvl {partyLevel})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1 text-center">
|
||||||
|
<BudgetCell label="Easy" value={budget.easy} color={DIFFICULTY_COLOR.Easy} active={difficulty === "Easy"} />
|
||||||
|
<BudgetCell label="Med" value={budget.medium} color={DIFFICULTY_COLOR.Medium} active={difficulty === "Medium"} />
|
||||||
|
<BudgetCell label="Hard" value={budget.hard} color={DIFFICULTY_COLOR.Hard} active={difficulty === "Hard"} />
|
||||||
|
<BudgetCell label="Dead" value={budget.deadly} color={DIFFICULTY_COLOR.Deadly} active={difficulty === "Deadly"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -107,11 +327,18 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
{encounter && (
|
{encounter && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
<span
|
||||||
{encounter.difficulty} Encounter
|
className="font-heading text-xs font-semibold rounded-full px-2 py-0.5"
|
||||||
|
style={{
|
||||||
|
color: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
|
||||||
|
borderColor: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
|
||||||
|
borderWidth: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{difficulty ?? encounter.difficulty} Encounter
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--color-text-dim)] text-[10px]">
|
<span className="text-[var(--color-text-dim)] text-[10px]">
|
||||||
Level {partyLevel} × {partySize}
|
~{computedXp} XP · L{partyLevel}×{partySize}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,8 +358,79 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Loot</span>
|
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Loot</span>
|
||||||
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
|
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={sendToInitiative}
|
||||||
|
className="mt-1 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-1.5 text-xs font-semibold hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center justify-center gap-1.5"
|
||||||
|
title="Add these monsters to the Initiative Tracker"
|
||||||
|
>
|
||||||
|
⚔ Send to Initiative Tracker
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BudgetCell({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded px-1 py-0.5 transition-colors ${
|
||||||
|
active ? "bg-[var(--color-bg-card)]" : ""
|
||||||
|
}`}
|
||||||
|
style={{ color: active ? color : "var(--color-text-dim)" }}
|
||||||
|
>
|
||||||
|
<div className="text-[9px] uppercase tracking-wider">{label}</div>
|
||||||
|
<div className="font-bold">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: CR → XP. From the DMG encounter-building table. Used to estimate
|
||||||
|
// the XP total of a generated encounter when computing difficulty.
|
||||||
|
const CR_TO_XP: Record<string, number> = {
|
||||||
|
"0": 10,
|
||||||
|
"0.125": 25,
|
||||||
|
"0.25": 50,
|
||||||
|
"0.5": 100,
|
||||||
|
"1": 200,
|
||||||
|
"2": 450,
|
||||||
|
"3": 700,
|
||||||
|
"4": 1100,
|
||||||
|
"5": 1800,
|
||||||
|
"6": 2300,
|
||||||
|
"7": 2900,
|
||||||
|
"8": 3900,
|
||||||
|
"9": 5000,
|
||||||
|
"10": 5900,
|
||||||
|
"11": 7200,
|
||||||
|
"12": 8400,
|
||||||
|
"13": 10000,
|
||||||
|
"14": 11500,
|
||||||
|
"15": 13000,
|
||||||
|
"16": 15000,
|
||||||
|
"17": 18000,
|
||||||
|
"18": 20000,
|
||||||
|
"19": 22000,
|
||||||
|
"20": 25000,
|
||||||
|
"21": 33000,
|
||||||
|
"22": 41000,
|
||||||
|
"23": 50000,
|
||||||
|
"24": 62000,
|
||||||
|
"25": 75000,
|
||||||
|
"26": 90000,
|
||||||
|
"27": 105000,
|
||||||
|
"28": 120000,
|
||||||
|
"29": 135000,
|
||||||
|
"30": 155000,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: a tiny class component. New dep (`react-error-boundary`) is 4KB
|
||||||
|
// for a feature that's 20 lines. Roll it by hand.
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
console.error("Uncaught error in DM-Pal:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 p-8 text-center">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<pre className="max-w-xl text-xs text-[var(--color-text-dim)] whitespace-pre-wrap break-words">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => location.reload()}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Sparkles, ImageOff } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Text-to-image prompt. Changing it triggers a new generation. */
|
||||||
|
prompt: string | null;
|
||||||
|
/** Bump to force a regenerate with the same prompt. */
|
||||||
|
nonce?: number;
|
||||||
|
className?: string;
|
||||||
|
/** Aspect for the placeholder box while loading/empty. */
|
||||||
|
aspect?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and caches an image for `prompt` via the Ollama image model.
|
||||||
|
* Results are cached on disk by the backend, so re-renders are instant.
|
||||||
|
* macOS-only: on other OSes the backend returns an error and we show a
|
||||||
|
* placeholder instead of a confusing timeout.
|
||||||
|
*/
|
||||||
|
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prompt) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setUnsupported(false);
|
||||||
|
invoke<string>("generate_image", { req: { prompt } })
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) setDataUrl(url);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const msg = String(e);
|
||||||
|
if (!cancelled) {
|
||||||
|
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||||
|
else setError(msg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [prompt, nonce]);
|
||||||
|
|
||||||
|
if (!prompt) return null;
|
||||||
|
|
||||||
|
const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`;
|
||||||
|
|
||||||
|
if (unsupported) {
|
||||||
|
return (
|
||||||
|
<div className={box}>
|
||||||
|
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
|
||||||
|
<ImageOff size={20} />
|
||||||
|
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={box}>
|
||||||
|
<span className="text-[10px] text-[var(--color-danger)] px-2 text-center">{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={box}>
|
||||||
|
<Sparkles size={20} className="text-[var(--color-gold-bright)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataUrl ? (
|
||||||
|
<div className={box}>
|
||||||
|
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
History as HistoryIcon,
|
||||||
|
X,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ScrollText,
|
||||||
|
Sparkles,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import {
|
||||||
|
listGenerations,
|
||||||
|
getGeneration,
|
||||||
|
deleteGeneration,
|
||||||
|
clearAllGenerations,
|
||||||
|
relativeTime,
|
||||||
|
extractTitle,
|
||||||
|
KIND_LABELS,
|
||||||
|
KIND_ORDER,
|
||||||
|
type Generation,
|
||||||
|
type GenerationKind,
|
||||||
|
type GenerationSummary,
|
||||||
|
} from "../lib/generations";
|
||||||
|
import { ensureArray, flattenValue, parseLlmJson } from "../lib/llm-parse";
|
||||||
|
|
||||||
|
interface HistoryViewProps {
|
||||||
|
// ponytail: optional callback when a row is rehydrated into a tool.
|
||||||
|
// The App shell wires this to set the active view and prefill the form.
|
||||||
|
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||||
|
const [summaries, setSummaries] = useState<GenerationSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeKind, setActiveKind] = useState<GenerationKind | "all">("all");
|
||||||
|
const [selected, setSelected] = useState<Generation | null>(null);
|
||||||
|
const [selectedLoading, setSelectedLoading] = useState(false);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const list = await listGenerations();
|
||||||
|
setSummaries(list);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const byKind = new Map<GenerationKind, GenerationSummary[]>();
|
||||||
|
for (const k of KIND_ORDER) byKind.set(k, []);
|
||||||
|
for (const s of summaries) {
|
||||||
|
const list = byKind.get(s.kind);
|
||||||
|
if (list) list.push(s);
|
||||||
|
}
|
||||||
|
return byKind;
|
||||||
|
}, [summaries]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const rows: GenerationSummary[] = [];
|
||||||
|
for (const [, list] of grouped) {
|
||||||
|
for (const s of list) {
|
||||||
|
if (activeKind !== "all" && s.kind !== activeKind) continue;
|
||||||
|
if (q && !s.title.toLowerCase().includes(q)) continue;
|
||||||
|
rows.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [grouped, query, activeKind]);
|
||||||
|
|
||||||
|
const onSelect = useCallback(async (id: number) => {
|
||||||
|
setSelectedLoading(true);
|
||||||
|
try {
|
||||||
|
const g = await getGeneration(id);
|
||||||
|
setSelected(g);
|
||||||
|
} catch (e) {
|
||||||
|
addToast(`Failed to load: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
setSelectedLoading(false);
|
||||||
|
}
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const onDelete = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteGeneration(id);
|
||||||
|
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
if (selected?.id === id) setSelected(null);
|
||||||
|
} catch (e) {
|
||||||
|
addToast(`Delete failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected, addToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClearAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const n = await clearAllGenerations();
|
||||||
|
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||||
|
setSummaries([]);
|
||||||
|
setSelected(null);
|
||||||
|
setConfirmClear(false);
|
||||||
|
} catch (e) {
|
||||||
|
addToast(`Clear failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-3 p-4">
|
||||||
|
{/* List pane */}
|
||||||
|
<div className="flex flex-col w-80 shrink-0 glass-card p-3 gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-base font-semibold flex items-center gap-2">
|
||||||
|
<HistoryIcon size={16} /> History
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] p-1 rounded cursor-pointer"
|
||||||
|
title="Refresh"
|
||||||
|
aria-label="Refresh history"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{summaries.length > 0 && !confirmClear && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] p-1 rounded cursor-pointer"
|
||||||
|
title="Clear all"
|
||||||
|
aria-label="Clear all history"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmClear && (
|
||||||
|
<div className="flex items-center justify-between rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 px-2 py-1.5 text-xs">
|
||||||
|
<span className="text-[var(--color-danger)]">Delete all {summaries.length}?</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmClear(false)}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] rounded-lg px-2 py-1.5">
|
||||||
|
<Search size={12} className="text-[var(--color-text-dim)]" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search history…"
|
||||||
|
className="flex-1 bg-transparent text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
|
||||||
|
aria-label="Search history"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kind filter chips */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<KindChip
|
||||||
|
label="All"
|
||||||
|
count={summaries.length}
|
||||||
|
active={activeKind === "all"}
|
||||||
|
onClick={() => setActiveKind("all")}
|
||||||
|
/>
|
||||||
|
{KIND_ORDER.map((k) => {
|
||||||
|
const n = grouped.get(k)?.length ?? 0;
|
||||||
|
if (n === 0) return null;
|
||||||
|
return (
|
||||||
|
<KindChip
|
||||||
|
key={k}
|
||||||
|
label={KIND_LABELS[k]}
|
||||||
|
count={n}
|
||||||
|
active={activeKind === k}
|
||||||
|
onClick={() => setActiveKind(k)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto -mx-1">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-[var(--color-text-dim)] text-xs">
|
||||||
|
<Loader2 size={14} className="animate-spin mr-2" /> Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 p-2 text-[var(--color-danger)] text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||||
|
<ScrollText size={20} className="mb-2 opacity-50" />
|
||||||
|
<span>
|
||||||
|
{summaries.length === 0
|
||||||
|
? "Nothing generated yet."
|
||||||
|
: "No matches."}
|
||||||
|
</span>
|
||||||
|
{summaries.length === 0 && (
|
||||||
|
<span className="mt-1 text-[10px]">
|
||||||
|
Every NPC, world, item, and quest you generate will show up here.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||||
|
selected?.id === s.id
|
||||||
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||||
|
: "hover:bg-[var(--color-bg-card)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||||
|
{KIND_LABELS[s.kind]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||||
|
{relativeTime(s.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||||
|
{s.title}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail pane */}
|
||||||
|
<div className="flex-1 min-w-0 glass-card p-4 overflow-y-auto">
|
||||||
|
{selectedLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
||||||
|
<Loader2 size={16} className="animate-spin mr-2" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : selected ? (
|
||||||
|
<GenerationDetail
|
||||||
|
generation={selected}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onRehydrate={onRehydrate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center text-[var(--color-text-dim)] text-sm">
|
||||||
|
<Eye size={28} className="mb-3 opacity-40" />
|
||||||
|
<span>Select a generation to view it.</span>
|
||||||
|
<span className="mt-1 text-xs">
|
||||||
|
From here you can re-open it in its tool or copy as Markdown.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindChip({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-semibold"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label} <span className="opacity-70">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenerationDetail({
|
||||||
|
generation,
|
||||||
|
onDelete,
|
||||||
|
onRehydrate,
|
||||||
|
}: {
|
||||||
|
generation: Generation;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||||
|
}) {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const isImage = generation.kind === "image";
|
||||||
|
const isSession = generation.kind === "session";
|
||||||
|
|
||||||
|
let parsed: unknown = null;
|
||||||
|
if (!isImage) {
|
||||||
|
parsed = parseLlmJson(generation.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTitle = generation.title;
|
||||||
|
const title = isImage
|
||||||
|
? generation.title
|
||||||
|
: extractTitle(generation.kind, generation.data, fallbackTitle);
|
||||||
|
|
||||||
|
const rehydratableKinds: GenerationKind[] = [
|
||||||
|
"npc",
|
||||||
|
"encounter",
|
||||||
|
"world",
|
||||||
|
"item",
|
||||||
|
"quest",
|
||||||
|
];
|
||||||
|
const canRehydrate = rehydratableKinds.includes(generation.kind);
|
||||||
|
|
||||||
|
function copyMarkdown() {
|
||||||
|
let md: string;
|
||||||
|
if (isImage) {
|
||||||
|
md = `\n\n_${new Date(
|
||||||
|
generation.createdAt,
|
||||||
|
).toLocaleString()}_`;
|
||||||
|
} else if (parsed && typeof parsed === "object") {
|
||||||
|
md = `# ${title}\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
||||||
|
} else {
|
||||||
|
md = `# ${title}\n\n${generation.data}`;
|
||||||
|
}
|
||||||
|
void navigator.clipboard.writeText(md).then(
|
||||||
|
() => addToast("Copied as Markdown", "success"),
|
||||||
|
() => addToast("Copy failed", "error"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3 pb-2 border-b border-[var(--color-border-glass)]">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
|
||||||
|
{KIND_LABELS[generation.kind]}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-lg font-bold truncate">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{generation.source && (
|
||||||
|
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
|
||||||
|
from: {generation.source}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
|
||||||
|
{new Date(generation.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canRehydrate && onRehydrate && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRehydrate(generation.kind, generation)}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer flex items-center gap-1"
|
||||||
|
title={`Open in ${KIND_LABELS[generation.kind]} tool`}
|
||||||
|
>
|
||||||
|
<Sparkles size={12} /> Open in tool
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={copyMarkdown}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(generation.id)}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1.5 text-xs hover:text-[var(--color-danger)] transition-colors cursor-pointer"
|
||||||
|
title="Delete this generation"
|
||||||
|
aria-label="Delete generation"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{isImage ? (
|
||||||
|
<ImageBody data={generation.data} prompt={generation.title} />
|
||||||
|
) : isSession ? (
|
||||||
|
<SessionBody data={generation.data} />
|
||||||
|
) : parsed && typeof parsed === "object" ? (
|
||||||
|
<StructuredBody data={parsed} />
|
||||||
|
) : (
|
||||||
|
<pre className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-3 text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap break-words">
|
||||||
|
{generation.data}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageBody({ data, prompt }: { data: string; prompt: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 items-start">
|
||||||
|
<img
|
||||||
|
src={data}
|
||||||
|
alt={prompt}
|
||||||
|
className="rounded-lg max-w-full max-h-[60vh] border border-[var(--color-border-glass)]"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={data}
|
||||||
|
download="dm-pal-image.png"
|
||||||
|
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)]"
|
||||||
|
>
|
||||||
|
⬇ Save PNG
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionBody({ data }: { data: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-sm text-[var(--color-text-primary)] whitespace-pre-wrap leading-relaxed">
|
||||||
|
{data}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StructuredBody({ data }: { data: unknown }) {
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{typeof obj.bio === "string" && (
|
||||||
|
<Section label="Bio">
|
||||||
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
|
||||||
|
{obj.bio}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{typeof obj.description === "string" && (
|
||||||
|
<Section label="Description">
|
||||||
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
|
||||||
|
{obj.description}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{typeof obj.hook === "string" && (
|
||||||
|
<Section label="Hook">
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)] italic">
|
||||||
|
{obj.hook}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.personality) && obj.personality.length > 0 && (
|
||||||
|
<Section label="Personality">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ensureArray(obj.personality).map((p, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{flattenValue(p)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.goals) && obj.goals.length > 0 && (
|
||||||
|
<Section label="Goals">
|
||||||
|
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
|
||||||
|
{ensureArray(obj.goals).map((g, i) => (
|
||||||
|
<li key={i}>{flattenValue(g)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.monsters) && obj.monsters.length > 0 && (
|
||||||
|
<Section label="Monsters">
|
||||||
|
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
|
||||||
|
{ensureArray(obj.monsters).map((m, i) => (
|
||||||
|
<li key={i}>{flattenValue(m)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.terrain === "string" && (
|
||||||
|
<Section label="Terrain">
|
||||||
|
<p className="text-sm text-[var(--color-text-primary)]">{obj.terrain}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.difficulty === "string" && (
|
||||||
|
<Section label="Difficulty">
|
||||||
|
<span className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-0.5 text-xs">
|
||||||
|
{obj.difficulty}
|
||||||
|
</span>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.loot === "string" && (
|
||||||
|
<Section label="Loot">
|
||||||
|
<p className="text-sm text-[var(--color-gold-bright)]">{obj.loot}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.regions) && obj.regions.length > 0 && (
|
||||||
|
<Section label="Regions">
|
||||||
|
<ul className="text-sm text-[var(--color-text-primary)] space-y-1">
|
||||||
|
{ensureArray(obj.regions).map((r, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-2 py-1"
|
||||||
|
>
|
||||||
|
{flattenValue(r)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.landmarks) && obj.landmarks.length > 0 && (
|
||||||
|
<Section label="Landmarks">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ensureArray(obj.landmarks).map((l, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{flattenValue(l)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.conflicts) && obj.conflicts.length > 0 && (
|
||||||
|
<Section label="Conflicts">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{ensureArray(obj.conflicts).map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-2 py-1 text-sm text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{flattenValue(c)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.cultures) && obj.cultures.length > 0 && (
|
||||||
|
<Section label="Cultures">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ensureArray(obj.cultures).map((c, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{flattenValue(c)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.isArray(obj.steps) && obj.steps.length > 0 && (
|
||||||
|
<Section label="Steps">
|
||||||
|
<ol className="flex flex-col gap-2 list-decimal list-inside">
|
||||||
|
{ensureArray(obj.steps).map((s, i) => {
|
||||||
|
const step = typeof s === "object" && s !== null
|
||||||
|
? (s as Record<string, unknown>)
|
||||||
|
: { title: "", description: flattenValue(s) };
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2"
|
||||||
|
>
|
||||||
|
{typeof step.title === "string" && (
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-gold-bright)]">
|
||||||
|
{step.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof step.description === "string" && (
|
||||||
|
<div className="text-sm text-[var(--color-text-primary)] mt-0.5">
|
||||||
|
{step.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.twist === "string" && (
|
||||||
|
<Section label="Twist">
|
||||||
|
<div className="rounded border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||||
|
{obj.twist}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.reward === "string" && (
|
||||||
|
<Section label="Reward">
|
||||||
|
<div className="rounded border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||||
|
{obj.reward}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.mechanical === "string" && (
|
||||||
|
<Section label="Mechanics">
|
||||||
|
<div className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-2 text-sm text-[var(--color-text-primary)]">
|
||||||
|
{obj.mechanical}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.lore === "string" && (
|
||||||
|
<Section label="Lore">
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)] italic">
|
||||||
|
{obj.lore}
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof obj.rarity === "string" && (
|
||||||
|
<Section label="Rarity">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
|
{obj.rarity}
|
||||||
|
{typeof obj.type === "string" && <> · {obj.type}</>}
|
||||||
|
</span>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback: any keys we didn't render show as a key/value table. */}
|
||||||
|
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] mt-2">
|
||||||
|
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
|
||||||
|
Raw JSON
|
||||||
|
</summary>
|
||||||
|
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap break-words">
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)] font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal wrapper ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HistoryDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryDrawer({ open, onClose, onRehydrate }: HistoryDrawerProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-30 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Generation history"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-4 lg:inset-8 bg-[var(--color-bg-deep)] rounded-2xl border border-[var(--color-border-glass)] overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
initial={{ scale: 0.97, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.97, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold flex items-center gap-2">
|
||||||
|
<HistoryIcon size={14} /> Generation History
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-text-primary)] cursor-pointer"
|
||||||
|
title="Close (Esc)"
|
||||||
|
aria-label="Close history"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-44px)]">
|
||||||
|
<HistoryView onRehydrate={(k, d) => {
|
||||||
|
onRehydrate?.(k, d);
|
||||||
|
onClose();
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { ImagePlus, Sparkles } from "lucide-react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "x/flux2-klein:4b";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [model, setModel] = useState("");
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [variant, setVariant] = useState(0);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
setPrompt(g.title);
|
||||||
|
setDataUrl(g.data);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setDataUrl(null);
|
||||||
|
try {
|
||||||
|
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||||
|
// so "regenerate" actually produces a new image instead of the cached one.
|
||||||
|
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
|
||||||
|
const url = await invoke<string>("generate_image", {
|
||||||
|
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
||||||
|
});
|
||||||
|
setDataUrl(url);
|
||||||
|
addToast("Image generated", "success");
|
||||||
|
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
||||||
|
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
addToast(`Image generation failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
setVariant((v) => v + 1);
|
||||||
|
// run after state update flushes
|
||||||
|
setTimeout(generate, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupported = error.toLowerCase().includes("macos-only");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-24 resize-y"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="e.g. portrait of a grizzled dwarf blacksmith at a forge, warm light, oil-painting fantasy style, 1024x1024"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.metaKey || e.ctrlKey) && generate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
|
Model (optional — defaults to {DEFAULT_MODEL})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={generate}
|
||||||
|
disabled={loading || !prompt.trim()}
|
||||||
|
className="flex-1 rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||||
|
</button>
|
||||||
|
{dataUrl && !loading && (
|
||||||
|
<button
|
||||||
|
onClick={regenerate}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
title="Generate a new variation"
|
||||||
|
>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="aspect-square w-full max-w-md mx-auto rounded-lg border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center">
|
||||||
|
<ImagePlus size={28} className="text-[var(--color-gold-bright)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unsupported && (
|
||||||
|
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
||||||
|
Image generation is macOS-only via Ollama (for now). On other platforms the
|
||||||
|
✨ buttons elsewhere fall back to a placeholder.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !unsupported && (
|
||||||
|
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dataUrl && !loading && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||||
|
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={dataUrl}
|
||||||
|
download="dm-pal-image.png"
|
||||||
|
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
⬇ Save PNG
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||||
|
|
||||||
interface Combatant {
|
interface Combatant {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -7,6 +9,7 @@ interface Combatant {
|
|||||||
hp: number;
|
hp: number;
|
||||||
maxHp: number;
|
maxHp: number;
|
||||||
conditions: string[];
|
conditions: string[];
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONDITIONS = [
|
const CONDITIONS = [
|
||||||
@@ -24,6 +27,46 @@ export function InitiativeTracker() {
|
|||||||
const [hp, setHp] = useState(10);
|
const [hp, setHp] = useState(10);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [round, setRound] = useState(1);
|
const [round, setRound] = useState(1);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
const [hpInput, setHpInput] = useState<Record<string, string>>({});
|
||||||
|
// (useToast is wired up in the bus handler below; silence unused-import.)
|
||||||
|
useToast();
|
||||||
|
|
||||||
|
// ponytail: listen for EncounterBuilder's "send to initiative" event.
|
||||||
|
// We don't auto-switch view (the DM might be editing) but the toast tells
|
||||||
|
// them where the combatants landed. The encounter's HP estimates land in
|
||||||
|
// a new "pending" state — the DM can then click "Roll initiative" to assign
|
||||||
|
// d20+0 to each (since the LLM didn't provide DEX scores) and re-sort.
|
||||||
|
const [pendingPush, setPendingPush] = useState<{ count: number; source: string } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
return bus.on<AddCombatantsPayload>(Events.AddCombatants, (payload) => {
|
||||||
|
const added: Combatant[] = payload.combatants.map((c) => ({
|
||||||
|
id: String(nextId++),
|
||||||
|
name: c.name,
|
||||||
|
initiative: 0,
|
||||||
|
hp: c.hp,
|
||||||
|
maxHp: c.hp,
|
||||||
|
conditions: [],
|
||||||
|
}));
|
||||||
|
setCombatants((prev) => [...prev, ...added]);
|
||||||
|
setPendingPush({ count: added.length, source: payload.source });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ponytail: roll initiative for any combatant stuck at 0 (the placeholder
|
||||||
|
// for "we don't know the DEX yet"). The DM would otherwise have to click
|
||||||
|
// each one individually. After this, sort by initiative.
|
||||||
|
function rollPendingInitiative() {
|
||||||
|
setCombatants((prev) => {
|
||||||
|
const rolled = prev.map((c) =>
|
||||||
|
c.initiative === 0 && pendingPush && c.id
|
||||||
|
? { ...c, initiative: Math.floor(Math.random() * 20) + 1 + 0 }
|
||||||
|
: c,
|
||||||
|
);
|
||||||
|
return rolled.sort((a, b) => b.initiative - a.initiative);
|
||||||
|
});
|
||||||
|
setPendingPush(null);
|
||||||
|
}
|
||||||
|
|
||||||
const addCombatant = useCallback(() => {
|
const addCombatant = useCallback(() => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
@@ -55,16 +98,45 @@ export function InitiativeTracker() {
|
|||||||
? c.conditions.filter((cn) => cn !== condition)
|
? c.conditions.filter((cn) => cn !== condition)
|
||||||
: [...c.conditions, condition],
|
: [...c.conditions, condition],
|
||||||
}
|
}
|
||||||
: c
|
: c,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ponytail: ±1 buttons are for chipping; typed input is for big hits.
|
||||||
|
// Setting HP to a specific number requires a single keystroke + Enter.
|
||||||
|
const applyHp = useCallback(
|
||||||
|
(id: string, raw: string) => {
|
||||||
|
const n = parseInt(raw);
|
||||||
|
if (Number.isNaN(n)) return;
|
||||||
|
setCombatants((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, n)) } : c,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setHpInput((prev) => {
|
||||||
|
const { [id]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const changeHp = useCallback((id: string, delta: number) => {
|
const changeHp = useCallback((id: string, delta: number) => {
|
||||||
setCombatants((prev) =>
|
setCombatants((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c
|
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c,
|
||||||
)
|
),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setInitiative = useCallback((id: string, raw: string) => {
|
||||||
|
const n = parseInt(raw);
|
||||||
|
if (Number.isNaN(n)) return;
|
||||||
|
setCombatants((prev) =>
|
||||||
|
[...prev.map((c) => (c.id === id ? { ...c, initiative: n } : c))].sort(
|
||||||
|
(a, b) => b.initiative - a.initiative,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -87,8 +159,19 @@ export function InitiativeTracker() {
|
|||||||
setCombatants([]);
|
setCombatants([]);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setRound(1);
|
setRound(1);
|
||||||
|
setConfirmReset(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const activeIdx = activeId ? combatants.findIndex((c) => c.id === activeId) : -1;
|
||||||
|
const nextUpId =
|
||||||
|
combatants.length > 0
|
||||||
|
? activeIdx === -1
|
||||||
|
? combatants[0].id
|
||||||
|
: activeIdx < combatants.length - 1
|
||||||
|
? combatants[activeIdx + 1].id
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
||||||
{/* Round indicator */}
|
{/* Round indicator */}
|
||||||
@@ -99,19 +182,57 @@ export function InitiativeTracker() {
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={nextTurn}
|
onClick={nextTurn}
|
||||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
disabled={combatants.length === 0}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Next Turn
|
Next Turn
|
||||||
</button>
|
</button>
|
||||||
<button
|
{confirmReset ? (
|
||||||
onClick={reset}
|
<div className="flex items-center gap-1">
|
||||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer hover:text-[var(--color-danger)] transition-colors"
|
<span className="text-[10px] text-[var(--color-danger)]">Clear all?</span>
|
||||||
>
|
<button
|
||||||
Reset
|
onClick={reset}
|
||||||
</button>
|
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-xs font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmReset(false)}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmReset(true)}
|
||||||
|
disabled={combatants.length === 0}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer hover:text-[var(--color-danger)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ponytail: when monsters are pushed from the Encounter Builder, the
|
||||||
|
DM needs to roll initiative for them. The push sets all their
|
||||||
|
initiatives to 0; this banner offers a one-click 1d20 per pending
|
||||||
|
combatant. */}
|
||||||
|
{pendingPush && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-gold-bright)]/40 px-2.5 py-1.5 text-xs">
|
||||||
|
<span className="text-[var(--color-gold-bright)]">
|
||||||
|
{pendingPush.count} from “{pendingPush.source}”
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={rollPendingInitiative}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Roll initiative
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add combatant */}
|
{/* Add combatant */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
@@ -120,6 +241,7 @@ export function InitiativeTracker() {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && addCombatant()}
|
onKeyDown={(e) => e.key === "Enter" && addCombatant()}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
|
aria-label="Combatant name"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="w-10 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
className="w-10 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||||
@@ -127,6 +249,7 @@ export function InitiativeTracker() {
|
|||||||
value={initBonus}
|
value={initBonus}
|
||||||
onChange={(e) => setInitBonus(parseInt(e.target.value) || 0)}
|
onChange={(e) => setInitBonus(parseInt(e.target.value) || 0)}
|
||||||
title="Initiative bonus"
|
title="Initiative bonus"
|
||||||
|
aria-label="Initiative bonus"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="w-12 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
className="w-12 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||||
@@ -134,6 +257,7 @@ export function InitiativeTracker() {
|
|||||||
value={hp}
|
value={hp}
|
||||||
onChange={(e) => setHp(parseInt(e.target.value) || 1)}
|
onChange={(e) => setHp(parseInt(e.target.value) || 1)}
|
||||||
title="Max HP"
|
title="Max HP"
|
||||||
|
aria-label="Max HP"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={addCombatant}
|
onClick={addCombatant}
|
||||||
@@ -143,10 +267,19 @@ export function InitiativeTracker() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{combatants.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center text-[var(--color-text-dim)] text-xs">
|
||||||
|
<span>No combatants yet.</span>
|
||||||
|
<span className="mt-1">Add a name + HP, press Enter or +.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Combatant list */}
|
{/* Combatant list */}
|
||||||
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
|
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
|
||||||
{combatants.map((c) => {
|
{combatants.map((c) => {
|
||||||
const isActive = c.id === activeId;
|
const isActive = c.id === activeId;
|
||||||
|
const isNextUp = c.id === nextUpId && !isActive;
|
||||||
const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0;
|
const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0;
|
||||||
const hpColor =
|
const hpColor =
|
||||||
hpPct > 0.5
|
hpPct > 0.5
|
||||||
@@ -161,22 +294,36 @@ export function InitiativeTracker() {
|
|||||||
className={`rounded-lg p-2 text-xs transition-all cursor-default ${
|
className={`rounded-lg p-2 text-xs transition-all cursor-default ${
|
||||||
isActive
|
isActive
|
||||||
? "glass-card-active"
|
? "glass-card-active"
|
||||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]"
|
: isNextUp
|
||||||
|
? "bg-[var(--color-bg-card)] border border-[var(--color-border-glass)]"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="font-mono text-[var(--color-text-dim)] w-6 text-right shrink-0">
|
<input
|
||||||
{c.initiative}
|
className="font-mono text-[var(--color-text-dim)] w-8 text-right shrink-0 bg-transparent focus:outline-none focus:text-[var(--color-gold-bright)]"
|
||||||
</span>
|
type="number"
|
||||||
|
defaultValue={c.initiative}
|
||||||
|
onBlur={(e) => setInitiative(c.id, e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||||
|
title="Edit initiative"
|
||||||
|
aria-label={`Initiative for ${c.name}`}
|
||||||
|
/>
|
||||||
<span className="text-[var(--color-text-primary)] truncate font-medium">
|
<span className="text-[var(--color-text-primary)] truncate font-medium">
|
||||||
{c.name}
|
{c.name}
|
||||||
</span>
|
</span>
|
||||||
|
{isNextUp && (
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||||
|
next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeCombatant(c.id)}
|
onClick={() => removeCombatant(c.id)}
|
||||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] transition-colors shrink-0 cursor-pointer"
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] transition-colors shrink-0 cursor-pointer"
|
||||||
title="Remove"
|
title="Remove"
|
||||||
|
aria-label={`Remove ${c.name}`}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -193,8 +340,23 @@ export function InitiativeTracker() {
|
|||||||
<span className="font-mono text-[10px] shrink-0" style={{ color: hpColor }}>
|
<span className="font-mono text-[10px] shrink-0" style={{ color: hpColor }}>
|
||||||
{c.hp}/{c.maxHp}
|
{c.hp}/{c.maxHp}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => changeHp(c.id, -1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer">−</button>
|
<button onClick={() => changeHp(c.id, -1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer" aria-label="Decrease HP by 1">−</button>
|
||||||
<button onClick={() => changeHp(c.id, 1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-success)] cursor-pointer">+</button>
|
<button onClick={() => changeHp(c.id, 1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-success)] cursor-pointer" aria-label="Increase HP by 1">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HP direct-set input — type a number, press Enter to set HP. */}
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<input
|
||||||
|
className="w-14 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] font-mono"
|
||||||
|
type="number"
|
||||||
|
placeholder="set HP"
|
||||||
|
value={hpInput[c.id] ?? ""}
|
||||||
|
onChange={(e) => setHpInput((p) => ({ ...p, [c.id]: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && hpInput[c.id] && applyHp(c.id, hpInput[c.id])}
|
||||||
|
title="Type a number and press Enter to set HP"
|
||||||
|
aria-label={`Set HP for ${c.name}`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
@@ -205,6 +367,7 @@ export function InitiativeTracker() {
|
|||||||
key={cn}
|
key={cn}
|
||||||
onClick={() => toggleCondition(c.id, cn)}
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-1.5 py-0 text-[10px] cursor-pointer hover:opacity-80"
|
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-1.5 py-0 text-[10px] cursor-pointer hover:opacity-80"
|
||||||
|
title={`Remove ${cn}`}
|
||||||
>
|
>
|
||||||
{cn}
|
{cn}
|
||||||
</span>
|
</span>
|
||||||
@@ -212,13 +375,14 @@ export function InitiativeTracker() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add condition */}
|
{/* Add condition — show ALL 13, not 5. */}
|
||||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||||
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).slice(0, 5).map((cn) => (
|
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => (
|
||||||
<button
|
<button
|
||||||
key={cn}
|
key={cn}
|
||||||
onClick={() => toggleCondition(c.id, cn)}
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
|
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
|
||||||
|
title={`Add ${cn}`}
|
||||||
>
|
>
|
||||||
+{cn.slice(0, 3)}
|
+{cn.slice(0, 3)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
|
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
|
||||||
|
import { GeneratedImage } from "./GeneratedImage";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
|
||||||
interface ItemResponse {
|
interface ItemResponse {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -14,7 +19,12 @@ interface ItemResponse {
|
|||||||
const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"];
|
const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"];
|
||||||
const TYPES = ["Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", "Ring", "Wand", "Staff"];
|
const TYPES = ["Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", "Ring", "Wand", "Staff"];
|
||||||
|
|
||||||
export function ItemForge() {
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [rarity, setRarity] = useState("Rare");
|
const [rarity, setRarity] = useState("Rare");
|
||||||
const [itemType, setItemType] = useState("Wondrous Item");
|
const [itemType, setItemType] = useState("Wondrous Item");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
@@ -22,6 +32,21 @@ export function ItemForge() {
|
|||||||
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [artNonce, setArtNonce] = useState(0);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "item", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
const parsed = parseLlmJson<ItemResponse>(g.data);
|
||||||
|
if (parsed) {
|
||||||
|
setItem(parsed);
|
||||||
|
setRarity(parsed.rarity || "Rare");
|
||||||
|
setItemType(parsed.type || "Wondrous Item");
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ponytail: FLUX.2 renders readable text, so the item name is painted in-image.
|
||||||
|
const artPrompt = item ? `fantasy product shot of a ${item.rarity || rarity} ${item.type || itemType} named "${item.name || "magic item"}", ${flattenValue(item.description)}, dramatic side lighting, dark velvet background, 1024x1024` : null;
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -49,6 +74,7 @@ The JSON must have exactly these keys:
|
|||||||
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||||
temperature: 0.85,
|
temperature: 0.85,
|
||||||
max_tokens: 500,
|
max_tokens: 500,
|
||||||
|
ragQuery: `${rarity} ${itemType} ${prompt}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +83,21 @@ The JSON must have exactly these keys:
|
|||||||
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setItem(parsed);
|
setItem(parsed);
|
||||||
|
addToast("Item forged", "success");
|
||||||
|
const title = extractTitle("item", result, `${parsed.rarity ?? rarity} ${parsed.type ?? itemType}`);
|
||||||
|
const source = `${parsed.rarity || rarity} ${parsed.type || itemType}${prompt ? ` — ${prompt}` : ""}`;
|
||||||
|
void addGeneration({ kind: "item", title, data: result, source });
|
||||||
|
void addToLore(
|
||||||
|
`Item: ${parsed.name || itemType}`,
|
||||||
|
`${parsed.name || "Magic item"} — ${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response as JSON. Raw response shown below.");
|
setError("Could not parse LLM response as JSON. Raw response shown below.");
|
||||||
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`Item generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -132,15 +168,29 @@ The JSON must have exactly these keys:
|
|||||||
|
|
||||||
{item && (
|
{item && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4 flex flex-col gap-3">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4 flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex gap-3">
|
||||||
<div>
|
<div className="shrink-0 w-24">
|
||||||
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
|
<GeneratedImage prompt={artPrompt} nonce={artNonce} aspect="aspect-square" />
|
||||||
{item.name || "Unknown Item"}
|
<button
|
||||||
</h3>
|
onClick={() => setArtNonce((n) => n + 1)}
|
||||||
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
|
className="mt-1 w-full text-[10px] text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||||
<span>{item.rarity || rarity}</span>
|
title="Regenerate item art"
|
||||||
<span>•</span>
|
>
|
||||||
<span>{item.type || itemType}</span>
|
✨ new art
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
|
||||||
|
{item.name || "Unknown Item"}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
|
||||||
|
<span>{item.rarity || rarity}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{item.type || itemType}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface RagSource {
|
||||||
|
source: string;
|
||||||
|
chunks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RagHit {
|
||||||
|
text: string;
|
||||||
|
source: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LorePanel() {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [sources, setSources] = useState<RagSource[]>([]);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<RagHit[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
setSources(await invoke<RagSource[]>("rag_list"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSources();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
if (!source.trim() || !text.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
setMsg("");
|
||||||
|
try {
|
||||||
|
const n = await invoke<number>("rag_add", { req: { source, text } });
|
||||||
|
setMsg(`Indexed ${n} chunk${n === 1 ? "" : "s"}`);
|
||||||
|
setText("");
|
||||||
|
loadSources();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(String(e));
|
||||||
|
}
|
||||||
|
setAdding(false);
|
||||||
|
setTimeout(() => setMsg(""), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOne(s: string) {
|
||||||
|
await invoke("rag_clear", { source: s });
|
||||||
|
loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
await invoke("rag_clear", { source: null });
|
||||||
|
loadSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
setHits(await invoke<RagHit[]>("rag_search", { query, topK: 5 }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
|
{/* Add lore */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
||||||
|
<input
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-40 resize-y"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={add}
|
||||||
|
disabled={adding}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{adding ? "Embedding…" : "Add to Lore"}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sources */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||||
|
{sources.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sources.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{sources.map((s) => (
|
||||||
|
<li key={s.source} className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5">
|
||||||
|
<span className="text-[var(--color-text-primary)] text-xs truncate">
|
||||||
|
{s.source} <span className="text-[var(--color-text-dim)]">· {s.chunks} chunks</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => clearOne(s.source)}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors shrink-0"
|
||||||
|
title="Clear this source"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test retrieval */}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Test Retrieval</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||||
|
placeholder="Ask something your lore should answer…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={search}
|
||||||
|
disabled={searching}
|
||||||
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{searching ? "…" : "Search"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{hits.length > 0 && (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{hits.map((h, i) => (
|
||||||
|
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{hits.length === 0 && query && !searching && (
|
||||||
|
<p className="text-xs text-[var(--color-text-dim)]">No matches.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { GeneratedImage } from "./GeneratedImage";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
|
||||||
|
import { Dice5 } from "lucide-react";
|
||||||
|
|
||||||
interface NpcResponse {
|
interface NpcResponse {
|
||||||
bio: string;
|
bio: string;
|
||||||
@@ -8,17 +15,34 @@ interface NpcResponse {
|
|||||||
goals: string[];
|
goals: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const RACES = ["Human", "Elf", "Dwarf", "Halfling", "Orc", "Tiefling", "Dragonborn", "Gnome"];
|
interface Props {
|
||||||
const ALIGNMENTS = ["Lawful Good", "Neutral Good", "Chaotic Good", "Lawful Neutral", "True Neutral", "Chaotic Neutral", "Lawful Evil", "Neutral Evil", "Chaotic Evil"];
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function NpcGenerator() {
|
export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [race, setRace] = useState("Dwarf");
|
const [race, setRace] = useState("Dwarf");
|
||||||
const [charClass, setCharClass] = useState("Artisan");
|
const [background, setBackground] = useState("Guild Artisan");
|
||||||
const [alignment, setAlignment] = useState("Chaotic Good");
|
const [alignment, setAlignment] = useState("Chaotic Good");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [npc, setNpc] = useState<NpcResponse | null>(null);
|
const [npc, setNpc] = useState<NpcResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [portraitNonce, setPortraitNonce] = useState(0);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
const parsed = parseLlmJson<NpcResponse>(g.data);
|
||||||
|
if (parsed) {
|
||||||
|
setNpc(parsed);
|
||||||
|
if (g.title) setName(g.title);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ponytail: portrait prompt mirrors the NPC inputs; FLUX.2 renders readable
|
||||||
|
// text so we drop the name in-image when one is given.
|
||||||
|
const portraitPrompt = npc ? `fantasy oil-painting portrait of a ${race} ${background.toLowerCase()}, ${alignment.toLowerCase()} demeanor, ${ensureArray(npc.personality).slice(0, 2).map(flattenValue).join(", ")}, dramatic lighting, 1024x1024${name ? `, name "${name}" rendered as a caption` : ""}` : null;
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -28,7 +52,7 @@ export function NpcGenerator() {
|
|||||||
const prompt = `Create a detailed NPC for a fantasy RPG:
|
const prompt = `Create a detailed NPC for a fantasy RPG:
|
||||||
${name ? `Name: ${name}` : "Name: (generate one)"}
|
${name ? `Name: ${name}` : "Name: (generate one)"}
|
||||||
Race: ${race}
|
Race: ${race}
|
||||||
Class: ${charClass}
|
Background: ${background}
|
||||||
Alignment: ${alignment}
|
Alignment: ${alignment}
|
||||||
|
|
||||||
Provide the response as a JSON object with exactly these keys:
|
Provide the response as a JSON object with exactly these keys:
|
||||||
@@ -39,18 +63,33 @@ Provide the response as a JSON object with exactly these keys:
|
|||||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
|
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
|
||||||
|
|
||||||
const result = await invoke<string>("generate", {
|
const result = await invoke<string>("generate", {
|
||||||
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512 },
|
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512, ragQuery: `${race} ${background} ${alignment} NPC` },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to parse JSON from the response
|
// Try to parse JSON from the response
|
||||||
const parsed = parseLlmJson<NpcResponse>(result);
|
const parsed = parseLlmJson<NpcResponse>(result);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setNpc(parsed);
|
setNpc(parsed);
|
||||||
|
addToast("NPC generated", "success");
|
||||||
|
// ponytail: persist to history so the user can re-open it later.
|
||||||
|
const title = name.trim() || extractTitle("npc", result, `${race} ${background}`);
|
||||||
|
const source = `${race} ${background} (${alignment})${name ? `, "${name}"` : ""}`;
|
||||||
|
void addGeneration({ kind: "npc", title, data: result, source });
|
||||||
|
// ponytail: ingest every generated NPC into lore so later generations
|
||||||
|
// stay consistent with the growing cast.
|
||||||
|
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
|
||||||
|
const goals = ensureArray(parsed.goals).map(flattenValue).join("; ");
|
||||||
|
void addToLore(
|
||||||
|
`NPC: ${name || race + " " + background}`,
|
||||||
|
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||||
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`NPC generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,12 +100,23 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Name</label>
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Name</label>
|
||||||
<input
|
<div className="flex gap-1.5">
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
<input
|
||||||
value={name}
|
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
placeholder="Random"
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
placeholder="Random"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setName(randomName(race))}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
title={`Random ${race} name`}
|
||||||
|
aria-label={`Random ${race} name`}
|
||||||
|
>
|
||||||
|
<Dice5 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Race</label>
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Race</label>
|
||||||
@@ -79,12 +129,14 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Class</label>
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Background</label>
|
||||||
<input
|
<select
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm cursor-pointer"
|
||||||
value={charClass}
|
value={background}
|
||||||
onChange={(e) => setCharClass(e.target.value)}
|
onChange={(e) => setBackground(e.target.value)}
|
||||||
/>
|
>
|
||||||
|
{BACKGROUNDS.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Alignment</label>
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Alignment</label>
|
||||||
@@ -115,10 +167,24 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
|
|
||||||
{npc && (
|
{npc && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
|
<div className="flex gap-3">
|
||||||
{name || "NPC"}
|
<div className="shrink-0 w-24">
|
||||||
</h3>
|
<GeneratedImage prompt={portraitPrompt} nonce={portraitNonce} aspect="aspect-square" />
|
||||||
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
|
<button
|
||||||
|
onClick={() => setPortraitNonce((n) => n + 1)}
|
||||||
|
className="mt-1 w-full text-[10px] text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||||
|
title="Regenerate portrait"
|
||||||
|
>
|
||||||
|
✨ new portrait
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
|
||||||
|
{name || "NPC"}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { parseLlmJson } from "../lib/llm-parse";
|
import { parseLlmJson } from "../lib/llm-parse";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
|
||||||
interface QuestStep {
|
interface QuestStep {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,13 +20,28 @@ interface QuestResponse {
|
|||||||
reward: string;
|
reward: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestDesigner() {
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [theme, setTheme] = useState("");
|
const [theme, setTheme] = useState("");
|
||||||
const [level, setLevel] = useState(5);
|
const [level, setLevel] = useState(5);
|
||||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
const parsed = parseLlmJson<QuestResponse>(g.data);
|
||||||
|
if (parsed) {
|
||||||
|
setQuest(parsed);
|
||||||
|
setCurrentStep(0);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -51,11 +70,24 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
const parsed = parseLlmJson<QuestResponse>(result);
|
const parsed = parseLlmJson<QuestResponse>(result);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setQuest(parsed);
|
setQuest(parsed);
|
||||||
|
addToast("Quest designed", "success");
|
||||||
|
const title = extractTitle("quest", result, `Quest (lvl ${level})`);
|
||||||
|
const source = `Level ${level}${theme ? `, ${theme}` : ""}`;
|
||||||
|
void addGeneration({ kind: "quest", title, data: result, source });
|
||||||
|
const steps = (parsed.steps || [])
|
||||||
|
.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.choice ? ` (Choice: ${s.choice})` : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
void addToLore(
|
||||||
|
`Quest: ${parsed.title || "Untitled"}`,
|
||||||
|
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||||
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`Quest generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
|
||||||
export function SessionLogger() {
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
|
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
setSummary(g.data);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
});
|
||||||
|
|
||||||
function addEntry() {
|
function addEntry() {
|
||||||
if (!notes.trim()) return;
|
if (!notes.trim()) return;
|
||||||
@@ -34,8 +49,15 @@ export function SessionLogger() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
setSummary(result);
|
setSummary(result);
|
||||||
|
addToast("Session summary generated", "success");
|
||||||
|
// ponytail: persist so the DM can re-open past summaries. The data is
|
||||||
|
// raw text here, not JSON, so the extractTitle() fallback is fine.
|
||||||
|
const title = `Session ${new Date().toLocaleDateString()}`;
|
||||||
|
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
||||||
|
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`Summary failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface LlmConfig {
|
interface LlmConfig {
|
||||||
api_url: string;
|
api_url: string;
|
||||||
@@ -8,21 +9,29 @@ interface LlmConfig {
|
|||||||
temperature: number;
|
temperature: number;
|
||||||
max_tokens: number;
|
max_tokens: number;
|
||||||
top_p: number;
|
top_p: number;
|
||||||
|
image_model: string;
|
||||||
|
embed_model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPanel() {
|
export function SettingsPanel() {
|
||||||
const [config, setConfig] = useState<LlmConfig | null>(null);
|
const [config, setConfig] = useState<LlmConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
async function loadConfig() {
|
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||||
try {
|
useEffect(() => {
|
||||||
const c = await invoke<LlmConfig>("get_llm_config");
|
(async () => {
|
||||||
setConfig(c);
|
try {
|
||||||
} catch (e) {
|
const c = await invoke<LlmConfig>("get_llm_config");
|
||||||
console.error("Failed to load config:", e);
|
setConfig(c);
|
||||||
}
|
} catch (e) {
|
||||||
}
|
setError(String(e));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
@@ -31,24 +40,20 @@ export function SettingsPanel() {
|
|||||||
await invoke("set_llm_config", { config });
|
await invoke("set_llm_config", { config });
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
addToast("Settings saved", "success");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save config:", e);
|
addToast(`Save failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<p className="text-[var(--color-text-secondary)] text-sm">
|
<p className="text-[var(--color-text-secondary)] text-sm">Loading settings…</p>
|
||||||
Configure your LLM connection
|
{error && (
|
||||||
</p>
|
<p className="text-[var(--color-danger)] text-xs">Failed to load: {error}</p>
|
||||||
<button
|
)}
|
||||||
onClick={loadConfig}
|
|
||||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Load Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,13 +76,23 @@ export function SettingsPanel() {
|
|||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
API Key (optional)
|
API Key (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className="flex gap-1.5">
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
<input
|
||||||
type="password"
|
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
||||||
value={config.api_key}
|
type={showKey ? "text" : "password"}
|
||||||
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
|
value={config.api_key}
|
||||||
placeholder="sk-... (leave blank for local)"
|
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
|
||||||
/>
|
placeholder="sk-... (leave blank for local)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((s) => !s)}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors text-xs cursor-pointer"
|
||||||
|
title={showKey ? "Hide" : "Show"}
|
||||||
|
>
|
||||||
|
{showKey ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -92,6 +107,30 @@ export function SettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
|
Image model
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
||||||
|
value={config.image_model}
|
||||||
|
onChange={(e) => setConfig({ ...config, image_model: e.target.value })}
|
||||||
|
placeholder="x/flux2-klein:4b"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
|
Embedding model (Lore RAG)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
|
||||||
|
value={config.embed_model}
|
||||||
|
onChange={(e) => setConfig({ ...config, embed_model: e.target.value })}
|
||||||
|
placeholder="nomic-embed-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
|
||||||
interface WorldResponse {
|
interface WorldResponse {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -11,11 +15,25 @@ interface WorldResponse {
|
|||||||
cultures: string[];
|
cultures: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldBuilder() {
|
interface Props {
|
||||||
|
prefill?: Generation | null;
|
||||||
|
onPrefillConsumed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [theme, setTheme] = useState("high fantasy");
|
const [theme, setTheme] = useState("high fantasy");
|
||||||
const [world, setWorld] = useState<WorldResponse | null>(null);
|
const [world, setWorld] = useState<WorldResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => {
|
||||||
|
const parsed = parseLlmJson<WorldResponse>(g.data);
|
||||||
|
if (parsed) {
|
||||||
|
setWorld(parsed);
|
||||||
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -38,16 +56,31 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
system: "You are a creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
system: "You are a creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||||
temperature: 0.9,
|
temperature: 0.9,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
|
ragQuery: `fantasy world ${theme} regions landmarks cultures`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const parsed = parseLlmJson<WorldResponse>(result);
|
const parsed = parseLlmJson<WorldResponse>(result);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
setWorld(parsed);
|
setWorld(parsed);
|
||||||
|
addToast("World generated", "success");
|
||||||
|
const title = extractTitle("world", result, theme);
|
||||||
|
const source = `Theme: ${theme}`;
|
||||||
|
void addGeneration({ kind: "world", title, data: result, source });
|
||||||
|
const regions = ensureArray(parsed.regions).map(flattenValue).join("; ");
|
||||||
|
const landmarks = ensureArray(parsed.landmarks).map(flattenValue).join("; ");
|
||||||
|
const conflicts = ensureArray(parsed.conflicts).map(flattenValue).join("; ");
|
||||||
|
const cultures = ensureArray(parsed.cultures).map(flattenValue).join("; ");
|
||||||
|
void addToLore(
|
||||||
|
`World: ${parsed.name || theme}`,
|
||||||
|
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||||
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
addToast(`World generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -122,7 +155,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)})
|
)}
|
||||||
|
|
||||||
{/* Conflicts */}
|
{/* Conflicts */}
|
||||||
{world.conflicts?.length > 0 && (
|
{world.conflicts?.length > 0 && (
|
||||||
@@ -138,7 +171,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)})
|
)}
|
||||||
|
|
||||||
{/* Cultures */}
|
{/* Cultures */}
|
||||||
{world.cultures?.length > 0 && (
|
{world.cultures?.length > 0 && (
|
||||||
@@ -154,7 +187,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)})
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-53
@@ -1,3 +1,8 @@
|
|||||||
|
/* ponytail: Google Fonts CSS2 import — single request, all families/weights.
|
||||||
|
TODO: download woff2 to public/fonts/ and switch to @font-face for true
|
||||||
|
offline-first. Until then, this needs network on first run. */
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;700&display=swap");
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* ─── Design Tokens ─────────────────────────────────────────── */
|
/* ─── Design Tokens ─────────────────────────────────────────── */
|
||||||
@@ -18,10 +23,10 @@
|
|||||||
--color-gold-muted: #a8872a;
|
--color-gold-muted: #a8872a;
|
||||||
--color-gold-glow: rgba(212, 175, 55, 0.25);
|
--color-gold-glow: rgba(212, 175, 55, 0.25);
|
||||||
|
|
||||||
/* Text */
|
/* Text — #7080a0 hits ~5.0:1 against --color-bg-deep (WCAG AA). */
|
||||||
--color-text-primary: #e8e0d0;
|
--color-text-primary: #e8e0d0;
|
||||||
--color-text-secondary: #8b9bb4;
|
--color-text-secondary: #8b9bb4;
|
||||||
--color-text-dim: #4a5568;
|
--color-text-dim: #7080a0;
|
||||||
|
|
||||||
/* Semantic */
|
/* Semantic */
|
||||||
--color-danger: #ef4444;
|
--color-danger: #ef4444;
|
||||||
@@ -35,57 +40,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Self-Hosted Fonts ─────────────────────────────────────── */
|
/* ─── Self-Hosted Fonts ─────────────────────────────────────── */
|
||||||
/* TODO: download woff2 files to public/fonts/ for offline-first */
|
/* ponytail: Google Fonts are loaded at the top of this file (must precede
|
||||||
/* For now, load from Google Fonts during development */
|
Tailwind per CSS spec). TODO: download woff2 to public/fonts/ and switch
|
||||||
@font-face {
|
to @font-face for true offline-first. */
|
||||||
font-family: "Cinzel";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/cinzel/v25/8vIJ7ww63mViL4j6N6R2lQ.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Cinzel";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/cinzel/v25/8vIK7ww63mVnVhR0LQRJNg.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrY.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrY.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Inter";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/inter/v19/UcCO3FwrK3iLTeHuS_nVMrY.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "JetBrains Mono";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/jetbrainsmono/v21/tDbY2o-flEEny0FZhsfKx5Q.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "JetBrains Mono";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("https://fonts.gstatic.com/s/jetbrainsmono/v21/tDbY2o-flEEny0FZhsfKx5Q.woff2") format("woff2");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Base Styles ───────────────────────────────────────────── */
|
/* ─── Base Styles ───────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -181,6 +138,36 @@ body,
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ponytail: custom tooltip on any [data-tooltip] element. The label sits to
|
||||||
|
the right of icon-only nav buttons, hidden until hover or keyboard focus. */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 12px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--color-bg-deep);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-gold-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease 0.2s;
|
||||||
|
z-index: 50;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
[data-tooltip]:hover::after,
|
||||||
|
[data-tooltip]:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Animations ──────────────────────────────────────────────── */
|
/* ─── Animations ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
@@ -198,3 +185,20 @@ body,
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ponytail: respect users who ask for less motion. framer-motion honors this
|
||||||
|
via its useReducedMotion hook, but the inline keyframes/glass-card transitions
|
||||||
|
still need this CSS guard. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
[data-tooltip]::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// ponytail: tiny event bus so tools can push data to each other without
|
||||||
|
// a shared store. Survives component unmounts via a module-level emitter.
|
||||||
|
// Use sparingly: only for cross-tool flows (Encounter → Initiative, Item →
|
||||||
|
// Lore, etc.). The History view stays the single source of truth for
|
||||||
|
// rehydrating past results.
|
||||||
|
|
||||||
|
type Handler<T> = (payload: T) => void;
|
||||||
|
|
||||||
|
interface CombatantInput {
|
||||||
|
name: string;
|
||||||
|
initiative?: number;
|
||||||
|
hp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private map = new Map<string, Set<Handler<unknown>>>();
|
||||||
|
|
||||||
|
on<T>(event: string, handler: Handler<T>): () => void {
|
||||||
|
const set = this.map.get(event) ?? new Set();
|
||||||
|
set.add(handler as Handler<unknown>);
|
||||||
|
this.map.set(event, set);
|
||||||
|
return () => {
|
||||||
|
set.delete(handler as Handler<unknown>);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<T>(event: string, payload: T): void {
|
||||||
|
const set = this.map.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const h of set) {
|
||||||
|
try {
|
||||||
|
h(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[EventBus] handler for ${event} threw:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bus = new EventBus();
|
||||||
|
|
||||||
|
// ─── Event names + payload types ────────────────────────────
|
||||||
|
|
||||||
|
export const Events = {
|
||||||
|
AddCombatants: "add-combatants",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AddCombatantsPayload = {
|
||||||
|
combatants: CombatantInput[];
|
||||||
|
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// ponytail: 5e DMG p.82 XP thresholds per character. Single source of truth
|
||||||
|
// for the encounter builder's budget math. The "Easy/Medium/Hard/Deadly"
|
||||||
|
// thresholds are *per character*; the table is indexed by character level.
|
||||||
|
|
||||||
|
export type Difficulty = "Easy" | "Medium" | "Hard" | "Deadly";
|
||||||
|
|
||||||
|
const THRESHOLDS: Record<number, Record<Difficulty, number>> = {
|
||||||
|
1: { Easy: 25, Medium: 50, Hard: 75, Deadly: 100 },
|
||||||
|
2: { Easy: 50, Medium: 100, Hard: 150, Deadly: 200 },
|
||||||
|
3: { Easy: 75, Medium: 150, Hard: 225, Deadly: 400 },
|
||||||
|
4: { Easy: 125, Medium: 250, Hard: 375, Deadly: 500 },
|
||||||
|
5: { Easy: 250, Medium: 500, Hard: 750, Deadly: 1100 },
|
||||||
|
6: { Easy: 300, Medium: 600, Hard: 900, Deadly: 1400 },
|
||||||
|
7: { Easy: 350, Medium: 750, Hard: 1100, Deadly: 1700 },
|
||||||
|
8: { Easy: 450, Medium: 900, Hard: 1400, Deadly: 2100 },
|
||||||
|
9: { Easy: 550, Medium: 1100, Hard: 1600, Deadly: 2400 },
|
||||||
|
10: { Easy: 600, Medium: 1200, Hard: 1900, Deadly: 2800 },
|
||||||
|
11: { Easy: 800, Medium: 1600, Hard: 2400, Deadly: 3600 },
|
||||||
|
12: { Easy: 1000, Medium: 2000, Hard: 3000, Deadly: 4500 },
|
||||||
|
13: { Easy: 1100, Medium: 2200, Hard: 3400, Deadly: 5100 },
|
||||||
|
14: { Easy: 1250, Medium: 2500, Hard: 3800, Deadly: 5700 },
|
||||||
|
15: { Easy: 1400, Medium: 2800, Hard: 4300, Deadly: 6400 },
|
||||||
|
16: { Easy: 1600, Medium: 3200, Hard: 4800, Deadly: 7200 },
|
||||||
|
17: { Easy: 2000, Medium: 3900, Hard: 5900, Deadly: 8800 },
|
||||||
|
18: { Easy: 2100, Medium: 4200, Hard: 6300, Deadly: 9500 },
|
||||||
|
19: { Easy: 2400, Medium: 4900, Hard: 7300, Deadly: 10900 },
|
||||||
|
20: { Easy: 2800, Medium: 5700, Hard: 8500, Deadly: 12700 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Budget {
|
||||||
|
easy: number;
|
||||||
|
medium: number;
|
||||||
|
hard: number;
|
||||||
|
deadly: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute per-difficulty XP budgets for a party. Clamps level to 1..20. */
|
||||||
|
export function encounterBudget(partyLevel: number, partySize: number): Budget {
|
||||||
|
const lvl = Math.max(1, Math.min(20, Math.round(partyLevel)));
|
||||||
|
const size = Math.max(1, Math.round(partySize));
|
||||||
|
const t = THRESHOLDS[lvl];
|
||||||
|
return {
|
||||||
|
easy: t.Easy * size,
|
||||||
|
medium: t.Medium * size,
|
||||||
|
hard: t.Hard * size,
|
||||||
|
deadly: t.Deadly * size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an XP total to a difficulty bucket. */
|
||||||
|
export function difficultyForXp(xp: number, budget: Budget): Difficulty {
|
||||||
|
if (xp >= budget.deadly) return "Deadly";
|
||||||
|
if (xp >= budget.hard) return "Hard";
|
||||||
|
if (xp >= budget.medium) return "Medium";
|
||||||
|
if (xp >= budget.easy) return "Easy";
|
||||||
|
return "Easy";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
|
||||||
|
Easy: "var(--color-success)",
|
||||||
|
Medium: "var(--color-info)",
|
||||||
|
Hard: "var(--color-gold-bright)",
|
||||||
|
Deadly: "var(--color-danger)",
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export type GenerationKind =
|
||||||
|
| "npc"
|
||||||
|
| "encounter"
|
||||||
|
| "world"
|
||||||
|
| "item"
|
||||||
|
| "quest"
|
||||||
|
| "session"
|
||||||
|
| "image";
|
||||||
|
|
||||||
|
export interface GenerationSummary {
|
||||||
|
id: number;
|
||||||
|
kind: GenerationKind;
|
||||||
|
title: string;
|
||||||
|
/** Epoch ms */
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Generation {
|
||||||
|
id: number;
|
||||||
|
kind: GenerationKind;
|
||||||
|
title: string;
|
||||||
|
/** Raw JSON string from the LLM (or data URL for image kind). */
|
||||||
|
data: string;
|
||||||
|
source: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddGenerationRequest {
|
||||||
|
kind: GenerationKind;
|
||||||
|
title: string;
|
||||||
|
data: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: thin wrapper. Every generator that succeeds calls addGeneration;
|
||||||
|
// the History view lists/gets/deletes via these helpers.
|
||||||
|
|
||||||
|
export async function addGeneration(req: AddGenerationRequest): Promise<number> {
|
||||||
|
return invoke<number>("generation_add", { req });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGenerations(
|
||||||
|
kind?: GenerationKind,
|
||||||
|
): Promise<GenerationSummary[]> {
|
||||||
|
return invoke<GenerationSummary[]>("generation_list", { kind: kind ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGeneration(id: number): Promise<Generation | null> {
|
||||||
|
return invoke<Generation | null>("generation_get", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGeneration(id: number): Promise<number> {
|
||||||
|
return invoke<number>("generation_delete", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllGenerations(): Promise<number> {
|
||||||
|
return invoke<number>("generation_delete", { id: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generationCounts(): Promise<Record<string, number>> {
|
||||||
|
const pairs = await invoke<[string, number][]>("generation_counts");
|
||||||
|
return Object.fromEntries(pairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Title extraction ────────────────────────────────────────
|
||||||
|
|
||||||
|
// ponytail: each LLM response shape has a slightly different "title" field.
|
||||||
|
// Centralize so the history list can label rows without re-parsing in the UI.
|
||||||
|
|
||||||
|
const TITLE_KEYS: Record<GenerationKind, string[]> = {
|
||||||
|
npc: ["name"],
|
||||||
|
encounter: ["title"],
|
||||||
|
world: ["name"],
|
||||||
|
item: ["name"],
|
||||||
|
quest: ["title"],
|
||||||
|
session: ["title"],
|
||||||
|
image: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a human-readable title out of an LLM JSON string. Falls back to the
|
||||||
|
* caller's `fallback` if the JSON is malformed or has no recognized field.
|
||||||
|
*/
|
||||||
|
export function extractTitle(
|
||||||
|
kind: GenerationKind,
|
||||||
|
data: string,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(data);
|
||||||
|
for (const k of TITLE_KEYS[kind]) {
|
||||||
|
const v = obj?.[k];
|
||||||
|
if (typeof v === "string" && v.trim()) return v.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — for image kind the data is a base64 PNG data URL.
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Time formatting ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Compact relative time for the history list: "just now", "3m", "2h", "5d". */
|
||||||
|
export function relativeTime(epochMs: number, now: number = Date.now()): string {
|
||||||
|
const diff = Math.max(0, now - epochMs);
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 45) return "just now";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 30) return `${d}d ago`;
|
||||||
|
const date = new Date(epochMs);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Defaults (one source of truth for kind label/icon) ──────
|
||||||
|
|
||||||
|
export const KIND_LABELS: Record<GenerationKind, string> = {
|
||||||
|
npc: "NPC",
|
||||||
|
encounter: "Encounter",
|
||||||
|
world: "World",
|
||||||
|
item: "Item",
|
||||||
|
quest: "Quest",
|
||||||
|
session: "Session",
|
||||||
|
image: "Image",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KIND_ORDER: GenerationKind[] = [
|
||||||
|
"npc",
|
||||||
|
"encounter",
|
||||||
|
"world",
|
||||||
|
"item",
|
||||||
|
"quest",
|
||||||
|
"session",
|
||||||
|
"image",
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort lore ingestion. Every generated result gets indexed into the
|
||||||
|
* local RAG store so future generations stay consistent with everything the
|
||||||
|
* DM has already created. Errors are swallowed — lore is augmentation, never
|
||||||
|
* a blocker on the generation UX.
|
||||||
|
*/
|
||||||
|
export async function addToLore(source: string, text: string): Promise<void> {
|
||||||
|
if (!source.trim() || !text.trim()) return;
|
||||||
|
try {
|
||||||
|
await invoke("rag_add", { req: { source, text } });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("addToLore failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// ponytail: 5e SRD races (9) and PHB-style backgrounds (16). The original
|
||||||
|
// generator only had 8 races and a free-text "Class" that conflated classes
|
||||||
|
// and backgrounds. These lists disambiguate.
|
||||||
|
|
||||||
|
export const RACES = [
|
||||||
|
"Human",
|
||||||
|
"Elf",
|
||||||
|
"Dwarf",
|
||||||
|
"Halfling",
|
||||||
|
"Gnome",
|
||||||
|
"Dragonborn",
|
||||||
|
"Tiefling",
|
||||||
|
"Half-Orc",
|
||||||
|
"Goliath",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ponytail: PHB backgrounds. 5e calls these "backgrounds" and they cover
|
||||||
|
// occupation, social standing, and starting equipment. They're the right
|
||||||
|
// concept for an NPC generator — most named NPCs have a background, not
|
||||||
|
// necessarily a class.
|
||||||
|
export const BACKGROUNDS = [
|
||||||
|
"Acolyte",
|
||||||
|
"Charlatan",
|
||||||
|
"Criminal",
|
||||||
|
"Entertainer",
|
||||||
|
"Folk Hero",
|
||||||
|
"Guild Artisan",
|
||||||
|
"Hermit",
|
||||||
|
"Noble",
|
||||||
|
"Outlander",
|
||||||
|
"Sage",
|
||||||
|
"Sailor",
|
||||||
|
"Soldier",
|
||||||
|
"Urchin",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ALIGNMENTS = [
|
||||||
|
"Lawful Good",
|
||||||
|
"Neutral Good",
|
||||||
|
"Chaotic Good",
|
||||||
|
"Lawful Neutral",
|
||||||
|
"True Neutral",
|
||||||
|
"Chaotic Neutral",
|
||||||
|
"Lawful Evil",
|
||||||
|
"Neutral Evil",
|
||||||
|
"Chaotic Evil",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ponytail: per-race name banks. Tiny but functional; full banks live in
|
||||||
|
// the SRD. Sufficient for a "random" button on the form.
|
||||||
|
const NAMES_BY_RACE: Record<string, { first: string[]; last: string[] }> = {
|
||||||
|
Human: {
|
||||||
|
first: ["Aldric", "Branwen", "Cassia", "Doran", "Elara", "Fendrel", "Gwynne", "Hadrian", "Isobel", "Joren"],
|
||||||
|
last: ["Ashford", "Brightwood", "Carrow", "Dunharrow", "Emberlain", "Frostvale", "Greycastle", "Holloway"],
|
||||||
|
},
|
||||||
|
Elf: {
|
||||||
|
first: ["Aelar", "Beiro", "Carric", "Drannor", "Enna", "Faelar", "Galinndan", "Hadarai", "Immeral", "Ivellios"],
|
||||||
|
last: ["Amakir", "Galanodel", "Holimion", "Liadon", "Meliamne", "Nailo", "Siannodel"],
|
||||||
|
},
|
||||||
|
Dwarf: {
|
||||||
|
first: ["Adrik", "Baern", "Brottor", "Dain", "Eberk", "Gardain", "Harbek", "Kildrak", "Morgran", "Orsik"],
|
||||||
|
last: ["Balderk", "Battlehammer", "Brawnanvil", "Dankil", "Fireforge", "Frostbeard", "Gorunn", "Holderhek", "Ironfist"],
|
||||||
|
},
|
||||||
|
Halfling: {
|
||||||
|
first: ["Alton", "Beau", "Cade", "Eldon", "Garret", "Lyle", "Milo", "Osborn", "Perrin", "Reed"],
|
||||||
|
last: ["Brushgather", "Goodbarrel", "Greenbottle", "High-hill", "Hilltopple", "Leagallow", "Tealeaf", "Thorngage"],
|
||||||
|
},
|
||||||
|
Gnome: {
|
||||||
|
first: ["Alston", "Boddynock", "Brocc", "Burgell", "Dimble", "Eldon", "Erky", "Fonkin", "Frug", "Gerbo"],
|
||||||
|
last: ["Beren", "Daergel", "Folkor", "Garrick", "Nackle", "Murnig", "Ningel", "Raulnor", "Scheppen", "Timbers"],
|
||||||
|
},
|
||||||
|
Dragonborn: {
|
||||||
|
first: ["Arjhan", "Balasar", "Bharash", "Donaar", "Ghesh", "Heskan", "Kriv", "Medrash", "Mehen", "Nadarr"],
|
||||||
|
last: ["Clethtinthiallor", "Daardendrian", "Delmirev", "Drachedandion", "Fenkenkabradon", "Kepeshkmolik", "Kerrhylon", "Kimbatuul", "Linxakasendalor", "Myastan"],
|
||||||
|
},
|
||||||
|
Tiefling: {
|
||||||
|
first: ["Akmenos", "Amnon", "Barakas", "Damakos", "Ekemon", "Iados", "Kairon", "Leucis", "Melech", "Mordai"],
|
||||||
|
last: ["Carrion", "Glory", "Hope", "Ideal", "Music", "Nowhere", "Quest", "Random", "Sorrow", "Temerity"],
|
||||||
|
},
|
||||||
|
"Half-Orc": {
|
||||||
|
first: ["Dench", "Feng", "Gell", "Henk", "Holg", "Imsh", "Keth", "Krusk", "Mhurren", "Ront"],
|
||||||
|
last: ["Bloodfang", "Cleavehowl", "Grimaxe", "Ironjaw", "Skullbelly", "Stoneheart", "Wartooth"],
|
||||||
|
},
|
||||||
|
Goliath: {
|
||||||
|
first: ["Aukan", "Eglath", "Gae-Al", "Gauthak", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith"],
|
||||||
|
last: ["Anakir", "Dukan", "Gatholos", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith", "Nalla"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function randomName(race: string): string {
|
||||||
|
const bank = NAMES_BY_RACE[race] ?? NAMES_BY_RACE.Human;
|
||||||
|
const first = bank.first[Math.floor(Math.random() * bank.first.length)];
|
||||||
|
const last = bank.last[Math.floor(Math.random() * bank.last.length)];
|
||||||
|
return `${first} ${last}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Generation, GenerationKind } from "../lib/generations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shape each generator's prefill callback expects. Tools accept `prefill`
|
||||||
|
* (a Generation) and call this hook to consume it once. The hook calls
|
||||||
|
* `onConsumed` to tell the parent to clear the pending prefill, so re-renders
|
||||||
|
* don't re-trigger the effect.
|
||||||
|
*/
|
||||||
|
export function usePrefillEffect<T extends Generation>(
|
||||||
|
prefill: T | null,
|
||||||
|
kind: GenerationKind,
|
||||||
|
onConsumed: () => void,
|
||||||
|
apply: (data: T) => void,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefill && prefill.kind === kind) {
|
||||||
|
apply(prefill as T);
|
||||||
|
onConsumed();
|
||||||
|
}
|
||||||
|
// Intentional: run only when prefill id changes (mount or rehydrate).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [prefill?.id]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user