working through the plan + UI/ UX
This commit is contained in:
+85
-10
@@ -18,6 +18,7 @@
|
||||
3. [Quick Start — Boilerplate](#3-quick-start--boilerplate)
|
||||
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 5–30 s on HDD/CPU. Show progress bar and cancel button |
|
||||
| **GPU offloading** | Expose `n_gpu_layers` slider per model |
|
||||
| **Context length** | 2k/4k/8k selector with memory warning |
|
||||
| **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. |
|
||||
| **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool |
|
||||
| **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models |
|
||||
|
||||
@@ -797,9 +854,16 @@ To keep AI-generated content consistent with your world:
|
||||
- [x] Encounter Builder (AI-generated via LLM, terrain selector, difficulty)
|
||||
- [x] Commit and tag `v0.5.0-dm-tools`
|
||||
|
||||
### Milestone 6 — Lore RAG & Polish
|
||||
- [ ] Add lore chunking + embedding storage (`sqlite-vec`)
|
||||
- [ ] Inject retrieved lore into LLM prompts
|
||||
### Milestone 6 — Lore RAG & Polish (incl. Image Generation)
|
||||
- [ ] Add `generate_image` Tauri command (reuse Ollama `/api/generate`, parse singular `image` field)
|
||||
- [ ] Wire ✨ generate/regenerate into NPC, Item Forge, World Builder (map), Encounter Builder (battle map + loot), Handout Renderer
|
||||
- [ ] Persist generated PNGs to `$APPDATA/dm-toolkit/images/` keyed by prompt hash (cache, don't regenerate)
|
||||
- [ ] Progress bar driven from NDJSON `step`/`total`; background task, non-blocking UI
|
||||
- [ ] Gate image-gen UI behind macOS check; placeholder fallback elsewhere
|
||||
- [ ] Add `image_model` to `LlmConfig` + settings picker (no second settings panel)
|
||||
- [x] Then proceed to lore RAG & polish below:
|
||||
- [x] Add lore chunking + embedding storage (SQLite + brute-force cosine; `sqlite-vec` upgrade path noted)
|
||||
- [x] Inject retrieved lore into LLM prompts (shared `generate` path)
|
||||
- [ ] Soundboard / ambience module
|
||||
- [ ] Handout renderer (Markdown → PDF)
|
||||
- [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states
|
||||
@@ -830,9 +894,20 @@ A consolidated checklist covering all phases:
|
||||
6. [x] Implement design system: glassmorphism, bento grid, dark blue + gold palette, Cinzel/Inter/JetBrains Mono fonts
|
||||
7. [x] Implement core utilities: World Builder, NPC Generator, Encounter Builder, Dice Roller, Session Logger
|
||||
8. [x] Implement DM-specific tools: Initiative Tracker, Random Tables, Calendar, Encounter Builder
|
||||
9. [ ] Add lore RAG / semantic search (`sqlite-vec`)
|
||||
10. [ ] Build first-run model download / license wizard
|
||||
11. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build`
|
||||
9. [x] Add lore RAG / semantic search (brute-force cosine over Ollama `nomic-embed-text` embeddings in SQLite; `sqlite-vec` upgrade path noted)
|
||||
- [x] `RagStore` (rusqlite bundled): chunk-on-paragraph, batch embed via `/api/embed`, store as little-endian f32 BLOB
|
||||
- [x] Commands `rag_add` / `rag_search` / `rag_list` / `rag_clear`
|
||||
- [x] Lore injection in shared `generate` path via optional `ragQuery` field (every generator grounded for free)
|
||||
- [x] `LorePanel` UI: add lore, list/clear sources, test retrieval
|
||||
- [x] Wired `ragQuery` into NPC, Item Forge, World Builder; `embed_model` in settings
|
||||
10. [x] Image generation via Ollama (`x/flux2-klein`) for NPCs, items, maps, loot, handouts — macOS-gated, cached to disk
|
||||
- [x] `generate_image` command (reuses Ollama `/api/generate`, parses singular `image` field, disk cache by prompt hash)
|
||||
- [x] `GeneratedImage` reusable component (loading / macOS-gate placeholder / ✨ regenerate)
|
||||
- [x] Wired into NPC Generator (portrait) + Item Forge (item art)
|
||||
- [ ] Wire into World Builder (map), Encounter Builder (battle map + loot), Handout Renderer when those surfaces ship
|
||||
- [x] `image_model` in `LlmConfig` + settings picker
|
||||
11. [ ] Build first-run model download / license wizard
|
||||
12. [ ] Add tests, CI (`tauri-action`), code signing, updater, then package with `npm run tauri build`
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+70
-1
@@ -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"
|
||||
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,203 @@
|
||||
use crate::llm::AppState;
|
||||
use serde::Deserialize;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use tauri::Manager;
|
||||
|
||||
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImageRequest {
|
||||
pub prompt: String,
|
||||
/// Override the configured image model for this call.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// One line of Ollama's NDJSON image-generation response.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaImageLine {
|
||||
#[serde(default)]
|
||||
done: bool,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
/// Generate (or fetch from disk cache) an image for `prompt` via the configured
|
||||
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`.
|
||||
///
|
||||
/// Ollama image models are macOS-only today; on other platforms we return an error
|
||||
/// so the front-end can fall back to a placeholder instead of a confusing timeout.
|
||||
#[tauri::command]
|
||||
pub async fn generate_image(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
req: ImageRequest,
|
||||
) -> Result<String, String> {
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
return Err("image generation is macOS-only via Ollama (for now)".into());
|
||||
}
|
||||
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let model = req.model.unwrap_or(config.image_model.clone());
|
||||
|
||||
let cache_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("dm-toolkit")
|
||||
.join("images");
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
// Stable cache key over model + prompt. Regenerating with a tweaked prompt
|
||||
// produces a new file; identical prompt reuses the cached PNG.
|
||||
let mut hasher = DefaultHasher::new();
|
||||
model.hash(&mut hasher);
|
||||
req.prompt.hash(&mut hasher);
|
||||
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
|
||||
|
||||
// Cache hit: return the stored PNG without calling the model.
|
||||
if cache_path.exists() {
|
||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||
return Ok(data_url(&bytes));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"prompt": req.prompt,
|
||||
"stream": false,
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {e}"))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let text = res.text().await.unwrap_or_default();
|
||||
return Err(format!("image error {status}: {text}"));
|
||||
}
|
||||
|
||||
// Ollama returns newline-delimited JSON even with stream:false for image models;
|
||||
// the final line with `done: true` carries the singular `image` base64 field.
|
||||
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
|
||||
let mut png_b64: Option<String> = None;
|
||||
for line in body_text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(parsed) = serde_json::from_str::<OllamaImageLine>(line) {
|
||||
if let Some(b64) = parsed.image {
|
||||
png_b64 = Some(b64);
|
||||
if parsed.done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?;
|
||||
|
||||
// Decode + persist to cache, then return a data URL.
|
||||
let png_bytes = base64_decode(&b64)?;
|
||||
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
|
||||
Ok(data_url(&png_bytes))
|
||||
}
|
||||
|
||||
fn data_url(png: &[u8]) -> String {
|
||||
// ponytail: no base64 crate dep — a 30-line encoder.
|
||||
let table: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((png.len() + 2) / 3 * 4);
|
||||
let mut chunks = png.chunks_exact(3);
|
||||
for c in &mut chunks {
|
||||
let n = (c[0] as usize) << 16 | (c[1] as usize) << 8 | c[2] as usize;
|
||||
out.push(table[(n >> 18) & 63] as char);
|
||||
out.push(table[(n >> 12) & 63] as char);
|
||||
out.push(table[(n >> 6) & 63] as char);
|
||||
out.push(table[n & 63] as char);
|
||||
}
|
||||
let rem = chunks.remainder();
|
||||
match rem.len() {
|
||||
1 => {
|
||||
let n = (rem[0] as usize) << 16;
|
||||
out.push(table[(n >> 18) & 63] as char);
|
||||
out.push(table[(n >> 12) & 63] as char);
|
||||
out.push('=');
|
||||
out.push('=');
|
||||
}
|
||||
2 => {
|
||||
let n = (rem[0] as usize) << 16 | (rem[1] as usize) << 8;
|
||||
out.push(table[(n >> 18) & 63] as char);
|
||||
out.push(table[(n >> 12) & 63] as char);
|
||||
out.push(table[(n >> 6) & 63] as char);
|
||||
out.push('=');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
format!("data:image/png;base64,{out}")
|
||||
}
|
||||
|
||||
/// Minimal standard base64 decoder (no extra dependency).
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
fn val(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'A'..=b'Z' => Some(c - b'A'),
|
||||
b'a'..=b'z' => Some(c - b'a' + 26),
|
||||
b'0'..=b'9' => Some(c - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let input = input.trim();
|
||||
let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if bytes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||
let mut buf: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in &bytes {
|
||||
if b == b'=' {
|
||||
break;
|
||||
}
|
||||
let v = val(b).ok_or_else(|| format!("invalid base64 char: {b}"))? as u32;
|
||||
buf = (buf << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_base64() {
|
||||
let data = b"hello world \x00\xff\x10";
|
||||
let url = data_url(data);
|
||||
let b64 = url.strip_prefix("data:image/png;base64,").unwrap();
|
||||
let decoded = base64_decode(b64).unwrap();
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_is_stable() {
|
||||
// sanity: same inputs → same filename shape (16 hex digits)
|
||||
let mut h = DefaultHasher::new();
|
||||
"x/flux2-klein:4b".hash(&mut h);
|
||||
"prompt".hash(&mut h);
|
||||
let s = format!("{:016x}", h.finish());
|
||||
assert_eq!(s.len(), 16);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
||||
let temperature = req.temperature.unwrap_or(config.temperature);
|
||||
let 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(
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub mod llm_commands;
|
||||
pub mod llm_commands;
|
||||
pub mod image_commands;
|
||||
pub mod rag_commands;
|
||||
pub mod generation_commands;
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::llm::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RagAddRequest {
|
||||
pub source: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RagHit {
|
||||
pub text: String,
|
||||
pub source: String,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RagSource {
|
||||
pub source: String,
|
||||
pub chunks: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rag_add(state: tauri::State<'_, AppState>, req: RagAddRequest) -> Result<usize, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
state.rag.add_document(&config, &req.source, &req.text).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rag_search(
|
||||
state: tauri::State<'_, AppState>,
|
||||
query: String,
|
||||
top_k: Option<usize>,
|
||||
) -> Result<Vec<RagHit>, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let hits = state
|
||||
.rag
|
||||
.search(&config, &query, top_k.unwrap_or(4))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(hits.into_iter().map(|(text, source, score)| RagHit { text, source, score }).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, String> {
|
||||
state
|
||||
.rag
|
||||
.list_sources()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(source, chunks)| Ok(RagSource { source, chunks }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option<String>) -> Result<(), String> {
|
||||
state.rag.clear(source.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use rusqlite::{params, params_from_iter, types::Value, Connection};
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ponytail: a separate SQLite file from lore.db so a corrupt generations
|
||||
// store can't lose the user's world bible. Same `bundled` rusqlite feature.
|
||||
// Generations are append-only in v1: no schema migrations planned.
|
||||
|
||||
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS generations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
source TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gen_kind ON generations(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_gen_created ON generations(created_at DESC);";
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Generation {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub data: String,
|
||||
pub source: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerationSummary {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
/// Epoch ms — for relative-time display in the UI.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
pub struct GenerationStore {
|
||||
db: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl GenerationStore {
|
||||
pub fn open(dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let path = dir.join("generations.db");
|
||||
let db = Connection::open(path)?;
|
||||
db.execute_batch(SCHEMA)?;
|
||||
Ok(Self { db: Mutex::new(db) })
|
||||
}
|
||||
|
||||
/// Append a generation. `data` is the raw JSON string from the LLM
|
||||
/// (already-validated by the caller), `source` is the human-readable
|
||||
/// input (theme, name, etc.) so the history list can show context.
|
||||
pub fn add(
|
||||
&self,
|
||||
kind: &str,
|
||||
title: &str,
|
||||
data: &str,
|
||||
source: Option<&str>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
db.execute(
|
||||
"INSERT INTO generations (kind, title, data, source, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
params![kind, title, data, source, now],
|
||||
)?;
|
||||
Ok(db.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// All generations, newest first, optionally filtered by kind.
|
||||
pub fn list(&self, kind: Option<&str>) -> anyhow::Result<Vec<GenerationSummary>> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
let (sql, params): (&str, Vec<Value>) = match kind {
|
||||
Some(k) => (
|
||||
"SELECT id, kind, title, created_at FROM generations WHERE kind = ?1 ORDER BY created_at DESC",
|
||||
vec![Value::from(k.to_string())],
|
||||
),
|
||||
None => (
|
||||
"SELECT id, kind, title, created_at FROM generations ORDER BY created_at DESC",
|
||||
vec![],
|
||||
),
|
||||
};
|
||||
let mut stmt = db.prepare(sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(params.iter()), |r| {
|
||||
Ok(GenerationSummary {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Full row including data + source. Used when rehydrating into a tool.
|
||||
pub fn get(&self, id: i64) -> anyhow::Result<Option<Generation>> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT id, kind, title, data, source, created_at FROM generations WHERE id = ?",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
if let Some(r) = rows.next()? {
|
||||
Ok(Some(Generation {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
data: r.get(3)?,
|
||||
source: r.get(4)?,
|
||||
created_at: r.get(5)?,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete one row, or all rows if id is None.
|
||||
pub fn delete(&self, id: Option<i64>) -> anyhow::Result<usize> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
let n = match id {
|
||||
Some(i) => db.execute("DELETE FROM generations WHERE id = ?", params![i])?,
|
||||
None => db.execute("DELETE FROM generations", [])?,
|
||||
};
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Count by kind — for the dashboard "X NPCs, Y encounters" pill.
|
||||
pub fn counts(&self) -> anyhow::Result<Vec<(String, i64)>> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT kind, COUNT(*) FROM generations GROUP BY kind ORDER BY kind",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp() -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dm-pal-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let dir = tmp();
|
||||
let store = GenerationStore::open(&dir).unwrap();
|
||||
let id = store
|
||||
.add("npc", "Thorin Stonefist", r#"{"name":"Thorin"}"#, Some("Dwarf Fighter"))
|
||||
.unwrap();
|
||||
let g = store.get(id).unwrap().unwrap();
|
||||
assert_eq!(g.kind, "npc");
|
||||
assert_eq!(g.title, "Thorin Stonefist");
|
||||
assert_eq!(g.source.as_deref(), Some("Dwarf Fighter"));
|
||||
assert!(g.created_at > 0);
|
||||
|
||||
let list = store.list(None).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].id, id);
|
||||
|
||||
let npcs = store.list(Some("npc")).unwrap();
|
||||
assert_eq!(npcs.len(), 1);
|
||||
let items = store.list(Some("item")).unwrap();
|
||||
assert_eq!(items.len(), 0);
|
||||
|
||||
let counts = store.counts().unwrap();
|
||||
assert_eq!(counts, vec![("npc".to_string(), 1)]);
|
||||
|
||||
assert_eq!(store.delete(Some(id)).unwrap(), 1);
|
||||
assert!(store.get(id).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all() {
|
||||
let dir = tmp();
|
||||
let store = GenerationStore::open(&dir).unwrap();
|
||||
store.add("a", "x", "{}", None).unwrap();
|
||||
store.add("b", "y", "{}", None).unwrap();
|
||||
assert_eq!(store.delete(None).unwrap(), 2);
|
||||
assert_eq!(store.list(None).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
+29
-2
@@ -1,8 +1,11 @@
|
||||
mod llm;
|
||||
mod 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");
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { navItems, type View } from "../App";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (view: View) => void;
|
||||
}
|
||||
|
||||
interface PaletteItem {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
view: View;
|
||||
}
|
||||
|
||||
// ponytail: command palette is just nav items + the dashboard. A separate
|
||||
// index of generated NPCs/items/quests would need persistence first, which
|
||||
// is P1; ship the jump-to-tool now and extend later.
|
||||
function buildItems(): PaletteItem[] {
|
||||
const items: PaletteItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", hint: "Home", view: "dashboard" },
|
||||
...navItems.map((n) => ({
|
||||
id: n.view,
|
||||
label: n.label,
|
||||
hint: n.shortcut ?? (n.group === "session" ? "Session" : "World"),
|
||||
view: n.view,
|
||||
})),
|
||||
{ id: "settings", label: "Settings", hint: "⌘,", view: "settings" },
|
||||
];
|
||||
return items;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const items = useMemo(() => buildItems(), []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((it) => it.label.toLowerCase().includes(q));
|
||||
}, [items, query]);
|
||||
|
||||
// Focus input + reset on open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActiveIdx(0);
|
||||
// requestAnimationFrame avoids the focus racing the open transition.
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Keep the active row in view as the user arrows around.
|
||||
useEffect(() => {
|
||||
if (activeIdx >= filtered.length) setActiveIdx(0);
|
||||
}, [filtered.length, activeIdx]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => (filtered.length === 0 ? 0 : (i + 1) % filtered.length));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) =>
|
||||
filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length,
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const item = filtered[activeIdx];
|
||||
if (item) onSelect(item.view);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div
|
||||
className="relative w-full max-w-md glass-card p-2 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIdx(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Jump anywhere…"
|
||||
aria-label="Search tools"
|
||||
className="w-full bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
|
||||
/>
|
||||
<div className="border-t border-[var(--color-border-subtle)] mt-1 max-h-72 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-[var(--color-text-dim)]">No matches.</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
<button
|
||||
key={it.id}
|
||||
onClick={() => onSelect(it.view)}
|
||||
onMouseEnter={() => setActiveIdx(i)}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm rounded cursor-pointer transition-colors ${
|
||||
i === activeIdx
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "text-[var(--color-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span>{it.label}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{it.hint && (
|
||||
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)]">
|
||||
{it.hint}
|
||||
</kbd>
|
||||
)}
|
||||
<ArrowRight
|
||||
size={12}
|
||||
className={
|
||||
i === activeIdx ? "text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)]"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 pt-2 text-[10px] text-[var(--color-text-dim)]">
|
||||
<span>↑↓ navigate · ↵ open</span>
|
||||
<span>esc to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,23 @@ import {
|
||||
Calendar,
|
||||
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
@@ -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]}`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// ponytail: a tiny class component. New dep (`react-error-boundary`) is 4KB
|
||||
// for a feature that's 20 lines. Roll it by hand.
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error("Uncaught error in DM-Pal:", error);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 p-8 text-center">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<pre className="max-w-xl text-xs text-[var(--color-text-dim)] whitespace-pre-wrap break-words">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Sparkles, ImageOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
/** Text-to-image prompt. Changing it triggers a new generation. */
|
||||
prompt: string | null;
|
||||
/** Bump to force a regenerate with the same prompt. */
|
||||
nonce?: number;
|
||||
className?: string;
|
||||
/** Aspect for the placeholder box while loading/empty. */
|
||||
aspect?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and caches an image for `prompt` via the Ollama image model.
|
||||
* Results are cached on disk by the backend, so re-renders are instant.
|
||||
* macOS-only: on other OSes the backend returns an error and we show a
|
||||
* placeholder instead of a confusing timeout.
|
||||
*/
|
||||
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setUnsupported(false);
|
||||
invoke<string>("generate_image", { req: { prompt } })
|
||||
.then((url) => {
|
||||
if (!cancelled) setDataUrl(url);
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg = String(e);
|
||||
if (!cancelled) {
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else setError(msg);
|
||||
}
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [prompt, nonce]);
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`;
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<div className={box}>
|
||||
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
|
||||
<ImageOff size={20} />
|
||||
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={box}>
|
||||
<span className="text-[10px] text-[var(--color-danger)] px-2 text-center">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={box}>
|
||||
<Sparkles size={20} className="text-[var(--color-gold-bright)] animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return dataUrl ? (
|
||||
<div className={box}>
|
||||
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
History as HistoryIcon,
|
||||
X,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import {
|
||||
listGenerations,
|
||||
getGeneration,
|
||||
deleteGeneration,
|
||||
clearAllGenerations,
|
||||
relativeTime,
|
||||
extractTitle,
|
||||
KIND_LABELS,
|
||||
KIND_ORDER,
|
||||
type Generation,
|
||||
type GenerationKind,
|
||||
type GenerationSummary,
|
||||
} from "../lib/generations";
|
||||
import { ensureArray, flattenValue, parseLlmJson } from "../lib/llm-parse";
|
||||
|
||||
interface HistoryViewProps {
|
||||
// ponytail: optional callback when a row is rehydrated into a tool.
|
||||
// The App shell wires this to set the active view and prefill the form.
|
||||
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||
}
|
||||
|
||||
export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const [summaries, setSummaries] = useState<GenerationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeKind, setActiveKind] = useState<GenerationKind | "all">("all");
|
||||
const [selected, setSelected] = useState<Generation | null>(null);
|
||||
const [selectedLoading, setSelectedLoading] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const list = await listGenerations();
|
||||
setSummaries(list);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byKind = new Map<GenerationKind, GenerationSummary[]>();
|
||||
for (const k of KIND_ORDER) byKind.set(k, []);
|
||||
for (const s of summaries) {
|
||||
const list = byKind.get(s.kind);
|
||||
if (list) list.push(s);
|
||||
}
|
||||
return byKind;
|
||||
}, [summaries]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const rows: GenerationSummary[] = [];
|
||||
for (const [, list] of grouped) {
|
||||
for (const s of list) {
|
||||
if (activeKind !== "all" && s.kind !== activeKind) continue;
|
||||
if (q && !s.title.toLowerCase().includes(q)) continue;
|
||||
rows.push(s);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [grouped, query, activeKind]);
|
||||
|
||||
const onSelect = useCallback(async (id: number) => {
|
||||
setSelectedLoading(true);
|
||||
try {
|
||||
const g = await getGeneration(id);
|
||||
setSelected(g);
|
||||
} catch (e) {
|
||||
addToast(`Failed to load: ${e}`, "error");
|
||||
} finally {
|
||||
setSelectedLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const onDelete = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
await deleteGeneration(id);
|
||||
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
} catch (e) {
|
||||
addToast(`Delete failed: ${e}`, "error");
|
||||
}
|
||||
},
|
||||
[selected, addToast],
|
||||
);
|
||||
|
||||
const onClearAll = useCallback(async () => {
|
||||
try {
|
||||
const n = await clearAllGenerations();
|
||||
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||
setSummaries([]);
|
||||
setSelected(null);
|
||||
setConfirmClear(false);
|
||||
} catch (e) {
|
||||
addToast(`Clear failed: ${e}`, "error");
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-3 p-4">
|
||||
{/* List pane */}
|
||||
<div className="flex flex-col w-80 shrink-0 glass-card p-3 gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-base font-semibold flex items-center gap-2">
|
||||
<HistoryIcon size={16} /> History
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] p-1 rounded cursor-pointer"
|
||||
title="Refresh"
|
||||
aria-label="Refresh history"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{summaries.length > 0 && !confirmClear && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] p-1 rounded cursor-pointer"
|
||||
title="Clear all"
|
||||
aria-label="Clear all history"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmClear && (
|
||||
<div className="flex items-center justify-between rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 px-2 py-1.5 text-xs">
|
||||
<span className="text-[var(--color-danger)]">Delete all {summaries.length}?</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] rounded-lg px-2 py-1.5">
|
||||
<Search size={12} className="text-[var(--color-text-dim)]" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search history…"
|
||||
className="flex-1 bg-transparent text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
|
||||
aria-label="Search history"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kind filter chips */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<KindChip
|
||||
label="All"
|
||||
count={summaries.length}
|
||||
active={activeKind === "all"}
|
||||
onClick={() => setActiveKind("all")}
|
||||
/>
|
||||
{KIND_ORDER.map((k) => {
|
||||
const n = grouped.get(k)?.length ?? 0;
|
||||
if (n === 0) return null;
|
||||
return (
|
||||
<KindChip
|
||||
key={k}
|
||||
label={KIND_LABELS[k]}
|
||||
count={n}
|
||||
active={activeKind === k}
|
||||
onClick={() => setActiveKind(k)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto -mx-1">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-6 text-[var(--color-text-dim)] text-xs">
|
||||
<Loader2 size={14} className="animate-spin mr-2" /> Loading…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 p-2 text-[var(--color-danger)] text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||
<ScrollText size={20} className="mb-2 opacity-50" />
|
||||
<span>
|
||||
{summaries.length === 0
|
||||
? "Nothing generated yet."
|
||||
: "No matches."}
|
||||
</span>
|
||||
{summaries.length === 0 && (
|
||||
<span className="mt-1 text-[10px]">
|
||||
Every NPC, world, item, and quest you generate will show up here.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||
{KIND_LABELS[s.kind]}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||
{relativeTime(s.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||
{s.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail pane */}
|
||||
<div className="flex-1 min-w-0 glass-card p-4 overflow-y-auto">
|
||||
{selectedLoading ? (
|
||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
|
||||
<Loader2 size={16} className="animate-spin mr-2" /> Loading…
|
||||
</div>
|
||||
) : selected ? (
|
||||
<GenerationDetail
|
||||
generation={selected}
|
||||
onDelete={onDelete}
|
||||
onRehydrate={onRehydrate}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center text-[var(--color-text-dim)] text-sm">
|
||||
<Eye size={28} className="mb-3 opacity-40" />
|
||||
<span>Select a generation to view it.</span>
|
||||
<span className="mt-1 text-xs">
|
||||
From here you can re-open it in its tool or copy as Markdown.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KindChip({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${
|
||||
active
|
||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-semibold"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
>
|
||||
{label} <span className="opacity-70">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationDetail({
|
||||
generation,
|
||||
onDelete,
|
||||
onRehydrate,
|
||||
}: {
|
||||
generation: Generation;
|
||||
onDelete: (id: number) => void;
|
||||
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||
}) {
|
||||
const { addToast } = useToast();
|
||||
const isImage = generation.kind === "image";
|
||||
const isSession = generation.kind === "session";
|
||||
|
||||
let parsed: unknown = null;
|
||||
if (!isImage) {
|
||||
parsed = parseLlmJson(generation.data);
|
||||
}
|
||||
|
||||
const fallbackTitle = generation.title;
|
||||
const title = isImage
|
||||
? generation.title
|
||||
: extractTitle(generation.kind, generation.data, fallbackTitle);
|
||||
|
||||
const rehydratableKinds: GenerationKind[] = [
|
||||
"npc",
|
||||
"encounter",
|
||||
"world",
|
||||
"item",
|
||||
"quest",
|
||||
];
|
||||
const canRehydrate = rehydratableKinds.includes(generation.kind);
|
||||
|
||||
function copyMarkdown() {
|
||||
let md: string;
|
||||
if (isImage) {
|
||||
md = `\n\n_${new Date(
|
||||
generation.createdAt,
|
||||
).toLocaleString()}_`;
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
md = `# ${title}\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
||||
} else {
|
||||
md = `# ${title}\n\n${generation.data}`;
|
||||
}
|
||||
void navigator.clipboard.writeText(md).then(
|
||||
() => addToast("Copied as Markdown", "success"),
|
||||
() => addToast("Copy failed", "error"),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 pb-2 border-b border-[var(--color-border-glass)]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
|
||||
{KIND_LABELS[generation.kind]}
|
||||
</div>
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-lg font-bold truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{generation.source && (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
|
||||
from: {generation.source}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
|
||||
{new Date(generation.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{canRehydrate && onRehydrate && (
|
||||
<button
|
||||
onClick={() => onRehydrate(generation.kind, generation)}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer flex items-center gap-1"
|
||||
title={`Open in ${KIND_LABELS[generation.kind]} tool`}
|
||||
>
|
||||
<Sparkles size={12} /> Open in tool
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={copyMarkdown}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(generation.id)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1.5 text-xs hover:text-[var(--color-danger)] transition-colors cursor-pointer"
|
||||
title="Delete this generation"
|
||||
aria-label="Delete generation"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{isImage ? (
|
||||
<ImageBody data={generation.data} prompt={generation.title} />
|
||||
) : isSession ? (
|
||||
<SessionBody data={generation.data} />
|
||||
) : parsed && typeof parsed === "object" ? (
|
||||
<StructuredBody data={parsed} />
|
||||
) : (
|
||||
<pre className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-3 text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap break-words">
|
||||
{generation.data}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageBody({ data, prompt }: { data: string; prompt: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<img
|
||||
src={data}
|
||||
alt={prompt}
|
||||
className="rounded-lg max-w-full max-h-[60vh] border border-[var(--color-border-glass)]"
|
||||
/>
|
||||
<a
|
||||
href={data}
|
||||
download="dm-pal-image.png"
|
||||
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)]"
|
||||
>
|
||||
⬇ Save PNG
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBody({ data }: { data: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-sm text-[var(--color-text-primary)] whitespace-pre-wrap leading-relaxed">
|
||||
{data}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StructuredBody({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{typeof obj.bio === "string" && (
|
||||
<Section label="Bio">
|
||||
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
|
||||
{obj.bio}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
{typeof obj.description === "string" && (
|
||||
<Section label="Description">
|
||||
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
|
||||
{obj.description}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
{typeof obj.hook === "string" && (
|
||||
<Section label="Hook">
|
||||
<p className="text-sm text-[var(--color-text-secondary)] italic">
|
||||
{obj.hook}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.personality) && obj.personality.length > 0 && (
|
||||
<Section label="Personality">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ensureArray(obj.personality).map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
|
||||
>
|
||||
{flattenValue(p)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.goals) && obj.goals.length > 0 && (
|
||||
<Section label="Goals">
|
||||
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
|
||||
{ensureArray(obj.goals).map((g, i) => (
|
||||
<li key={i}>{flattenValue(g)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.monsters) && obj.monsters.length > 0 && (
|
||||
<Section label="Monsters">
|
||||
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
|
||||
{ensureArray(obj.monsters).map((m, i) => (
|
||||
<li key={i}>{flattenValue(m)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.terrain === "string" && (
|
||||
<Section label="Terrain">
|
||||
<p className="text-sm text-[var(--color-text-primary)]">{obj.terrain}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.difficulty === "string" && (
|
||||
<Section label="Difficulty">
|
||||
<span className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-0.5 text-xs">
|
||||
{obj.difficulty}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.loot === "string" && (
|
||||
<Section label="Loot">
|
||||
<p className="text-sm text-[var(--color-gold-bright)]">{obj.loot}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.regions) && obj.regions.length > 0 && (
|
||||
<Section label="Regions">
|
||||
<ul className="text-sm text-[var(--color-text-primary)] space-y-1">
|
||||
{ensureArray(obj.regions).map((r, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-2 py-1"
|
||||
>
|
||||
{flattenValue(r)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.landmarks) && obj.landmarks.length > 0 && (
|
||||
<Section label="Landmarks">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ensureArray(obj.landmarks).map((l, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
|
||||
>
|
||||
{flattenValue(l)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.conflicts) && obj.conflicts.length > 0 && (
|
||||
<Section label="Conflicts">
|
||||
<div className="flex flex-col gap-1">
|
||||
{ensureArray(obj.conflicts).map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-2 py-1 text-sm text-[var(--color-text-primary)]"
|
||||
>
|
||||
{flattenValue(c)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.cultures) && obj.cultures.length > 0 && (
|
||||
<Section label="Cultures">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ensureArray(obj.cultures).map((c, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2 py-0.5 text-xs"
|
||||
>
|
||||
{flattenValue(c)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{Array.isArray(obj.steps) && obj.steps.length > 0 && (
|
||||
<Section label="Steps">
|
||||
<ol className="flex flex-col gap-2 list-decimal list-inside">
|
||||
{ensureArray(obj.steps).map((s, i) => {
|
||||
const step = typeof s === "object" && s !== null
|
||||
? (s as Record<string, unknown>)
|
||||
: { title: "", description: flattenValue(s) };
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2"
|
||||
>
|
||||
{typeof step.title === "string" && (
|
||||
<div className="text-sm font-semibold text-[var(--color-gold-bright)]">
|
||||
{step.title}
|
||||
</div>
|
||||
)}
|
||||
{typeof step.description === "string" && (
|
||||
<div className="text-sm text-[var(--color-text-primary)] mt-0.5">
|
||||
{step.description}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.twist === "string" && (
|
||||
<Section label="Twist">
|
||||
<div className="rounded border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{obj.twist}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.reward === "string" && (
|
||||
<Section label="Reward">
|
||||
<div className="rounded border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
|
||||
{obj.reward}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.mechanical === "string" && (
|
||||
<Section label="Mechanics">
|
||||
<div className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-2 text-sm text-[var(--color-text-primary)]">
|
||||
{obj.mechanical}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.lore === "string" && (
|
||||
<Section label="Lore">
|
||||
<p className="text-sm text-[var(--color-text-secondary)] italic">
|
||||
{obj.lore}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{typeof obj.rarity === "string" && (
|
||||
<Section label="Rarity">
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
{obj.rarity}
|
||||
{typeof obj.type === "string" && <> · {obj.type}</>}
|
||||
</span>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Fallback: any keys we didn't render show as a key/value table. */}
|
||||
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] mt-2">
|
||||
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
|
||||
Raw JSON
|
||||
</summary>
|
||||
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)] font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal wrapper ────────────────────────────────────────────
|
||||
|
||||
interface HistoryDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
|
||||
}
|
||||
|
||||
export function HistoryDrawer({ open, onClose, onRehydrate }: HistoryDrawerProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-30 bg-black/50"
|
||||
onClick={onClose}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
aria-label="Generation history"
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-4 lg:inset-8 bg-[var(--color-bg-deep)] rounded-2xl border border-[var(--color-border-glass)] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
initial={{ scale: 0.97, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.97, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold flex items-center gap-2">
|
||||
<HistoryIcon size={14} /> Generation History
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-text-primary)] cursor-pointer"
|
||||
title="Close (Esc)"
|
||||
aria-label="Close history"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-44px)]">
|
||||
<HistoryView onRehydrate={(k, d) => {
|
||||
onRehydrate?.(k, d);
|
||||
onClose();
|
||||
}} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ImagePlus, Sparkles } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
|
||||
const DEFAULT_MODEL = "x/flux2-klein:4b";
|
||||
|
||||
interface Props {
|
||||
prefill?: Generation | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [variant, setVariant] = useState(0);
|
||||
const { addToast } = useToast();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
||||
setPrompt(g.title);
|
||||
setDataUrl(g.data);
|
||||
addToast(`Loaded "${g.title}" from history`, "info");
|
||||
});
|
||||
|
||||
async function generate() {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setDataUrl(null);
|
||||
try {
|
||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||
// so "regenerate" actually produces a new image instead of the cached one.
|
||||
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
|
||||
const url = await invoke<string>("generate_image", {
|
||||
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
||||
});
|
||||
setDataUrl(url);
|
||||
addToast("Image generated", "success");
|
||||
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
||||
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Image generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
setVariant((v) => v + 1);
|
||||
// run after state update flushes
|
||||
setTimeout(generate, 0);
|
||||
}
|
||||
|
||||
const unsupported = error.toLowerCase().includes("macos-only");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
|
||||
<textarea
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-24 resize-y"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="e.g. portrait of a grizzled dwarf blacksmith at a forge, warm light, oil-painting fantasy style, 1024x1024"
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.metaKey || e.ctrlKey) && generate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
Model (optional — defaults to {DEFAULT_MODEL})
|
||||
</label>
|
||||
<input
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="flex-1 rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||
</button>
|
||||
{dataUrl && !loading && (
|
||||
<button
|
||||
onClick={regenerate}
|
||||
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
title="Generate a new variation"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center">
|
||||
<ImagePlus size={28} className="text-[var(--color-gold-bright)] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unsupported && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
||||
Image generation is macOS-only via Ollama (for now). On other platforms the
|
||||
✨ buttons elsewhere fall back to a placeholder.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !unsupported && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataUrl && !loading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<a
|
||||
href={dataUrl}
|
||||
download="dm-pal-image.png"
|
||||
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||
>
|
||||
⬇ Save PNG
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useToast } from "./Toast";
|
||||
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||
|
||||
interface Combatant {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface RagSource {
|
||||
source: string;
|
||||
chunks: number;
|
||||
}
|
||||
|
||||
interface RagHit {
|
||||
text: string;
|
||||
source: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function LorePanel() {
|
||||
const [source, setSource] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [sources, setSources] = useState<RagSource[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<RagHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
setSources(await invoke<RagSource[]>("rag_list"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSources();
|
||||
}, []);
|
||||
|
||||
async function add() {
|
||||
if (!source.trim() || !text.trim()) return;
|
||||
setAdding(true);
|
||||
setMsg("");
|
||||
try {
|
||||
const n = await invoke<number>("rag_add", { req: { source, text } });
|
||||
setMsg(`Indexed ${n} chunk${n === 1 ? "" : "s"}`);
|
||||
setText("");
|
||||
loadSources();
|
||||
} catch (e) {
|
||||
setMsg(String(e));
|
||||
}
|
||||
setAdding(false);
|
||||
setTimeout(() => setMsg(""), 3000);
|
||||
}
|
||||
|
||||
async function clearOne(s: string) {
|
||||
await invoke("rag_clear", { source: s });
|
||||
loadSources();
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
await invoke("rag_clear", { source: null });
|
||||
loadSources();
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setHits(await invoke<RagHit[]>("rag_search", { query, topK: 5 }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setSearching(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{/* Add lore */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
||||
<input
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
|
||||
/>
|
||||
<textarea
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-40 resize-y"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
|
||||
/>
|
||||
<button
|
||||
onClick={add}
|
||||
disabled={adding}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{adding ? "Embedding…" : "Add to Lore"}
|
||||
</button>
|
||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||
{sources.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sources.map((s) => (
|
||||
<li key={s.source} className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5">
|
||||
<span className="text-[var(--color-text-primary)] text-xs truncate">
|
||||
{s.source} <span className="text-[var(--color-text-dim)]">· {s.chunks} chunks</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => clearOne(s.source)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors shrink-0"
|
||||
title="Clear this source"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test retrieval */}
|
||||
<div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Test Retrieval</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="Ask something your lore should answer…"
|
||||
/>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={searching}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{searching ? "…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{hits.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{hits.map((h, i) => (
|
||||
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hits.length === 0 && query && !searching && (
|
||||
<p className="text-xs text-[var(--color-text-dim)]">No matches.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { 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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// ponytail: tiny event bus so tools can push data to each other without
|
||||
// a shared store. Survives component unmounts via a module-level emitter.
|
||||
// Use sparingly: only for cross-tool flows (Encounter → Initiative, Item →
|
||||
// Lore, etc.). The History view stays the single source of truth for
|
||||
// rehydrating past results.
|
||||
|
||||
type Handler<T> = (payload: T) => void;
|
||||
|
||||
interface CombatantInput {
|
||||
name: string;
|
||||
initiative?: number;
|
||||
hp: number;
|
||||
}
|
||||
|
||||
class EventBus {
|
||||
private map = new Map<string, Set<Handler<unknown>>>();
|
||||
|
||||
on<T>(event: string, handler: Handler<T>): () => void {
|
||||
const set = this.map.get(event) ?? new Set();
|
||||
set.add(handler as Handler<unknown>);
|
||||
this.map.set(event, set);
|
||||
return () => {
|
||||
set.delete(handler as Handler<unknown>);
|
||||
};
|
||||
}
|
||||
|
||||
emit<T>(event: string, payload: T): void {
|
||||
const set = this.map.get(event);
|
||||
if (!set) return;
|
||||
for (const h of set) {
|
||||
try {
|
||||
h(payload);
|
||||
} catch (e) {
|
||||
console.error(`[EventBus] handler for ${event} threw:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bus = new EventBus();
|
||||
|
||||
// ─── Event names + payload types ────────────────────────────
|
||||
|
||||
export const Events = {
|
||||
AddCombatants: "add-combatants",
|
||||
} as const;
|
||||
|
||||
export type AddCombatantsPayload = {
|
||||
combatants: CombatantInput[];
|
||||
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
|
||||
source: string;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
// ponytail: 5e DMG p.82 XP thresholds per character. Single source of truth
|
||||
// for the encounter builder's budget math. The "Easy/Medium/Hard/Deadly"
|
||||
// thresholds are *per character*; the table is indexed by character level.
|
||||
|
||||
export type Difficulty = "Easy" | "Medium" | "Hard" | "Deadly";
|
||||
|
||||
const THRESHOLDS: Record<number, Record<Difficulty, number>> = {
|
||||
1: { Easy: 25, Medium: 50, Hard: 75, Deadly: 100 },
|
||||
2: { Easy: 50, Medium: 100, Hard: 150, Deadly: 200 },
|
||||
3: { Easy: 75, Medium: 150, Hard: 225, Deadly: 400 },
|
||||
4: { Easy: 125, Medium: 250, Hard: 375, Deadly: 500 },
|
||||
5: { Easy: 250, Medium: 500, Hard: 750, Deadly: 1100 },
|
||||
6: { Easy: 300, Medium: 600, Hard: 900, Deadly: 1400 },
|
||||
7: { Easy: 350, Medium: 750, Hard: 1100, Deadly: 1700 },
|
||||
8: { Easy: 450, Medium: 900, Hard: 1400, Deadly: 2100 },
|
||||
9: { Easy: 550, Medium: 1100, Hard: 1600, Deadly: 2400 },
|
||||
10: { Easy: 600, Medium: 1200, Hard: 1900, Deadly: 2800 },
|
||||
11: { Easy: 800, Medium: 1600, Hard: 2400, Deadly: 3600 },
|
||||
12: { Easy: 1000, Medium: 2000, Hard: 3000, Deadly: 4500 },
|
||||
13: { Easy: 1100, Medium: 2200, Hard: 3400, Deadly: 5100 },
|
||||
14: { Easy: 1250, Medium: 2500, Hard: 3800, Deadly: 5700 },
|
||||
15: { Easy: 1400, Medium: 2800, Hard: 4300, Deadly: 6400 },
|
||||
16: { Easy: 1600, Medium: 3200, Hard: 4800, Deadly: 7200 },
|
||||
17: { Easy: 2000, Medium: 3900, Hard: 5900, Deadly: 8800 },
|
||||
18: { Easy: 2100, Medium: 4200, Hard: 6300, Deadly: 9500 },
|
||||
19: { Easy: 2400, Medium: 4900, Hard: 7300, Deadly: 10900 },
|
||||
20: { Easy: 2800, Medium: 5700, Hard: 8500, Deadly: 12700 },
|
||||
};
|
||||
|
||||
export interface Budget {
|
||||
easy: number;
|
||||
medium: number;
|
||||
hard: number;
|
||||
deadly: number;
|
||||
}
|
||||
|
||||
/** Compute per-difficulty XP budgets for a party. Clamps level to 1..20. */
|
||||
export function encounterBudget(partyLevel: number, partySize: number): Budget {
|
||||
const lvl = Math.max(1, Math.min(20, Math.round(partyLevel)));
|
||||
const size = Math.max(1, Math.round(partySize));
|
||||
const t = THRESHOLDS[lvl];
|
||||
return {
|
||||
easy: t.Easy * size,
|
||||
medium: t.Medium * size,
|
||||
hard: t.Hard * size,
|
||||
deadly: t.Deadly * size,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map an XP total to a difficulty bucket. */
|
||||
export function difficultyForXp(xp: number, budget: Budget): Difficulty {
|
||||
if (xp >= budget.deadly) return "Deadly";
|
||||
if (xp >= budget.hard) return "Hard";
|
||||
if (xp >= budget.medium) return "Medium";
|
||||
if (xp >= budget.easy) return "Easy";
|
||||
return "Easy";
|
||||
}
|
||||
|
||||
export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
|
||||
Easy: "var(--color-success)",
|
||||
Medium: "var(--color-info)",
|
||||
Hard: "var(--color-gold-bright)",
|
||||
Deadly: "var(--color-danger)",
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type GenerationKind =
|
||||
| "npc"
|
||||
| "encounter"
|
||||
| "world"
|
||||
| "item"
|
||||
| "quest"
|
||||
| "session"
|
||||
| "image";
|
||||
|
||||
export interface GenerationSummary {
|
||||
id: number;
|
||||
kind: GenerationKind;
|
||||
title: string;
|
||||
/** Epoch ms */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Generation {
|
||||
id: number;
|
||||
kind: GenerationKind;
|
||||
title: string;
|
||||
/** Raw JSON string from the LLM (or data URL for image kind). */
|
||||
data: string;
|
||||
source: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface AddGenerationRequest {
|
||||
kind: GenerationKind;
|
||||
title: string;
|
||||
data: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ponytail: thin wrapper. Every generator that succeeds calls addGeneration;
|
||||
// the History view lists/gets/deletes via these helpers.
|
||||
|
||||
export async function addGeneration(req: AddGenerationRequest): Promise<number> {
|
||||
return invoke<number>("generation_add", { req });
|
||||
}
|
||||
|
||||
export async function listGenerations(
|
||||
kind?: GenerationKind,
|
||||
): Promise<GenerationSummary[]> {
|
||||
return invoke<GenerationSummary[]>("generation_list", { kind: kind ?? null });
|
||||
}
|
||||
|
||||
export async function getGeneration(id: number): Promise<Generation | null> {
|
||||
return invoke<Generation | null>("generation_get", { id });
|
||||
}
|
||||
|
||||
export async function deleteGeneration(id: number): Promise<number> {
|
||||
return invoke<number>("generation_delete", { id });
|
||||
}
|
||||
|
||||
export async function clearAllGenerations(): Promise<number> {
|
||||
return invoke<number>("generation_delete", { id: null });
|
||||
}
|
||||
|
||||
export async function generationCounts(): Promise<Record<string, number>> {
|
||||
const pairs = await invoke<[string, number][]>("generation_counts");
|
||||
return Object.fromEntries(pairs);
|
||||
}
|
||||
|
||||
// ─── Title extraction ────────────────────────────────────────
|
||||
|
||||
// ponytail: each LLM response shape has a slightly different "title" field.
|
||||
// Centralize so the history list can label rows without re-parsing in the UI.
|
||||
|
||||
const TITLE_KEYS: Record<GenerationKind, string[]> = {
|
||||
npc: ["name"],
|
||||
encounter: ["title"],
|
||||
world: ["name"],
|
||||
item: ["name"],
|
||||
quest: ["title"],
|
||||
session: ["title"],
|
||||
image: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull a human-readable title out of an LLM JSON string. Falls back to the
|
||||
* caller's `fallback` if the JSON is malformed or has no recognized field.
|
||||
*/
|
||||
export function extractTitle(
|
||||
kind: GenerationKind,
|
||||
data: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
try {
|
||||
const obj = JSON.parse(data);
|
||||
for (const k of TITLE_KEYS[kind]) {
|
||||
const v = obj?.[k];
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — for image kind the data is a base64 PNG data URL.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ─── Time formatting ─────────────────────────────────────────
|
||||
|
||||
/** Compact relative time for the history list: "just now", "3m", "2h", "5d". */
|
||||
export function relativeTime(epochMs: number, now: number = Date.now()): string {
|
||||
const diff = Math.max(0, now - epochMs);
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 45) return "just now";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const date = new Date(epochMs);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ─── Defaults (one source of truth for kind label/icon) ──────
|
||||
|
||||
export const KIND_LABELS: Record<GenerationKind, string> = {
|
||||
npc: "NPC",
|
||||
encounter: "Encounter",
|
||||
world: "World",
|
||||
item: "Item",
|
||||
quest: "Quest",
|
||||
session: "Session",
|
||||
image: "Image",
|
||||
};
|
||||
|
||||
export const KIND_ORDER: GenerationKind[] = [
|
||||
"npc",
|
||||
"encounter",
|
||||
"world",
|
||||
"item",
|
||||
"quest",
|
||||
"session",
|
||||
"image",
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* Best-effort lore ingestion. Every generated result gets indexed into the
|
||||
* local RAG store so future generations stay consistent with everything the
|
||||
* DM has already created. Errors are swallowed — lore is augmentation, never
|
||||
* a blocker on the generation UX.
|
||||
*/
|
||||
export async function addToLore(source: string, text: string): Promise<void> {
|
||||
if (!source.trim() || !text.trim()) return;
|
||||
try {
|
||||
await invoke("rag_add", { req: { source, text } });
|
||||
} catch (e) {
|
||||
console.warn("addToLore failed:", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ponytail: 5e SRD races (9) and PHB-style backgrounds (16). The original
|
||||
// generator only had 8 races and a free-text "Class" that conflated classes
|
||||
// and backgrounds. These lists disambiguate.
|
||||
|
||||
export const RACES = [
|
||||
"Human",
|
||||
"Elf",
|
||||
"Dwarf",
|
||||
"Halfling",
|
||||
"Gnome",
|
||||
"Dragonborn",
|
||||
"Tiefling",
|
||||
"Half-Orc",
|
||||
"Goliath",
|
||||
] as const;
|
||||
|
||||
// ponytail: PHB backgrounds. 5e calls these "backgrounds" and they cover
|
||||
// occupation, social standing, and starting equipment. They're the right
|
||||
// concept for an NPC generator — most named NPCs have a background, not
|
||||
// necessarily a class.
|
||||
export const BACKGROUNDS = [
|
||||
"Acolyte",
|
||||
"Charlatan",
|
||||
"Criminal",
|
||||
"Entertainer",
|
||||
"Folk Hero",
|
||||
"Guild Artisan",
|
||||
"Hermit",
|
||||
"Noble",
|
||||
"Outlander",
|
||||
"Sage",
|
||||
"Sailor",
|
||||
"Soldier",
|
||||
"Urchin",
|
||||
] as const;
|
||||
|
||||
export const ALIGNMENTS = [
|
||||
"Lawful Good",
|
||||
"Neutral Good",
|
||||
"Chaotic Good",
|
||||
"Lawful Neutral",
|
||||
"True Neutral",
|
||||
"Chaotic Neutral",
|
||||
"Lawful Evil",
|
||||
"Neutral Evil",
|
||||
"Chaotic Evil",
|
||||
] as const;
|
||||
|
||||
// ponytail: per-race name banks. Tiny but functional; full banks live in
|
||||
// the SRD. Sufficient for a "random" button on the form.
|
||||
const NAMES_BY_RACE: Record<string, { first: string[]; last: string[] }> = {
|
||||
Human: {
|
||||
first: ["Aldric", "Branwen", "Cassia", "Doran", "Elara", "Fendrel", "Gwynne", "Hadrian", "Isobel", "Joren"],
|
||||
last: ["Ashford", "Brightwood", "Carrow", "Dunharrow", "Emberlain", "Frostvale", "Greycastle", "Holloway"],
|
||||
},
|
||||
Elf: {
|
||||
first: ["Aelar", "Beiro", "Carric", "Drannor", "Enna", "Faelar", "Galinndan", "Hadarai", "Immeral", "Ivellios"],
|
||||
last: ["Amakir", "Galanodel", "Holimion", "Liadon", "Meliamne", "Nailo", "Siannodel"],
|
||||
},
|
||||
Dwarf: {
|
||||
first: ["Adrik", "Baern", "Brottor", "Dain", "Eberk", "Gardain", "Harbek", "Kildrak", "Morgran", "Orsik"],
|
||||
last: ["Balderk", "Battlehammer", "Brawnanvil", "Dankil", "Fireforge", "Frostbeard", "Gorunn", "Holderhek", "Ironfist"],
|
||||
},
|
||||
Halfling: {
|
||||
first: ["Alton", "Beau", "Cade", "Eldon", "Garret", "Lyle", "Milo", "Osborn", "Perrin", "Reed"],
|
||||
last: ["Brushgather", "Goodbarrel", "Greenbottle", "High-hill", "Hilltopple", "Leagallow", "Tealeaf", "Thorngage"],
|
||||
},
|
||||
Gnome: {
|
||||
first: ["Alston", "Boddynock", "Brocc", "Burgell", "Dimble", "Eldon", "Erky", "Fonkin", "Frug", "Gerbo"],
|
||||
last: ["Beren", "Daergel", "Folkor", "Garrick", "Nackle", "Murnig", "Ningel", "Raulnor", "Scheppen", "Timbers"],
|
||||
},
|
||||
Dragonborn: {
|
||||
first: ["Arjhan", "Balasar", "Bharash", "Donaar", "Ghesh", "Heskan", "Kriv", "Medrash", "Mehen", "Nadarr"],
|
||||
last: ["Clethtinthiallor", "Daardendrian", "Delmirev", "Drachedandion", "Fenkenkabradon", "Kepeshkmolik", "Kerrhylon", "Kimbatuul", "Linxakasendalor", "Myastan"],
|
||||
},
|
||||
Tiefling: {
|
||||
first: ["Akmenos", "Amnon", "Barakas", "Damakos", "Ekemon", "Iados", "Kairon", "Leucis", "Melech", "Mordai"],
|
||||
last: ["Carrion", "Glory", "Hope", "Ideal", "Music", "Nowhere", "Quest", "Random", "Sorrow", "Temerity"],
|
||||
},
|
||||
"Half-Orc": {
|
||||
first: ["Dench", "Feng", "Gell", "Henk", "Holg", "Imsh", "Keth", "Krusk", "Mhurren", "Ront"],
|
||||
last: ["Bloodfang", "Cleavehowl", "Grimaxe", "Ironjaw", "Skullbelly", "Stoneheart", "Wartooth"],
|
||||
},
|
||||
Goliath: {
|
||||
first: ["Aukan", "Eglath", "Gae-Al", "Gauthak", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith"],
|
||||
last: ["Anakir", "Dukan", "Gatholos", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith", "Nalla"],
|
||||
},
|
||||
};
|
||||
|
||||
export function randomName(race: string): string {
|
||||
const bank = NAMES_BY_RACE[race] ?? NAMES_BY_RACE.Human;
|
||||
const first = bank.first[Math.floor(Math.random() * bank.first.length)];
|
||||
const last = bank.last[Math.floor(Math.random() * bank.last.length)];
|
||||
return `${first} ${last}`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Generation, GenerationKind } from "../lib/generations";
|
||||
|
||||
/**
|
||||
* The shape each generator's prefill callback expects. Tools accept `prefill`
|
||||
* (a Generation) and call this hook to consume it once. The hook calls
|
||||
* `onConsumed` to tell the parent to clear the pending prefill, so re-renders
|
||||
* don't re-trigger the effect.
|
||||
*/
|
||||
export function usePrefillEffect<T extends Generation>(
|
||||
prefill: T | null,
|
||||
kind: GenerationKind,
|
||||
onConsumed: () => void,
|
||||
apply: (data: T) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (prefill && prefill.kind === kind) {
|
||||
apply(prefill as T);
|
||||
onConsumed();
|
||||
}
|
||||
// Intentional: run only when prefill id changes (mount or rehydrate).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [prefill?.id]);
|
||||
}
|
||||
Reference in New Issue
Block a user