working through the plan + UI/ UX

This commit is contained in:
itsamejms
2026-07-12 22:13:43 +01:00
parent 5b256242be
commit a24f3615e0
38 changed files with 5295 additions and 328 deletions
+85 -10
View File
@@ -18,6 +18,7 @@
3. [Quick Start — Boilerplate](#3-quick-start--boilerplate)
4. [Local LLM Integration](#4-local-llm-integration)
5. [Core Utilities & AI Features](#5-core-utilities--ai-features)
- [4.4 Image Generation via Ollama](#44-image-generation-via-ollama)
6. [Front-End Architecture (React/TS)](#6-front-end-architecture-reactts)
7. [Design System — Visual Language](#7-design-system--visual-language)
8. [UI/UX Feature Design](#8-uiux-feature-design)
@@ -297,6 +298,61 @@ pub async fn query_openai(prompt: &str) -> anyhow::Result<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.
### 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
@@ -305,11 +361,11 @@ Each module exposes a **Tauri command** (`#[tauri::command]`) that the front-end
| Module | Key AI Tasks | Sample Prompt |
|--------|-------------|---------------|
| **World Builder** | Generate continent names, climate zones, major cities, mythic legends | *"Create a fantasy continent with 3 climates and one legendary creature."* |
| **NPC Generator** | Personality traits, background story, NPC goals, relationship webs | *"Generate a charismatic dwarf blacksmith who despises elves."* |
| **World Builder** | Generate continent names, climate zones, major cities, mythic legends**+ render a parchment-style map image** | *"Create a fantasy continent with 3 climates and one legendary creature."* |
| **NPC Generator** | Personality traits, background story, NPC goals, relationship webs**+ render a portrait** | *"Generate a charismatic dwarf blacksmith who despises elves."* |
| **Quest Designer** | Multi-step plot arcs, branching outcomes, hooks | *"Outline a 4-chapter quest where the party seeks the lost crown of Rithor."* |
| **Encounter Builder** | Balanced monster groups, terrain modifiers, loot tables | *"Design an urban ambush for level-5 PCs with 3 bandits and a surprise mob."* |
| **Item Forge** | Stat tables, lore sentences, rarity levels | *"Create a +2 longsword of fire resistance that grants invisibility once per day."* |
| **Encounter Builder** | Balanced monster groups, terrain modifiers, loot tables**+ render a battle-map tile + loot item art** | *"Design an urban ambush for level-5 PCs with 3 bandits and a surprise mob."* |
| **Item Forge** | Stat tables, lore sentences, rarity levels**+ render item art (label text rendered in-image)** | *"Create a +2 longsword of fire resistance that grants invisibility once per day."* |
| **Initiative Tracker** | Turn order, HP/conditions, round timer | Manage combat state, auto-sort by initiative |
| **Random Tables** | Roll on customizable tables | Treasure, encounters, names, weather |
| **Calendar & Weather** | Fantasy calendar, seasonal events | Custom calendar system with weather |
@@ -703,6 +759,7 @@ Local LLMs are not "fire and forget." Plan for:
| **Loading state** | Model load can take 530 s on HDD/CPU. Show progress bar and cancel button |
| **GPU offloading** | Expose `n_gpu_layers` slider per model |
| **Context length** | 2k/4k/8k selector with memory warning |
| **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. |
| **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool |
| **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models |
@@ -797,9 +854,16 @@ To keep AI-generated content consistent with your world:
- [x] Encounter Builder (AI-generated via LLM, terrain selector, difficulty)
- [x] Commit and tag `v0.5.0-dm-tools`
### Milestone 6 — Lore RAG & Polish
- [ ] Add lore chunking + embedding storage (`sqlite-vec`)
- [ ] Inject retrieved lore into LLM prompts
### Milestone 6 — Lore RAG & Polish (incl. Image Generation)
- [ ] Add `generate_image` Tauri command (reuse Ollama `/api/generate`, parse singular `image` field)
- [ ] Wire ✨ generate/regenerate into NPC, Item Forge, World Builder (map), Encounter Builder (battle map + loot), Handout Renderer
- [ ] Persist generated PNGs to `$APPDATA/dm-toolkit/images/` keyed by prompt hash (cache, don't regenerate)
- [ ] Progress bar driven from NDJSON `step`/`total`; background task, non-blocking UI
- [ ] Gate image-gen UI behind macOS check; placeholder fallback elsewhere
- [ ] Add `image_model` to `LlmConfig` + settings picker (no second settings panel)
- [x] Then proceed to lore RAG & polish below:
- [x] Add lore chunking + embedding storage (SQLite + brute-force cosine; `sqlite-vec` upgrade path noted)
- [x] Inject retrieved lore into LLM prompts (shared `generate` path)
- [ ] Soundboard / ambience module
- [ ] Handout renderer (Markdown → PDF)
- [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states
@@ -830,9 +894,20 @@ A consolidated checklist covering all phases:
6. [x] Implement design system: glassmorphism, bento grid, dark blue + gold palette, Cinzel/Inter/JetBrains Mono fonts
7. [x] Implement core utilities: World Builder, NPC Generator, Encounter Builder, Dice Roller, Session Logger
8. [x] Implement DM-specific tools: Initiative Tracker, Random Tables, Calendar, Encounter Builder
9. [ ] Add lore RAG / semantic search (`sqlite-vec`)
10. [ ] Build first-run model download / license wizard
11. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build`
9. [x] Add lore RAG / semantic search (brute-force cosine over Ollama `nomic-embed-text` embeddings in SQLite; `sqlite-vec` upgrade path noted)
- [x] `RagStore` (rusqlite bundled): chunk-on-paragraph, batch embed via `/api/embed`, store as little-endian f32 BLOB
- [x] Commands `rag_add` / `rag_search` / `rag_list` / `rag_clear`
- [x] Lore injection in shared `generate` path via optional `ragQuery` field (every generator grounded for free)
- [x] `LorePanel` UI: add lore, list/clear sources, test retrieval
- [x] Wired `ragQuery` into NPC, Item Forge, World Builder; `embed_model` in settings
10. [x] Image generation via Ollama (`x/flux2-klein`) for NPCs, items, maps, loot, handouts — macOS-gated, cached to disk
- [x] `generate_image` command (reuses Ollama `/api/generate`, parses singular `image` field, disk cache by prompt hash)
- [x] `GeneratedImage` reusable component (loading / macOS-gate placeholder / ✨ regenerate)
- [x] Wired into NPC Generator (portrait) + Item Forge (item art)
- [ ] Wire into World Builder (map), Encounter Builder (battle map + loot), Handout Renderer when those surfaces ship
- [x] `image_model` in `LlmConfig` + settings picker
11. [ ] Build first-run model download / license wizard
12. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build`
---
File diff suppressed because it is too large Load Diff
+70 -1
View File
@@ -19,6 +19,18 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -739,8 +751,10 @@ dependencies = [
name = "dm-pal"
version = "0.1.0"
dependencies = [
"anyhow",
"log",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"tauri",
@@ -883,6 +897,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1389,7 +1415,16 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
@@ -1398,6 +1433,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1898,6 +1942,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2883,6 +2938,20 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust_decimal"
version = "1.42.1"
+2
View File
@@ -18,6 +18,7 @@ tauri-build = { version = "2.6.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
@@ -25,3 +26,4 @@ tauri-plugin-store = "2"
tauri-plugin-fs = "2"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
rusqlite = { version = "0.31", features = ["bundled"] }
@@ -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) {}
+203
View File
@@ -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);
}
}
+42 -5
View File
@@ -16,6 +16,10 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
// Inject retrieved lore into the system prompt so every generator stays
// consistent with the user's world bible. Shared path = every caller.
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else {
@@ -33,12 +37,13 @@ pub async fn generate_stream(
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
let client = reqwest::Client::new();
let messages = llm::build_messages(req.system.as_deref(), &req.prompt);
let temperature = req.temperature.unwrap_or(config.temperature);
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
tauri::async_runtime::spawn(async move {
// For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later
let result = if llm::is_ollama(&config.api_url) {
@@ -76,6 +81,38 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm
Ok(())
}
// ─── Lore RAG injection (shared by generate + generate_stream) ─
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
/// into the system message so generation is grounded in the user's world bible.
/// Silently degrades to the original messages on any retrieval error.
async fn inject_lore(
state: &tauri::State<'_, AppState>,
_client: &reqwest::Client,
config: &crate::llm::LlmConfig,
mut messages: Vec<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 ───────────────────────────────────────────────
async fn call_ollama(
+4 -1
View File
@@ -1 +1,4 @@
pub mod llm_commands;
pub mod llm_commands;
pub mod image_commands;
pub mod rag_commands;
pub mod generation_commands;
+58
View File
@@ -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())
}
+197
View File
@@ -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
View File
@@ -1,8 +1,11 @@
mod llm;
mod rag;
mod generations;
mod commands;
use llm::AppState;
use std::sync::Mutex;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -10,8 +13,22 @@ pub fn run() {
.plugin(tauri_plugin_log::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.manage(AppState {
config: Mutex::new(llm::LlmConfig::default()),
.setup(|app| {
let data_dir = app
.path()
.app_data_dir()
.expect("app data dir")
.join("dm-toolkit");
let rag = rag::RagStore::open(&data_dir.join("lore"))
.expect("open lore db");
let gen = generations::GenerationStore::open(&data_dir)
.expect("open generations db");
app.manage(AppState {
config: Mutex::new(llm::LlmConfig::default()),
rag,
gen,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
greet,
@@ -19,6 +36,16 @@ pub fn run() {
commands::llm_commands::generate_stream,
commands::llm_commands::get_llm_config,
commands::llm_commands::set_llm_config,
commands::image_commands::generate_image,
commands::rag_commands::rag_add,
commands::rag_commands::rag_search,
commands::rag_commands::rag_list,
commands::rag_commands::rag_clear,
commands::generation_commands::generation_add,
commands::generation_commands::generation_list,
commands::generation_commands::generation_get,
commands::generation_commands::generation_delete,
commands::generation_commands::generation_counts,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+12 -1
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::ipc::Channel;
// ─── LLM Event (for streaming) ───────────────────────────────
@@ -16,6 +15,8 @@ pub enum LlmEvent {
pub struct AppState {
pub config: Mutex<LlmConfig>,
pub rag: crate::rag::RagStore,
pub gen: crate::generations::GenerationStore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -26,6 +27,10 @@ pub struct LlmConfig {
pub temperature: f32,
pub max_tokens: u32,
pub top_p: f32,
/// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama.
pub image_model: String,
/// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`).
pub embed_model: String,
}
impl Default for LlmConfig {
@@ -38,6 +43,8 @@ impl Default for LlmConfig {
temperature: 0.7,
max_tokens: 512,
top_p: 0.9,
image_model: "x/flux2-klein:4b".to_string(),
embed_model: "nomic-embed-text".to_string(),
}
}
}
@@ -50,6 +57,10 @@ pub struct GenerateRequest {
pub system: Option<String>,
pub temperature: Option<f32>,
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 ─────────────────────────
+212
View File
@@ -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);
}
}
}
+278 -114
View File
@@ -3,17 +3,19 @@ import {
User,
Swords,
Dice5,
ScrollText,
Volume2,
Settings,
ChevronDown,
ChevronLeft,
Sparkles,
Timer,
Calendar,
Wand2,
BookOpen,
ImagePlus,
Shuffle,
Flag,
History,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Dashboard } from "./components/Dashboard";
import { SettingsPanel } from "./components/SettingsPanel";
import { NpcGenerator } from "./components/NpcGenerator";
@@ -27,7 +29,13 @@ import { WorldBuilder } from "./components/WorldBuilder";
import { ItemForge } from "./components/ItemForge";
import { QuestDesigner } from "./components/QuestDesigner";
import { Soundboard } from "./components/Soundboard";
import { LorePanel } from "./components/LorePanel";
import { ImageGenerator } from "./components/ImageGenerator";
import { ToastContainer } from "./components/Toast";
import { CommandPalette } from "./components/CommandPalette";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HistoryView } from "./components/HistoryView";
import type { Generation, GenerationKind } from "./lib/generations";
export type View =
| "dashboard"
@@ -42,28 +50,71 @@ export type View =
| "calendar"
| "quest"
| "items"
| "settings";
| "lore"
| "image"
| "settings"
| "history";
const navItems: { icon: typeof Map; label: string; view: View }[] = [
{ icon: Map, label: "World", view: "world" },
{ icon: User, label: "NPCs", view: "npcs" },
{ icon: Wand2, label: "Items", view: "items" },
{ icon: Swords, label: "Encounter", view: "encounter" },
{ icon: Dice5, label: "Dice", view: "dice" },
{ icon: ScrollText, label: "Session", view: "session" },
{ icon: Volume2, label: "Sound", view: "sound" },
// ponytail: tools that accept a prefill from history. Mirrors the kinds.
const PREFILLABLE: Partial<Record<View, GenerationKind>> = {
npcs: "npc",
encounter: "encounter",
world: "world",
items: "item",
quest: "quest",
session: "session",
image: "image",
};
type NavGroup = "session" | "world";
interface NavItem {
icon: typeof Map;
label: string;
view: View;
group: NavGroup;
shortcut?: string;
}
// ponytail: single source of truth for nav; groups drive the rail layout.
export const navItems: NavItem[] = [
{ icon: Timer, label: "Initiative", view: "initiative", group: "session", shortcut: "1" },
{ icon: Dice5, label: "Dice", view: "dice", group: "session", shortcut: "2" },
{ icon: Swords, label: "Encounter", view: "encounter", group: "session", shortcut: "3" },
{ icon: User, label: "NPCs", view: "npcs", group: "session", shortcut: "4" },
{ icon: Flag, label: "Quest", view: "quest", group: "session", shortcut: "5" },
{ icon: Volume2, label: "Sound", view: "sound", group: "session", shortcut: "6" },
{ icon: Map, label: "World", view: "world", group: "world", shortcut: "7" },
{ icon: BookOpen, label: "Lore", view: "lore", group: "world" },
{ icon: Wand2, label: "Items", view: "items", group: "world" },
{ icon: Calendar, label: "Calendar", view: "calendar", group: "world" },
{ icon: Shuffle, label: "Tables", view: "tables", group: "world" },
{ icon: ImagePlus, label: "Image", view: "image", group: "world" },
{ icon: History, label: "History", view: "history", group: "world" },
];
function renderView(view: View): React.ReactNode {
function renderView(
view: View,
prefill: Generation | null,
onPrefillConsumed: () => void,
rehydrate: (kind: GenerationKind, data: Generation) => void,
): React.ReactNode {
// ponytail: history view renders its own list/detail panes, so it doesn't
// use the prefill or wrapper pattern.
if (view === "history") {
return <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) {
case "npcs":
return <NpcGenerator />;
case "dice":
return <DiceRoller />;
case "session":
return <SessionLogger />;
case "encounter":
return <EncounterBuilder />;
case "initiative":
return <InitiativeTracker />;
case "tables":
@@ -72,12 +123,8 @@ function renderView(view: View): React.ReactNode {
return <CalendarWidget />;
case "settings":
return <SettingsPanel />;
case "world":
return <WorldBuilder />;
case "quest":
return <QuestDesigner />;
case "items":
return <ItemForge />;
case "lore":
return <LorePanel />;
case "sound":
return <Soundboard />;
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() {
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 (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
{/* 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 */}
<button
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 ${
view === "dashboard"
isOnDashboard
? "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)]"
}`}
title="Dashboard"
>
</button>
{navItems.map((item) => (
<button
key={item.label}
onClick={() => setView(item.view)}
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)]"
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`}
title={item.label}
>
<item.icon size={18} />
</button>
{/* SESSION group — in-combat tools */}
<GroupLabel>Session</GroupLabel>
{groupedNav.session.map((item) => (
<NavButton key={item.view} item={item} />
))}
<div className="w-6 h-px bg-[var(--color-border-subtle)] my-1.5" />
{/* WORLD group — prep / worldbuilding tools */}
<GroupLabel>World</GroupLabel>
{groupedNav.world.map((item) => (
<NavButton key={item.view} item={item} />
))}
{/* Spacer */}
<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 */}
<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 ${
view === "settings"
? "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="Settings"
>
<Settings size={18} />
</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"
data-tauri-drag-region
>
{isDetailView && (
{!isOnDashboard && (
<button
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"
title="Back to dashboard"
aria-label="Back to dashboard"
>
<ChevronLeft size={18} />
</button>
@@ -206,35 +323,82 @@ export default function App() {
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
DM-Pal
</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">
<ChevronDown size={12} />
<span>My Campaign</span>
</div>
<button
onClick={() => setPaletteOpen(true)}
data-tauri-no-drag
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>
{/* Main Content */}
<main className="flex-1 overflow-auto">
{view === "dashboard" ? (
<Dashboard onNavigate={setView} />
) : view === "settings" ? (
<div className="max-w-lg mx-auto p-6">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Settings
</h2>
<SettingsPanel />
</div>
) : isDetailView ? (
<div className="max-w-2xl mx-auto p-6">
<div className="glass-card p-6">
{renderView(view)}
<ErrorBoundary>
{isOnDashboard ? (
<Dashboard onNavigate={setView} />
) : view === "history" ? (
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
) : (
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}>
{view === "settings" ? (
<div className="glass-card p-6">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Settings
</h2>
<SettingsPanel />
</div>
) : (
<div className="glass-card p-6">
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
</div>
)}
</div>
</div>
) : null}
)}
</ErrorBoundary>
</main>
</div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={(v) => {
setView(v);
setPaletteOpen(false);
}}
/>
{/* Toast notifications */}
<ToastContainer />
</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>
);
}
+2 -1
View File
@@ -33,7 +33,8 @@ export function BentoCard({
return (
<motion.div
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 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
+148
View File
@@ -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>
);
}
+46 -13
View File
@@ -10,9 +10,23 @@ import {
Calendar,
Maximize2,
Wand2,
ImagePlus,
BookOpen,
} from "lucide-react";
import { BentoCard } from "./BentoCard";
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 {
onNavigate: (view: View) => void;
@@ -185,19 +199,38 @@ export function Dashboard({ onNavigate }: DashboardProps) {
</button>
</div>
</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>
);
}
// 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";
+207 -33
View File
@@ -1,18 +1,36 @@
import { useState, useCallback } from "react";
import { useEffect, useState, useCallback } from "react";
import { useToast } from "./Toast";
interface DieResult {
notation: string;
rolls: number[];
total: number;
modifier: number;
advantage?: "adv" | "dis" | null;
keptRoll?: number;
}
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 {
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+)?$/);
if (!match) return null;
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() {
const [input, setInput] = useState("1d20");
const [results, setResults] = useState<DieResult[]>([]);
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 parsed = parseNotation(input);
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
const roll = useCallback(
(overrideNotation?: string) => {
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));
const sum = rolls.reduce((a, b) => a + b, 0) + parsed.modifier;
const result: DieResult = {
notation: input.trim(),
rolls,
total: sum,
};
// For d20 + adv/dis, roll an extra die to compare.
const baseCount = parsed.count;
const isD20AdvDis = parsed.sides === 20 && baseCount === 1 && mode !== "normal";
const rollCount = isD20AdvDis ? 2 : baseCount;
const rawRolls = Array.from({ length: rollCount }, () => rollDie(parsed.sides));
const { rolls, kept } = applyMode(rawRolls, parsed.sides, mode);
const sum = kept + parsed.modifier;
setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum);
}, [input]);
const result: DieResult = {
notation: mode !== "normal" && parsed.sides === 20
? `${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) => {
if (e.key === "Enter") roll();
};
setResults((prev) => [result, ...prev].slice(0, 50));
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 (
<div className="flex flex-col gap-3 h-full">
{/* Big result display */}
<div className="flex items-center justify-center py-3">
<span className="font-mono text-3xl font-bold text-[var(--color-gold-bright)]">
<div className="flex flex-col items-center justify-center py-3 gap-1">
<span className="font-mono text-4xl font-bold text-[var(--color-gold-bright)]">
{lastTotal !== null ? lastTotal : "—"}
</span>
{lastBreakdown && (
<span className="font-mono text-[11px] text-[var(--color-text-secondary)]">
{formatBreakdown(lastBreakdown)}
</span>
)}
</div>
{/* 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"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onKeyDown={(e) => e.key === "Enter" && roll()}
placeholder="2d6+3"
/>
<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"
title="Roll (Space)"
>
Roll
</button>
</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 */}
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((d) => (
<button
key={d}
onClick={() => {
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);
}}
onClick={() => roll(`1${d}`)}
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}
@@ -93,19 +212,62 @@ export function DiceRoller() {
))}
</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 */}
<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">
{results.map((r, i) => (
<div
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"
>
<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.rolls.length > 1 && ` [${r.rolls.join(", ")}]`}
</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}
</span>
</div>
@@ -114,4 +276,16 @@ 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]}`;
}
+306 -8
View File
@@ -1,6 +1,12 @@
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 { 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 {
monsters: string[];
@@ -9,13 +15,178 @@ interface Encounter {
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 [partySize, setPartySize] = useState(4);
const [terrain, setTerrain] = useState("Forest");
const [encounter, setEncounter] = useState<Encounter | null>(null);
const [loading, setLoading] = useState(false);
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() {
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);
if (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 {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Encounter generation failed: ${e}`, "error");
}
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 (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
<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)}
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) => (
<option key={t} value={t}>{t}</option>
{TERRAINS.map((t) => (
<option key={t.value} value={t.value}>
{t.icon} {t.value}
</option>
))}
</select>
</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
onClick={generate}
disabled={loading}
@@ -107,11 +327,18 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
{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="flex items-center justify-between">
<span className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
{encounter.difficulty} Encounter
<span
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 className="text-[var(--color-text-dim)] text-[10px]">
Level {partyLevel} × {partySize}
~{computedXp} XP · L{partyLevel}×{partySize}
</span>
</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>
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
</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>
);
}
}
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,
};
+45
View File
@@ -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;
}
}
+86
View File
@@ -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;
}
+742
View File
@@ -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 = `![${title}](data:image/png;base64,…)\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>
);
}
+140
View File
@@ -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>
);
}
+185 -21
View File
@@ -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 {
id: string;
@@ -7,6 +9,7 @@ interface Combatant {
hp: number;
maxHp: number;
conditions: string[];
notes?: string;
}
const CONDITIONS = [
@@ -24,6 +27,46 @@ export function InitiativeTracker() {
const [hp, setHp] = useState(10);
const [activeId, setActiveId] = useState<string | null>(null);
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(() => {
if (!name.trim()) return;
@@ -55,16 +98,45 @@ export function InitiativeTracker() {
? c.conditions.filter((cn) => cn !== 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) => {
setCombatants((prev) =>
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([]);
setActiveId(null);
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 (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
{/* Round indicator */}
@@ -99,19 +182,57 @@ export function InitiativeTracker() {
<div className="flex gap-1">
<button
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
</button>
<button
onClick={reset}
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"
>
Reset
</button>
{confirmReset ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-danger)]">Clear all?</span>
<button
onClick={reset}
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>
{/* 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 */}
<div className="flex gap-1.5">
<input
@@ -120,6 +241,7 @@ export function InitiativeTracker() {
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCombatant()}
placeholder="Name"
aria-label="Combatant name"
/>
<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"
@@ -127,6 +249,7 @@ export function InitiativeTracker() {
value={initBonus}
onChange={(e) => setInitBonus(parseInt(e.target.value) || 0)}
title="Initiative bonus"
aria-label="Initiative bonus"
/>
<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"
@@ -134,6 +257,7 @@ export function InitiativeTracker() {
value={hp}
onChange={(e) => setHp(parseInt(e.target.value) || 1)}
title="Max HP"
aria-label="Max HP"
/>
<button
onClick={addCombatant}
@@ -143,10 +267,19 @@ export function InitiativeTracker() {
</button>
</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 */}
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
{combatants.map((c) => {
const isActive = c.id === activeId;
const isNextUp = c.id === nextUpId && !isActive;
const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0;
const hpColor =
hpPct > 0.5
@@ -161,22 +294,36 @@ export function InitiativeTracker() {
className={`rounded-lg p-2 text-xs transition-all cursor-default ${
isActive
? "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 gap-2 min-w-0">
<span className="font-mono text-[var(--color-text-dim)] w-6 text-right shrink-0">
{c.initiative}
</span>
<input
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)]"
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">
{c.name}
</span>
{isNextUp && (
<span className="text-[9px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
next
</span>
)}
</div>
<button
onClick={() => removeCombatant(c.id)}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] transition-colors shrink-0 cursor-pointer"
title="Remove"
aria-label={`Remove ${c.name}`}
>
×
</button>
@@ -193,8 +340,23 @@ export function InitiativeTracker() {
<span className="font-mono text-[10px] shrink-0" style={{ color: hpColor }}>
{c.hp}/{c.maxHp}
</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-success)] 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" 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>
{/* Conditions */}
@@ -205,6 +367,7 @@ export function InitiativeTracker() {
key={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"
title={`Remove ${cn}`}
>
{cn}
</span>
@@ -212,13 +375,14 @@ export function InitiativeTracker() {
</div>
)}
{/* Add condition */}
{/* Add condition — show ALL 13, not 5. */}
<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
key={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"
title={`Add ${cn}`}
>
+{cn.slice(0, 3)}
</button>
@@ -230,4 +394,4 @@ export function InitiativeTracker() {
</div>
</div>
);
}
}
+60 -10
View File
@@ -1,6 +1,11 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
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 {
name: string;
@@ -14,7 +19,12 @@ interface ItemResponse {
const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"];
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 [itemType, setItemType] = useState("Wondrous Item");
const [prompt, setPrompt] = useState("");
@@ -22,6 +32,21 @@ export function ItemForge() {
const [rawResponse, setRawResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
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() {
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.",
temperature: 0.85,
max_tokens: 500,
ragQuery: `${rarity} ${itemType} ${prompt}`,
},
});
@@ -57,11 +83,21 @@ The JSON must have exactly these keys:
if (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 {
setError("Could not parse LLM response as JSON. Raw response shown below.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Item generation failed: ${e}`, "error");
}
setLoading(false);
}
@@ -132,15 +168,29 @@ The JSON must have exactly these keys:
{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="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 className="flex gap-3">
<div className="shrink-0 w-24">
<GeneratedImage prompt={artPrompt} nonce={artNonce} aspect="aspect-square" />
<button
onClick={() => setArtNonce((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 item art"
>
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>
+176
View File
@@ -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>
);
}
+88 -22
View File
@@ -1,6 +1,13 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
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 {
bio: string;
@@ -8,17 +15,34 @@ interface NpcResponse {
goals: string[];
}
const RACES = ["Human", "Elf", "Dwarf", "Halfling", "Orc", "Tiefling", "Dragonborn", "Gnome"];
const ALIGNMENTS = ["Lawful Good", "Neutral Good", "Chaotic Good", "Lawful Neutral", "True Neutral", "Chaotic Neutral", "Lawful Evil", "Neutral Evil", "Chaotic Evil"];
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function NpcGenerator() {
export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [race, setRace] = useState("Dwarf");
const [charClass, setCharClass] = useState("Artisan");
const [background, setBackground] = useState("Guild Artisan");
const [alignment, setAlignment] = useState("Chaotic Good");
const [name, setName] = useState("");
const [npc, setNpc] = useState<NpcResponse | null>(null);
const [loading, setLoading] = useState(false);
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() {
setLoading(true);
@@ -28,7 +52,7 @@ export function NpcGenerator() {
const prompt = `Create a detailed NPC for a fantasy RPG:
${name ? `Name: ${name}` : "Name: (generate one)"}
Race: ${race}
Class: ${charClass}
Background: ${background}
Alignment: ${alignment}
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 }.`;
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
const parsed = parseLlmJson<NpcResponse>(result);
if (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 {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`NPC generation failed: ${e}`, "error");
}
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="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Name</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={name}
onChange={(e) => setName(e.target.value)}
placeholder="Random"
/>
<div className="flex gap-1.5">
<input
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"
value={name}
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 className="flex flex-col gap-1">
<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>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Class</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={charClass}
onChange={(e) => setCharClass(e.target.value)}
/>
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Background</label>
<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)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm cursor-pointer"
value={background}
onChange={(e) => setBackground(e.target.value)}
>
{BACKGROUNDS.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<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 && (
<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">
{name || "NPC"}
</h3>
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
<div className="flex gap-3">
<div className="shrink-0 w-24">
<GeneratedImage prompt={portraitPrompt} nonce={portraitNonce} aspect="aspect-square" />
<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>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
<div className="flex flex-wrap gap-1 mt-1">
+33 -1
View File
@@ -1,6 +1,10 @@
import { parseLlmJson } from "../lib/llm-parse";
import { useState } from "react";
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 {
title: string;
@@ -16,13 +20,28 @@ interface QuestResponse {
reward: string;
}
export function QuestDesigner() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
const [theme, setTheme] = useState("");
const [level, setLevel] = useState(5);
const [quest, setQuest] = useState<QuestResponse | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
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() {
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);
if (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 {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Quest generation failed: ${e}`, "error");
}
setLoading(false);
}
+23 -1
View File
@@ -1,12 +1,27 @@
import { useState } from "react";
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 [summary, setSummary] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
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() {
if (!notes.trim()) return;
@@ -34,8 +49,15 @@ export function SessionLogger() {
},
});
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) {
setError(String(e));
addToast(`Summary failed: ${e}`, "error");
}
setLoading(false);
}
+66 -27
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useToast } from "./Toast";
interface LlmConfig {
api_url: string;
@@ -8,21 +9,29 @@ interface LlmConfig {
temperature: number;
max_tokens: number;
top_p: number;
image_model: string;
embed_model: string;
}
export function SettingsPanel() {
const [config, setConfig] = useState<LlmConfig | null>(null);
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const [showKey, setShowKey] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast();
async function loadConfig() {
try {
const c = await invoke<LlmConfig>("get_llm_config");
setConfig(c);
} catch (e) {
console.error("Failed to load config:", e);
}
}
// ponytail: auto-load on mount so users don't see a gate before the form.
useEffect(() => {
(async () => {
try {
const c = await invoke<LlmConfig>("get_llm_config");
setConfig(c);
} catch (e) {
setError(String(e));
}
})();
}, []);
async function saveConfig() {
if (!config) return;
@@ -31,24 +40,20 @@ export function SettingsPanel() {
await invoke("set_llm_config", { config });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
addToast("Settings saved", "success");
} catch (e) {
console.error("Failed to save config:", e);
addToast(`Save failed: ${e}`, "error");
}
setLoading(false);
}
if (!config) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-[var(--color-text-secondary)] text-sm">
Configure your LLM connection
</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 className="flex flex-col items-center justify-center h-full gap-3">
<p className="text-[var(--color-text-secondary)] text-sm">Loading settings</p>
{error && (
<p className="text-[var(--color-danger)] text-xs">Failed to load: {error}</p>
)}
</div>
);
}
@@ -71,13 +76,23 @@ export function SettingsPanel() {
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
API Key (optional)
</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)]"
type="password"
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-... (leave blank for local)"
/>
<div className="flex gap-1.5">
<input
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)]"
type={showKey ? "text" : "password"}
value={config.api_key}
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 className="flex flex-col gap-1">
@@ -92,6 +107,30 @@ export function SettingsPanel() {
/>
</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="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
+37 -4
View File
@@ -1,6 +1,10 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
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 {
name: string;
@@ -11,11 +15,25 @@ interface WorldResponse {
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 [world, setWorld] = useState<WorldResponse | null>(null);
const [loading, setLoading] = useState(false);
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() {
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.",
temperature: 0.9,
max_tokens: 600,
ragQuery: `fantasy world ${theme} regions landmarks cultures`,
},
});
const parsed = parseLlmJson<WorldResponse>(result);
if (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 {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`World generation failed: ${e}`, "error");
}
setLoading(false);
}
@@ -122,7 +155,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
))}
</div>
</div>
)})
)}
{/* Conflicts */}
{world.conflicts?.length > 0 && (
@@ -138,7 +171,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
))}
</div>
</div>
)})
)}
{/* Cultures */}
{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>
+57 -53
View File
@@ -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";
/* ─── Design Tokens ─────────────────────────────────────────── */
@@ -18,10 +23,10 @@
--color-gold-muted: #a8872a;
--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-secondary: #8b9bb4;
--color-text-dim: #4a5568;
--color-text-dim: #7080a0;
/* Semantic */
--color-danger: #ef4444;
@@ -35,57 +40,9 @@
}
/* ─── Self-Hosted Fonts ─────────────────────────────────────── */
/* TODO: download woff2 files to public/fonts/ for offline-first */
/* For now, load from Google Fonts during development */
@font-face {
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");
}
/* ponytail: Google Fonts are loaded at the top of this file (must precede
Tailwind per CSS spec). TODO: download woff2 to public/fonts/ and switch
to @font-face for true offline-first. */
/* ─── Base Styles ───────────────────────────────────────────── */
@@ -181,6 +138,36 @@ body,
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 ──────────────────────────────────────────────── */
@keyframes slideIn {
@@ -197,4 +184,21 @@ body,
@keyframes fadeIn {
from { opacity: 0; }
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;
}
}
+52
View File
@@ -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;
};
+64
View File
@@ -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)",
};
+140
View File
@@ -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",
];
+16
View File
@@ -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);
}
}
+95
View File
@@ -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}`;
}
+24
View File
@@ -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]);
}