diff --git a/docs/plan.md b/docs/plan.md index c0c3df5..a2b5404 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -858,18 +858,18 @@ To keep AI-generated content consistent with your world: - [ ] 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 +- [x] 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 +- [x] Soundboard / ambience module - [ ] Handout renderer (Markdown → PDF) - [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states -- [ ] Ensure WCAG AA contrast on all text over glass cards -- [ ] Responsive bento collapse (2-column on narrow windows) -- [ ] Accessibility pass (keyboard nav, screen reader labels, focus rings in `--gold-bright`) +- [x] Ensure WCAG AA contrast on all text over glass cards +- [x] Responsive bento collapse (2-column on narrow windows) +- [x] Accessibility pass (keyboard nav, screen reader labels, focus rings in `--gold-bright`) - [ ] Commit and tag `v0.6.0-lore-polish` ### Milestone 7 — Testing, CI, Distribution diff --git a/docs/ui-ux-improvements.md b/docs/ui-ux-improvements.md index 7e9c880..833410a 100644 --- a/docs/ui-ux-improvements.md +++ b/docs/ui-ux-improvements.md @@ -4,8 +4,15 @@ ordered by impact, not file order. Each item ships with a concrete, checkable recommendation.* -> **Status:** in progress — P0 and P1 shipped, P2 largely done. The -> checklist below is updated as items land. +> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative, +> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger, +> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/ +> reward fields shipped; remaining P2 items are larger-effort or backend- +> gated: image-gen advanced params (Ollama API doesn't support them), +> soundboard scenes + real ambience packs, world hierarchy tree, lore +> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory / +> quest roster (History view covers re-opening). The checklist below is +> updated as items land. --- @@ -1045,7 +1052,7 @@ These are blockers or bugs that make the app feel broken. ### P2 — Important (next month) -- [ ] **Initiative Tracker**: +- [x] **Initiative Tracker**: - [x] Click-to-edit initiative number - [x] Custom conditions - [x] Damage input (numeric, not +/- only) @@ -1055,12 +1062,12 @@ These are blockers or bugs that make the app feel broken. - [x] Highlight next-up combatant - [x] Confirm before Reset - [x] "Import from Encounter" button -- [ ] **Dice Roller**: +- [x] **Dice Roller**: - [x] Modifier input (saved per session) - [x] Spacebar rolls - [x] Roll templates (Attack, Save, Check, Damage) - [x] Clear/copy history -- [ ] **Encounter Builder**: +- [x] **Encounter Builder**: - [x] XP budget math (party size, level, difficulty) - [x] Color-coded difficulty - [x] Send to Initiative Tracker @@ -1070,43 +1077,43 @@ These are blockers or bugs that make the app feel broken. - [ ] **NPC Generator**: - [x] Rename "Class" to "Background" with curated list - [x] Expand races to 9 (Half-Orc, Goliath) - - [ ] NPC Roster view - - [ ] Stat block section (AC, HP, ability scores) + - [ ] NPC Roster view (History view covers re-opening past NPCs) + - [x] Stat block section (AC, HP, ability scores) - [x] Random name button - [ ] Save portrait (right-click) - [x] Edit personality/goals in-place - [ ] **Item Forge**: - [x] Click art to expand - - [ ] Structured mechanics (attunement, charges, value, weight) - - [ ] Save to inventory + - [x] Structured mechanics (attunement, charges, value, weight) + - [ ] Save to inventory (History view covers re-opening past items) - [x] Random item button - [ ] **Quest Designer**: - [ ] Branching quest graph (react-flow) - [x] Step status (Pending/Active/Done) - [x] Player-facing view toggle (hide Twist) - - [ ] Reward breakdown (XP per character, gold, items) - - [ ] Quest roster + - [x] Reward breakdown (XP per character, gold, items) + - [ ] Quest roster (History view covers re-opening past quests) - [ ] **Image Generator**: - [ ] Negative prompt, seed, aspect ratio, steps, guidance - [ ] Batch mode (2×2 variants) - [ ] Gallery view with thumbnails - [x] Persist to campaign-scoped folder - [x] Copy to clipboard -- [ ] **Session Logger**: - - [ ] Markdown editor + live preview - - [ ] Sessions as first-class concept (multiple sessions) +- [x] **Session Logger**: + - [x] Markdown editor + live preview (minimal renderer; no dep) + - [x] Sessions as first-class concept (multiple sessions) - [x] Streaming AI summary - [x] Export to Markdown - [x] Per-entry actions (edit, delete, highlight) - - [ ] Tags -- [ ] **Calendar**: - - [ ] Custom calendar editor + - [x] Tags +- [x] **Calendar**: + - [x] Custom calendar editor - [x] Current campaign date with "Next day" button - [x] Weather generator - [x] Moon phases - - [ ] Event categories with colors - - [ ] Recurring events - - [ ] Agenda view + - [x] Event categories with colors + - [x] Recurring events + - [x] Agenda view - [ ] **Soundboard**: - [ ] Real ambience pack (2–3 royalty-free loops) - [ ] Custom sound import (drag-drop) @@ -1115,33 +1122,34 @@ These are blockers or bugs that make the app feel broken. - [x] Always-visible Stop All - [x] Save/load board state - [x] Pre-warm AudioContext -- [ ] **Random Tables**: +- [x] **Random Tables**: - [x] 10+ more built-in tables - - [ ] User-defined tables (create/edit/delete) - - [ ] Table import (paste Markdown) - - [ ] Weighted tables + - [x] User-defined tables (create/edit/delete) + - [x] Table import (paste Markdown) + - [x] Weighted tables (weight % + gap warning) - [x] Export table - [ ] **World Builder**: - [x] Map canvas (react-konva) - [x] Theme presets - [ ] Hierarchy tree - [x] Persist world -- [ ] **Lore Panel**: - - [ ] Two-pane layout - - [ ] Drag-drop file upload - - [ ] Directory picker - - [ ] Chunk preview - - [ ] Source filter +- [x] **Lore Panel**: + - [x] Two-pane layout + - [x] Drag-drop file upload (via native file input; directory picker needs tauri-plugin-dialog) + - [ ] Directory picker (needs tauri-plugin-dialog) + - [x] Chunk preview + - [x] Source filter - [x] ★ relevance (not raw cosine) - [x] Confirm before "Clear all" -- [ ] **Settings**: +- [x] **Settings**: - [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger) - [x] Connection test with model list - [x] Model picker with autocomplete (`GET /api/tags`) - [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom) - [x] Reset to defaults - - [ ] Export/import config -- [ ] **Cross-cutting**: + - [x] Export/import config + - [x] Configurable campaign data location (lore db, generations db, generated images) via a folder picker — migrates existing data and applies on restart +- [x] **Cross-cutting**: - [x] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ? - [x] Skeleton loaders for all async states - [x] Empty states for all tools diff --git a/package-lock.json b/package-lock.json index f271f93..95f2264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-log": "^2", "@tauri-apps/plugin-store": "^2.4.3", "framer-motion": "^12", @@ -1300,6 +1301,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-log": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz", diff --git a/package.json b/package.json index 9c44253..a4de48e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-log": "^2", "@tauri-apps/plugin-store": "^2.4.3", + "@tauri-apps/plugin-dialog": "^2", "framer-motion": "^12", "konva": "^10.3.0", "lucide-react": "^0.525", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57c66b9..e7ac623 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -752,6 +752,7 @@ name = "dm-pal" version = "0.1.0" dependencies = [ "anyhow", + "futures-util", "log", "reqwest 0.12.28", "rusqlite", @@ -759,6 +760,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-store", @@ -2831,6 +2833,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2852,12 +2855,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", ] @@ -2891,10 +2896,34 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3781,6 +3810,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.1" @@ -4607,6 +4654,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -4945,6 +5005,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4978,13 +5047,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5015,6 +5101,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5027,6 +5119,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5039,12 +5137,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5057,6 +5167,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5069,6 +5185,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5081,6 +5203,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5093,6 +5221,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d6fc87e..3c60546 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,8 @@ tauri = { version = "2.11.3", features = [] } tauri-plugin-log = "2" tauri-plugin-store = "2" tauri-plugin-fs = "2" -reqwest = { version = "0.12", features = ["json"] } +tauri-plugin-dialog = "2" +reqwest = { version = "0.12", features = ["json", "stream"] } +futures-util = "0.3" tokio = { version = "1", features = ["full"] } rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d0e35e7..dcfa1f3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ "fs:allow-rename", "fs:allow-read-dir", "fs:allow-appdata-read-recursive", - "fs:allow-appdata-write-recursive" + "fs:allow-appdata-write-recursive", + "dialog:default", + "dialog:allow-open" ] } \ No newline at end of file diff --git a/src-tauri/src/commands/data_commands.rs b/src-tauri/src/commands/data_commands.rs new file mode 100644 index 0000000..f8b4c9f --- /dev/null +++ b/src-tauri/src/commands/data_commands.rs @@ -0,0 +1,200 @@ +use crate::llm::AppState; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tauri::Manager; +use tauri_plugin_store::StoreExt; + +// ponytail: the data-dir pref store + key are defined in lib.rs; mirror them +// here so this module is self-contained for reads/writes. +const PREFS_STORE: &str = "dm-pal-prefs.json"; +const DATA_DIR_KEY: &str = "dataDir"; + +#[derive(Debug, Serialize)] +pub struct DataDirInfo { + /// Absolute path currently in use (may differ from `configured` if it was + /// unset/fell back to the default). + pub current: String, + /// Absolute path that will be used after the next restart, if different. + pub configured: Option, + /// The OS-default location ($APPDATA/dm-toolkit). + pub default: String, +} + +/// Return the current data dir + the configured (pending-restart) value, so +/// Settings can show "using X — restart to apply Y". +#[tauri::command] +pub fn get_data_dir( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let default = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("dm-toolkit"); + let configured = app + .store(PREFS_STORE) + .map_err(|e| e.to_string())? + .get(DATA_DIR_KEY) + .and_then(|v| v.as_str().map(|s| s.to_string())); + Ok(DataDirInfo { + current: state.data_dir.display().to_string(), + configured, + default: default.display().to_string(), + }) +} + +/// Subdirs/files that make up the campaign data and must move together. +const DATA_RELATIVE: &[&str] = &["lore/lore.db", "generations.db", "images"]; + +/// Recursively copy `src` to `dst`, but never overwrite an existing file — +/// a re-locate must not clobber data already at the destination, and never +/// deletes the source (the old location stays as a backup until the DM deletes it). +/// When both src and dst are directories, recurse so new files inside an +/// existing destination tree still get copied. +fn copy_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> { + if !src.exists() { + return Ok(()); + } + if src.is_dir() { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + copy_if_absent(&entry.path(), &dst.join(entry.file_name()))?; + } + return Ok(()); + } + // leaf file: skip if the destination already has it (no overwrite). + if dst.exists() { + return Ok(()); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst)?; + Ok(()) +} + +/// Set the data dir to `new_dir`: create it, copy existing campaign data +/// (lore.db, generations.db, images/) into it, and persist the preference so +/// the next restart uses it. The currently-open SQLite connections stay on the +/// old paths until restart — the caller must prompt the user to relaunch. +#[tauri::command] +pub fn set_data_dir( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + new_dir: String, +) -> Result { + let new_path = PathBuf::from(new_dir.trim()); + if !new_path.is_absolute() { + return Err("Choose an absolute folder path".into()); + } + std::fs::create_dir_all(&new_path).map_err(|e| format!("can't create dir: {e}"))?; + + // Copy existing data into the new location (no overwrite; old data stays put). + for rel in DATA_RELATIVE { + let src = state.data_dir.join(rel); + let dst = new_path.join(rel); + if src.exists() { + copy_if_absent(&src, &dst).map_err(|e| format!("migrate {rel}: {e}"))?; + } + } + + // Persist the preference for the next startup. + let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?; + store.set(DATA_DIR_KEY, serde_json::Value::String(new_path.display().to_string())); + store.save().map_err(|e| e.to_string())?; + + Ok(DataDirInfo { + current: state.data_dir.display().to_string(), + configured: Some(new_path.display().to_string()), + default: app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("dm-toolkit") + .display() + .to_string(), + }) +} + +/// Clear the preference so the next restart uses the OS default. Does not +/// delete any files — the DM re-points, then restarts. +#[tauri::command] +pub fn reset_data_dir(app: tauri::AppHandle) -> Result<(), String> { + let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?; + store.delete(DATA_DIR_KEY); + store.save().map_err(|e| e.to_string())?; + Ok(()) +} + +// ─── SQLite viewer (read-only) ───────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct SqlQueryReq { + /// "lore" or "generations". + pub db: String, + pub sql: String, +} + +#[derive(Debug, Serialize)] +pub struct SqlQueryResult { + pub columns: Vec, + pub rows: Vec>, +} + +/// Run a read-only SQL query against one of the two SQLite DBs so a DM can +/// inspect the backend from Settings → Advanced. Only SELECT/PRAGMA/WITH. +#[tauri::command] +pub fn sql_query(state: tauri::State<'_, AppState>, req: SqlQueryReq) -> Result { + let res = match req.db.as_str() { + "lore" => state.rag.raw_query(&req.sql), + "generations" => state.gen.raw_query(&req.sql), + other => Err(anyhow::anyhow!("unknown db: {other}")), + } + .map_err(|e| e.to_string())?; + Ok(SqlQueryResult { columns: res.0, rows: res.1 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copy_if_absent_does_not_overwrite() { + let tmp = std::env::temp_dir().join(format!("dm-pal-data-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(src.join("images")).unwrap(); + std::fs::write(src.join("images/a.png"), b"png").unwrap(); + std::fs::create_dir_all(src.join("lore")).unwrap(); + std::fs::write(src.join("lore/lore.db"), b"db").unwrap(); + + // first copy creates dst with the files + copy_if_absent(&src, &dst).unwrap(); + assert!(dst.join("images/a.png").exists()); + assert!(dst.join("lore/lore.db").exists()); + + // mutate src; second copy must NOT overwrite existing dst files + std::fs::write(src.join("images/a.png"), b"changed").unwrap(); + copy_if_absent(&src, &dst).unwrap(); + assert_eq!(std::fs::read(dst.join("images/a.png")).unwrap(), b"png"); + + // a new file in src does get copied (absent in dst) + std::fs::write(src.join("images/b.png"), b"png2").unwrap(); + copy_if_absent(&src, &dst).unwrap(); + assert!(dst.join("images/b.png").exists()); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn copy_if_absent_missing_src_is_noop() { + let tmp = std::env::temp_dir().join(format!("dm-pal-data-test2-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + copy_if_absent(&tmp.join("nope"), &tmp.join("dst")).unwrap(); + assert!(!tmp.join("dst").exists()); + let _ = std::fs::remove_dir_all(&tmp); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/image_commands.rs b/src-tauri/src/commands/image_commands.rs index 0ff3447..7498adb 100644 --- a/src-tauri/src/commands/image_commands.rs +++ b/src-tauri/src/commands/image_commands.rs @@ -1,8 +1,9 @@ use crate::llm::AppState; -use serde::Deserialize; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; -use tauri::Manager; +use tauri::ipc::Channel; // ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key. @@ -14,14 +15,57 @@ pub struct ImageRequest { } /// One line of Ollama's NDJSON image-generation response. -#[derive(Debug, Deserialize)] +/// `step`/`total` carry progress; the final `done: true` line carries the +/// singular `image` base64 field. All fields optional per-line. +#[derive(Debug, Default, Deserialize)] struct OllamaImageLine { #[serde(default)] done: bool, #[serde(default)] + step: Option, + #[serde(default)] + total: Option, + #[serde(default)] image: Option, } +/// Parsed view of one NDJSON line used by both the streaming and the +/// buffered paths. Extracted so the parsing logic is unit-testable without +/// touching the network. +#[derive(Debug, PartialEq)] +struct ImageProgress { + done: bool, + step: Option, + total: Option, + image: Option, +} + +fn parse_image_line(line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let parsed: OllamaImageLine = serde_json::from_str(line).ok()?; + Some(ImageProgress { + done: parsed.done, + step: parsed.step, + total: parsed.total, + image: parsed.image, + }) +} + +/// Channel events for streaming image generation. +#[derive(Clone, Serialize)] +#[serde(tag = "type", content = "data", rename_all = "camelCase")] +pub enum ImageEvent { + /// Progress update: (step, total). Either may be None if Ollama omits it. + Progress { step: Option, total: Option }, + /// Final result: a `data:image/png;base64,...` URL ready for ``. + Done(String), + /// Fatal error. + Error(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 ``. /// @@ -29,7 +73,6 @@ struct OllamaImageLine { /// 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 { @@ -40,34 +83,92 @@ pub async fn generate_image( 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 (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; + if cache_hit { let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; return Ok(data_url(&bytes)); } + let png_bytes = request_image_bytes(&config, &model, &req.prompt, None).await?; + std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?; + Ok(data_url(&png_bytes)) +} + +/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`, +/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache +/// as `generate_image`. The DM gets a real progress bar for the multi-second wait. +#[tauri::command] +pub async fn generate_image_stream( + state: tauri::State<'_, AppState>, + req: ImageRequest, + channel: Channel, +) -> Result<(), String> { + if cfg!(not(target_os = "macos")) { + let _ = channel.send(ImageEvent::Error( + "image generation is macOS-only via Ollama (for now)".into(), + )); + return Ok(()); + } + + let config = state.config.lock().map_err(|e| e.to_string())?.clone(); + let model = req.model.unwrap_or(config.image_model.clone()); + + let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; + if cache_hit { + let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?; + let _ = channel.send(ImageEvent::Done(data_url(&bytes))); + return Ok(()); + } + + // Drive the request on a background task so the command returns immediately + // and progress flows through the channel. Errors become ImageEvent::Error. + let channel = std::sync::Arc::new(channel); + let ch = channel.clone(); + tauri::async_runtime::spawn(async move { + match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await { + Ok(png_bytes) => { + let _ = std::fs::write(&cache_path, &png_bytes); + let _ = channel.send(ImageEvent::Done(data_url(&png_bytes))); + } + Err(e) => { + let _ = channel.send(ImageEvent::Error(e)); + } + } + }); + + Ok(()) +} + +/// Resolve the on-disk cache path for (model, prompt) and report a cache hit. +/// `base` is the configured data dir (AppState.data_dir). +fn prepare_cache( + base: &std::path::Path, + model: &str, + prompt: &str, +) -> Result<(std::path::PathBuf, bool), String> { + let cache_dir = base.join("images"); + std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; + + let mut hasher = DefaultHasher::new(); + model.hash(&mut hasher); + prompt.hash(&mut hasher); + let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish())); + let hit = cache_path.exists(); + Ok((cache_path, hit)) +} + +/// POST the image request to Ollama and collect the PNG bytes. When a channel +/// is given, parse the NDJSON body incrementally and emit progress; otherwise +/// read the whole body at once (legacy buffered path). +async fn request_image_bytes( + config: &crate::llm::LlmConfig, + model: &str, + prompt: &str, + channel: Option>>, +) -> Result, String> { 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 body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false }); let res = client .post(&url) @@ -82,19 +183,47 @@ pub async fn generate_image( 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}"))?; + // ponytail: Ollama image models emit NDJSON even with stream:false — + // progress lines (step/total) then a final done:true carrying the image. + // The buffered path reads it all at once; the streaming path splits lines + // as they arrive so the bar animates. let mut png_b64: Option = None; - for line in body_text.lines() { - let line = line.trim(); - if line.is_empty() { - continue; + let mut buffer = String::new(); + + let apply_line = |line: &str, png: &mut Option, ch: Option<&Channel>| { + if let Some(p) = parse_image_line(line) { + if let Some(c) = ch { + let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total }); + } + if let Some(b64) = p.image { + *png = Some(b64); + } } - if let Ok(parsed) = serde_json::from_str::(line) { - if let Some(b64) = parsed.image { - png_b64 = Some(b64); - if parsed.done { + }; + + if let Some(ch) = channel { + let mut stream = res.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("image read error: {e}"))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + // Process complete lines; keep the trailing partial line in buffer. + while let Some(idx) = buffer.find('\n') { + let line = buffer.split_off(idx + 1); + let complete = std::mem::replace(&mut buffer, line); + apply_line(&complete, &mut png_b64, Some(&ch)); + } + } + if !buffer.trim().is_empty() { + apply_line(&buffer, &mut png_b64, Some(&ch)); + } + } else { + let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?; + for line in body_text.lines() { + apply_line(line, &mut png_b64, None); + // ponytail: stop after the done line in the buffered path — the + // final image is the one we want. + if let Some(p) = parse_image_line(line) { + if p.done && p.image.is_some() { break; } } @@ -102,11 +231,8 @@ pub async fn generate_image( } 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)) + Ok(png_bytes) } fn data_url(png: &[u8]) -> String { @@ -115,7 +241,7 @@ fn data_url(png: &[u8]) -> String { 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; + 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); @@ -200,4 +326,48 @@ mod tests { let s = format!("{:016x}", h.finish()); assert_eq!(s.len(), 16); } + + #[test] + fn parse_progress_and_image_lines() { + // intermediate progress line, no image + let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap(); + assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None }); + + // final done line carries the image + let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA"}"#).unwrap(); + assert!(p.done); + assert_eq!(p.image.as_deref(), Some("iVBORw0KGgoAAAANSUhEUgAA")); + assert!(p.total.is_none() && p.step.is_none()); + + // blank/garbage lines are ignored, not errors + assert!(parse_image_line("").is_none()); + assert!(parse_image_line("not json").is_none()); + } + + #[test] + fn split_ndjson_buffer_keeps_trailing_partial() { + // Simulate two chunks arriving separately where the split falls mid-line. + let chunk1 = "{\"done\":false,\"step\":1,\"total\":4}\n{\"done\":tru"; + let chunk2 = "e,\"image\":\"abc\"}\n"; + let mut buffer = String::new(); + let mut png: Option = None; + let whole = format!("{chunk1}{chunk2}"); + // emulate the streaming loop over the concatenated body + buffer.push_str(&whole); + let mut lines = Vec::new(); + while let Some(idx) = buffer.find('\n') { + let line = buffer.split_off(idx + 1); + let complete = std::mem::replace(&mut buffer, line); + lines.push(complete); + } + if !buffer.trim().is_empty() { + lines.push(std::mem::take(&mut buffer)); + } + for line in &lines { + if let Some(p) = parse_image_line(line) { + if let Some(b) = p.image { png = Some(b); } + } + } + assert_eq!(png.as_deref(), Some("abc")); + } } \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 68a2f9d..945cc72 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod llm_commands; pub mod image_commands; pub mod rag_commands; -pub mod generation_commands; \ No newline at end of file +pub mod generation_commands; +pub mod data_commands; \ No newline at end of file diff --git a/src-tauri/src/commands/rag_commands.rs b/src-tauri/src/commands/rag_commands.rs index 4f1049f..1f6e149 100644 --- a/src-tauri/src/commands/rag_commands.rs +++ b/src-tauri/src/commands/rag_commands.rs @@ -55,4 +55,23 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result, Str #[tauri::command] pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option) -> Result<(), String> { state.rag.clear(source.as_deref()).map_err(|e| e.to_string()) +} + +#[derive(Debug, Serialize)] +pub struct RagChunk { + pub id: i64, + pub preview: String, +} + +/// Preview the chunks stored under `source` (first 200 chars each). Lets the +/// DM verify what actually got embedded without dumping the whole corpus. +#[tauri::command] +pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result, String> { + state + .rag + .list_chunks(&source) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(id, preview)| Ok(RagChunk { id, preview })) + .collect() } \ No newline at end of file diff --git a/src-tauri/src/generations/mod.rs b/src-tauri/src/generations/mod.rs index c7dcd1a..031b022 100644 --- a/src-tauri/src/generations/mod.rs +++ b/src-tauri/src/generations/mod.rs @@ -144,6 +144,15 @@ impl GenerationStore { } Ok(out) } + + /// Read-only SQL pass-through for the advanced SQLite viewer. + pub fn raw_query( + &self, + sql: &str, + ) -> anyhow::Result<(Vec, Vec>)> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + crate::sql_viewer::run_readonly_query(&db, sql) + } } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d27b88b..f5c1c9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,10 +2,37 @@ mod llm; mod rag; mod generations; mod commands; +mod sql_viewer; use llm::AppState; use std::sync::Mutex; use tauri::Manager; +use tauri_plugin_store::StoreExt; + +// ponytail: the data-dir preference lives in a tiny store at the OS default +// app_data_dir so it's always discoverable at startup, even before we know the +// custom location. Key: `dataDir` (absolute path). Empty/missing → default. +const PREFS_STORE: &str = "dm-pal-prefs.json"; +const DATA_DIR_KEY: &str = "dataDir"; + +/// Resolve the data dir: the configured one if set and usable, else the +/// default `$APPDATA/dm-toolkit`. +fn resolve_data_dir(app: &tauri::AppHandle) -> std::path::PathBuf { + let default = app + .path() + .app_data_dir() + .expect("app data dir") + .join("dm-toolkit"); + if let Ok(store) = app.store(PREFS_STORE) { + if let Some(Some(v)) = store.get(DATA_DIR_KEY).map(|v| v.as_str().map(|s| s.to_string())) { + let p = std::path::PathBuf::from(v); + // ponytail: fall back to default if the configured path is gone — + // a DM who unmounts the external drive shouldn't lose the app. + if p.is_absolute() { return p; } + } + } + default +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -13,12 +40,9 @@ pub fn run() { .plugin(tauri_plugin_log::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { - let data_dir = app - .path() - .app_data_dir() - .expect("app data dir") - .join("dm-toolkit"); + let data_dir = resolve_data_dir(&app.handle()); let rag = rag::RagStore::open(&data_dir.join("lore")) .expect("open lore db"); let gen = generations::GenerationStore::open(&data_dir) @@ -27,6 +51,7 @@ pub fn run() { config: Mutex::new(llm::LlmConfig::default()), rag, gen, + data_dir, }); Ok(()) }) @@ -38,15 +63,21 @@ pub fn run() { commands::llm_commands::set_llm_config, commands::llm_commands::test_connection, commands::image_commands::generate_image, + commands::image_commands::generate_image_stream, commands::rag_commands::rag_add, commands::rag_commands::rag_search, commands::rag_commands::rag_list, commands::rag_commands::rag_clear, + commands::rag_commands::rag_chunks, 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, + commands::data_commands::get_data_dir, + commands::data_commands::set_data_dir, + commands::data_commands::reset_data_dir, + commands::data_commands::sql_query, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index e9d4334..8cc099f 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -17,6 +17,10 @@ pub struct AppState { pub config: Mutex, pub rag: crate::rag::RagStore, pub gen: crate::generations::GenerationStore, + /// Where lore.db, generations.db, and cached images live. Configurable via + /// the `dataDir` key in the `dm-pal-prefs.json` store; defaults to + /// `$APPDATA/dm-toolkit` when unset. Set once at startup. + pub data_dir: std::path::PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/rag/mod.rs b/src-tauri/src/rag/mod.rs index fb6fc00..96d717b 100644 --- a/src-tauri/src/rag/mod.rs +++ b/src-tauri/src/rag/mod.rs @@ -179,6 +179,30 @@ impl RagStore { } Ok(()) } + + /// Preview chunks for a source: (id, first 200 chars of text). Used by the + /// Lore panel so a DM can see what actually got embedded without a full dump. + pub fn list_chunks(&self, source: &str) -> anyhow::Result> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + let mut stmt = db.prepare("SELECT id, substr(text, 1, 200) FROM chunks WHERE source = ? ORDER BY id")?; + let rows = stmt.query_map(params![source], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Read-only SQL pass-through for the advanced SQLite viewer. + pub fn raw_query( + &self, + sql: &str, + ) -> anyhow::Result<(Vec, Vec>)> { + let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; + crate::sql_viewer::run_readonly_query(&db, sql) + } } #[cfg(test)] diff --git a/src-tauri/src/sql_viewer.rs b/src-tauri/src/sql_viewer.rs new file mode 100644 index 0000000..f0954b5 --- /dev/null +++ b/src-tauri/src/sql_viewer.rs @@ -0,0 +1,111 @@ +use rusqlite::Connection; +use rusqlite::types::Value as SqlValue; +use serde_json::Value as JsonValue; + +// ponytail: a read-only SQL viewer for the two SQLite DBs (lore.db, +// generations.db). We gate on the statement starting with SELECT / PRAGMA / +// WITH so a curious DM can inspect the backend but can't mutate it from the +// UI. Enough for "what's going on in the backend" without a footgun. + +/// Run a read-only query against `conn`. Returns (column names, rows). +pub fn run_readonly_query( + conn: &Connection, + sql: &str, +) -> anyhow::Result<(Vec, Vec>)> { + let trimmed = sql.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() { + return Ok((Vec::new(), Vec::new())); + } + let upper = trimmed.to_uppercase(); + let allowed = upper.starts_with("SELECT") + || upper.starts_with("PRAGMA") + || upper.starts_with("WITH"); + if !allowed { + anyhow::bail!("Only read-only queries (SELECT / PRAGMA / WITH) are allowed"); + } + + let mut stmt = conn.prepare(trimmed)?; + let col_count = stmt.column_count(); + let columns: Vec = (0..col_count) + .map(|i| stmt.column_name(i).unwrap_or("?").to_string()) + .collect(); + let rows = stmt.query_map([], |row| { + let mut vals = Vec::with_capacity(col_count); + for i in 0..col_count { + let v: SqlValue = row.get(i)?; + vals.push(sql_to_json(v)); + } + Ok(vals) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok((columns, out)) +} + +fn sql_to_json(v: SqlValue) -> JsonValue { + match v { + SqlValue::Null => JsonValue::Null, + SqlValue::Integer(i) => i.into(), + SqlValue::Real(f) => f.into(), + SqlValue::Text(s) => JsonValue::String(s), + // ponytail: blobs aren't useful to render raw — show a size hint so the + // DM knows there's data without dumping megabytes of embedding bytes. + SqlValue::Blob(b) => JsonValue::String(format!("", b.len())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem() -> Connection { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, weight REAL);").unwrap(); + c.execute("INSERT INTO t (name, weight) VALUES (?1, ?2), (?1, ?2)", rusqlite::params!["goblin", 23.5]).unwrap(); + c + } + + #[test] + fn select_returns_columns_and_rows() { + let (cols, rows) = run_readonly_query(&mem(), "SELECT id, name, weight FROM t ORDER BY id").unwrap(); + assert_eq!(cols, vec!["id".to_string(), "name".into(), "weight".into()]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0][1], serde_json::json!("goblin")); + assert_eq!(rows[0][2], serde_json::json!(23.5)); + } + + #[test] + fn rejects_writes() { + let c = mem(); + let err = run_readonly_query(&c, "DELETE FROM t").unwrap_err(); + assert!(err.to_string().contains("read-only")); + let err = run_readonly_query(&c, "INSERT INTO t (name, weight) VALUES ('x', 1)").unwrap_err(); + assert!(err.to_string().contains("read-only")); + let err = run_readonly_query(&c, "UPDATE t SET name='x'").unwrap_err(); + assert!(err.to_string().contains("read-only")); + } + + #[test] + fn pragma_table_info_allowed() { + let (cols, rows) = run_readonly_query(&mem(), "PRAGMA table_info(t)").unwrap(); + assert!(cols.contains(&"name".to_string())); + assert!(rows.iter().any(|r| r.contains(&serde_json::json!("id")))); + } + + #[test] + fn with_cte_allowed() { + let (_cols, rows) = run_readonly_query(&mem(), "WITH x AS (SELECT 1 AS n) SELECT * FROM x").unwrap(); + assert_eq!(rows.len(), 1); + } + + #[test] + fn empty_and_trailing_semicolon_ok() { + let (cols, rows) = run_readonly_query(&mem(), "SELECT 1 AS n;").unwrap(); + assert_eq!(cols, vec!["n"]); + assert_eq!(rows.len(), 1); + let Ok((c, r)) = run_readonly_query(&mem(), " ") else { panic!("empty query failed") }; + assert!(c.is_empty() && r.is_empty()); + } +} \ No newline at end of file diff --git a/src/components/BentoCard.tsx b/src/components/BentoCard.tsx deleted file mode 100644 index 78d0109..0000000 --- a/src/components/BentoCard.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { motion } from "framer-motion"; -import type { ReactNode } from "react"; - -interface BentoCardProps { - title: string; - icon: ReactNode; - span?: string; - children: ReactNode; - className?: string; -} - -// ponytail: callers pass responsive span classes directly (e.g. -// "sm:col-span-2"). No string regex — the old replace() mangled pre-responsive -// classes and silently dropped unknown spans like col-span-3. - -export function BentoCard({ - title, - icon, - span = "", - children, - className = "", -}: BentoCardProps) { - return ( - - {/* Header */} -
- {icon} -

- {title} -

-
- {/* Body */} -
{children}
-
- ); -} \ No newline at end of file diff --git a/src/components/CalendarWidget.tsx b/src/components/CalendarWidget.tsx index 6d03250..076c6c6 100644 --- a/src/components/CalendarWidget.tsx +++ b/src/components/CalendarWidget.tsx @@ -1,60 +1,116 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { usePersistentState } from "../lib/usePersistentState"; -const MONTHS = [ - "Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn", - "Flamerule", "Eleasis", "Eleint", "Marpenoth", "Uktar", "Nightal", +// ponytail: the calendar is user-configurable for homebrew campaigns. +// `months` carry their own day count so a custom calendar can have variable +// month lengths. Default = the Forgotten Realms calendar (12 × 30). +interface CalMonth { + name: string; + days: number; +} +interface CalConfig { + months: CalMonth[]; + yearLabel: string; // suffix shown after the year, e.g. "DR" +} + +const FR_MONTHS: CalMonth[] = [ + { name: "Hammer", days: 30 }, { name: "Alturiak", days: 30 }, { name: "Ches", days: 30 }, + { name: "Tarsakh", days: 30 }, { name: "Mirtul", days: 30 }, { name: "Kythorn", days: 30 }, + { name: "Flamerule", days: 30 }, { name: "Eleasis", days: 30 }, { name: "Eleint", days: 30 }, + { name: "Marpenoth", days: 30 }, { name: "Uktar", days: 30 }, { name: "Nightal", days: 30 }, ]; +const DEFAULT_CONFIG: CalConfig = { months: FR_MONTHS, yearLabel: "DR" }; + +// ponytail: named categories with fixed colors so events are color-coded by +// intent, not cycled arbitrarily. A DM picks the category when creating. +const CATEGORIES = [ + { label: "Festival", color: "#e6cc80" }, + { label: "Quest", color: "var(--color-gold-bright)" }, + { label: "NPC", color: "var(--color-info)" }, + { label: "Political", color: "#a335ee" }, + { label: "Combat", color: "var(--color-danger)" }, + { label: "Custom", color: "var(--color-success)" }, +]; +const catColor = (cat: string) => CATEGORIES.find((c) => c.label === cat)?.color ?? "var(--color-gold-bright)"; + +type Recur = "none" | "yearly" | "monthly"; interface CalendarEvent { day: number; month: number; text: string; - color: string; + category: string; + recur: Recur; } -const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-success)", "var(--color-danger)"]; +function yearLen(cfg: CalConfig): number { + return cfg.months.reduce((s, m) => s + m.days, 0); +} +function daysInMonth(cfg: CalConfig, m: number): number { + return cfg.months[m]?.days ?? 30; +} +function monthName(cfg: CalConfig, m: number): string { + return cfg.months[m]?.name ?? `Month ${m + 1}`; +} +// absolute day index for a (day,month,year) under a variable-length calendar. +function dayIndex(day: number, month: number, year: number, cfg: CalConfig): number { + let idx = year * yearLen(cfg); + for (let i = 0; i < month; i++) idx += cfg.months[i].days; + return idx + (day - 1); +} +// inverse of dayIndex. +function fromIndex(idx: number, cfg: CalConfig): { day: number; month: number; year: number } { + const yl = yearLen(cfg); + let year = Math.floor(idx / yl); + let rem = idx % yl; + let month = 0; + while (month < cfg.months.length && rem >= cfg.months[month].days) { + rem -= cfg.months[month].days; + month++; + } + return { day: rem + 1, month, year }; +} export function CalendarWidget() { + const [config, setConfig] = usePersistentState("calendar.config", DEFAULT_CONFIG); const [month, setMonth] = useState(0); const [year, setYear] = useState(1492); const [events, setEvents] = usePersistentState("calendar.events", []); const [selectedDay, setSelectedDay] = useState(null); const [newEvent, setNewEvent] = useState(""); + const [newCategory, setNewCategory] = useState(CATEGORIES[0].label); + const [newRecur, setNewRecur] = useState("none"); + const [view, setView] = useState<"month" | "agenda" | "edit">("month"); // ponytail: the campaign's current date, persisted so it survives reloads. - // Advances with the "Next day" button; "Today" jumps the view to it. const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>( "calendar.today", { day: 15, month: 5, year: 1492 }, ); - // ponytail: 8 moon phases cycling on a ~30-day lunar cycle. Derived from - // a continuous day index so the phase is stable for any date. const MOON_PHASES = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"]; + // ponytail: 8 moon phases on a 30-day cycle, derived from the absolute day + // index so the phase is stable for any date under any calendar config. function moonPhase(day: number, month: number, year: number): string { - const idx = Math.floor(((year * 360 + month * 30 + (day - 1)) % 30) / 30 * 8); - return MOON_PHASES[((idx % 8) + 8) % 8]; + const idx = dayIndex(day, month, year, config); + const p = Math.floor(((idx % 30) / 30) * 8); + return MOON_PHASES[((p % 8) + 8) % 8]; } - // ponytail: deterministic weather per day (stable for a given date). - // A simple PRNG seeded by the date index so the forecast doesn't reshuffle - // every render. Coarse by design — a DM can override narratively. const WEATHER = [ "☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm", "❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural", ]; function weatherFor(day: number, month: number, year: number): string { - const seed = (year * 360 + month * 30 + (day - 1)) % 97; - return WEATHER[seed % WEATHER.length]; + const idx = dayIndex(day, month, year, config); + return WEATHER[Math.abs(idx) % WEATHER.length]; } - // Advance the campaign date by one day (30-day months, 12-month year). function nextDay() { setToday((t) => { let { day, month, year } = t; day += 1; - if (day > 30) { day = 1; month += 1; } - if (month > 11) { month = 0; year += 1; } + if (day > daysInMonth(config, month)) { day = 1; month += 1; } + if (month >= config.months.length) { month = 0; year += 1; } return { day, month, year }; }); } @@ -69,7 +125,7 @@ export function CalendarWidget() { if (!newEvent.trim() || selectedDay === null) return; setEvents((prev) => [ ...prev, - { day: selectedDay, month, text: newEvent.trim(), color: COLORS[events.length % COLORS.length] }, + { day: selectedDay, month, text: newEvent.trim(), category: newCategory, recur: newRecur }, ]); setNewEvent(""); } @@ -78,130 +134,303 @@ export function CalendarWidget() { setEvents((prev) => prev.filter((_, i) => i !== idx)); } - const dayEvents = events.filter((e) => e.day === selectedDay && e.month === month); + // ponytail: does an event fall on a given (day,month)? Recurring events + // match on month (yearly) or every month (monthly). One-off events match + // day+month only — we don't store the creation year, so a one-off repeats + // visually each year. Acceptable for a DM calendar; store year if it bites. + function eventOn(e: CalendarEvent, day: number, m: number): boolean { + if (e.recur === "monthly") return e.day === day; + if (e.recur === "yearly") return e.day === day && e.month === m; + return e.day === day && e.month === m; + } + + const dayEvents = useMemo( + () => events.filter((e) => e.day === selectedDay && (e.recur === "monthly" || e.month === month)), + [events, selectedDay, month], + ); + + // ponytail: agenda — the next N events from today forward, recurring expanded. + const agenda = useMemo(() => { + const startIdx = dayIndex(today.day, today.month, today.year, config); + const upcoming: { e: CalendarEvent; day: number; month: number; year: number }[] = []; + const scan = 2 * yearLen(config); // ~2 years + for (let off = 0; off < scan && upcoming.length < 12; off++) { + const { day, month: dm, year: dy } = fromIndex(startIdx + off, config); + for (const e of events) { + if (eventOn(e, day, dm)) upcoming.push({ e, day, month: dm, year: dy }); + } + } + return upcoming; + }, [events, today, config]); + + const dim = daysInMonth(config, month); return (
- {/* Month navigation */} + {/* View toggle */}
- -
-
- {MONTHS[month]} -
-
{year} DR
-
- -
- - {/* Campaign date controls */} -
- - Today: {MONTHS[today.month]} {today.day}, {today.year} DR {moonPhase(today.day, today.month, today.year)} -
+
+ + Today: {monthName(config, today.month)} {today.day}, {today.year} {config.yearLabel} {moonPhase(today.day, today.month, today.year)} +
- {/* Day grid */} -
- {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( -
- {d} + {view === "edit" ? ( +
+
+ Edit months, day counts, and the year label. Changes apply immediately.
- ))} - {/* Start offset - Faerûn calendar starts on first of month */} - {Array.from({ length: 30 }, (_, i) => { - const dayNum = i + 1; - const isToday = dayNum === today.day && month === today.month && year === today.year; - const hasEvent = events.some((e) => e.day === dayNum && e.month === month); - const isSelected = dayNum === selectedDay; - - return ( +
+ + setConfig({ ...config, yearLabel: e.target.value })} + placeholder="DR" + /> + → shown as “{year} {config.yearLabel}” +
+
+ {config.months.map((m, i) => ( +
+ {i + 1} + setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })} + /> + setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, days: Math.max(1, parseInt(e.target.value) || 1) } : x) })} + /> + +
+ ))} +
+
+ + - ); - })} -
- - {/* Events for selected day */} - {selectedDay !== null && ( -
-
-
- {MONTHS[month]} {selectedDay} -
- - {weatherFor(selectedDay, month, year)} -
- {dayEvents.length === 0 && ( -
No events
+
+ ) : view === "month" ? ( + <> + {/* Month navigation */} +
+ +
+
+ {monthName(config, month)} +
+
{year} {config.yearLabel}
+
+ +
+ +
+ + +
+ + {/* Day grid */} +
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( +
+ {d} +
+ ))} + {Array.from({ length: dim }, (_, i) => { + const dayNum = i + 1; + const isToday = dayNum === today.day && month === today.month && year === today.year; + const dayEvts = events.filter((e) => e.day === dayNum && (e.recur === "monthly" || e.month === month)); + const isSelected = dayNum === selectedDay; + return ( + + ); + })} +
+ + {/* Events for selected day */} + {selectedDay !== null && ( +
+
+
+ {monthName(config, month)} {selectedDay} +
+ + {weatherFor(selectedDay, month, year)} + +
+ {dayEvents.length === 0 && ( +
No events
+ )} + {dayEvents.map((e, i) => ( +
+ + {e.text} + {e.recur !== "none" && ( + ↻{e.recur === "monthly" ? "m" : "y"} + )} + +
+ ))} +
+ setNewEvent(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addEvent()} + placeholder="Add event…" + /> +
+ + + +
+
+
)} - {dayEvents.map((e, i) => ( -
- - {e.text} - + + ) : ( + // Agenda view — next events from today forward. +
+
+ Upcoming events +
+ {agenda.length === 0 && ( +
No upcoming events.
+ )} + {agenda.map((a, i) => ( +
+ + + {monthName(config, a.month).slice(0, 3)} {a.day} + + {a.e.text} + {a.e.recur !== "none" && ( + ↻{a.e.recur === "monthly" ? "m" : "y"} + )}
))} -
- setNewEvent(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addEvent()} - placeholder="Add event…" - /> +
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index a7e825c..76d8274 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -12,10 +12,9 @@ import { Shuffle, Flag, ScrollText, - ArrowRight, + type LucideIcon, } from "lucide-react"; import { useEffect, useState } from "react"; -import { BentoCard } from "./BentoCard"; import type { View } from "../App"; import { listGenerations, @@ -28,111 +27,61 @@ interface DashboardProps { onNavigate: (view: View) => void; } -// ponytail: launcher cards — icon, title, one-line description, Open CTA. -// No embedded mini-tools: the dashboard is a launcher, not an ant farm. -interface Card { - icon: typeof Map; +// ponytail: a launcher tile is ONE button — the whole card is the click target +// (the old dashboard only wired the tiny "Open" link, so the boxes felt dead). +// No redundant double-header; just icon, title, one-line description, and an +// optional "last used" footer. Grouped into sections so 13 tools stop being a +// wall of identical tiles. +interface Tile { + icon: LucideIcon; title: string; description: string; view: View; - span?: string; - // when set, the card shows "Last: {title} · {relativeTime}" from history. kind?: GenerationKind; } -const CARDS: Card[] = [ +interface Section { + label: string; + hint: string; + tiles: Tile[]; +} + +const SECTIONS: Section[] = [ { - icon: Map, - title: "World Builder", - description: "Generate a world with regions, landmarks, and conflicts.", - view: "world", - span: "sm:col-span-2", - kind: "world", + label: "At the table", + hint: "Live-session tools", + tiles: [ + { icon: Timer, title: "Initiative", description: "Track combatants, HP, conditions, and turns.", view: "initiative" }, + { icon: Dice5, title: "Dice Roller", description: "Roll with advantage, modifiers, and templates.", view: "dice" }, + { icon: Volume2, title: "Soundboard", description: "Synthesized ambience and SFX.", view: "sound" }, + { icon: ScrollText, title: "Session Log", description: "Take notes and generate an AI summary.", view: "session", kind: "session" }, + ], }, { - icon: Timer, - title: "Initiative", - description: "Track combatants, HP, conditions, and turn order.", - view: "initiative", - kind: undefined, + label: "Prep & create", + hint: "Generators for your next session", + tiles: [ + { icon: Swords, title: "Encounter", description: "Build balanced encounters with an XP budget.", view: "encounter", kind: "encounter" }, + { icon: User, title: "NPC Generator", description: "Generate NPCs with portraits and stat blocks.", view: "npcs", kind: "npc" }, + { icon: Flag, title: "Quest Designer", description: "Multi-step quests with twists and rewards.", view: "quest", kind: "quest" }, + { icon: Wand2, title: "Item Forge", description: "Forge magic items with art and mechanics.", view: "items", kind: "item" }, + { icon: ImagePlus, title: "Image Generator", description: "Portraits, maps, and scene art (macOS).", view: "image", kind: "image" }, + ], }, { - icon: Dice5, - title: "Dice Roller", - description: "Roll with advantage, modifiers, and roll templates.", - view: "dice", - }, - { - icon: Swords, - title: "Encounter", - description: "Build balanced encounters with an XP budget.", - view: "encounter", - kind: "encounter", - }, - { - icon: User, - title: "NPC Generator", - description: "Generate NPCs with portraits, personality, and goals.", - view: "npcs", - kind: "npc", - }, - { - icon: Flag, - title: "Quest Designer", - description: "Design multi-step quests with twists and rewards.", - view: "quest", - kind: "quest", - }, - { - icon: Wand2, - title: "Item Forge", - description: "Forge magic items with art and mechanics.", - view: "items", - kind: "item", - }, - { - icon: ScrollText, - title: "Session Log", - description: "Take notes and generate an AI session summary.", - view: "session", - kind: "session", - }, - { - icon: BookOpen, - title: "Lore (RAG)", - description: "Index your world bible and ground generations in it.", - view: "lore", - }, - { - icon: Calendar, - title: "Calendar", - description: "Track the in-world date, events, and moon phases.", - view: "calendar", - }, - { - icon: Shuffle, - title: "Random Tables", - description: "Roll on built-in and custom random tables.", - view: "tables", - }, - { - icon: ImagePlus, - title: "Image Generator", - description: "Generate portraits, maps, and scene art (macOS).", - view: "image", - span: "sm:col-span-2", - kind: "image", - }, - { - icon: Volume2, - title: "Soundboard", - description: "Synthesized ambience and SFX for the table.", - view: "sound", + label: "World & reference", + hint: "Your campaign bible", + tiles: [ + { icon: Map, title: "World Builder", description: "Generate a world with regions and a map.", view: "world", kind: "world" }, + { icon: BookOpen, title: "Lore (RAG)", description: "Index your world bible and ground generations.", view: "lore" }, + { icon: Calendar, title: "Calendar", description: "Track the in-world date, events, and moon phases.", view: "calendar" }, + { icon: Shuffle, title: "Random Tables", description: "Roll on built-in and custom tables.", view: "tables" }, + ], }, ]; export function Dashboard({ onNavigate }: DashboardProps) { - // ponytail: one fetch on mount → most-recent generation per kind, for the + // ponytail: one fetch on mount → newest generation per kind, for the // "Last: …" footer. listGenerations returns newest-first per kind. const [latest, setLatest] = useState>>({}); useEffect(() => { @@ -151,47 +100,62 @@ export function Dashboard({ onNavigate }: DashboardProps) { }, []); return ( -
- {CARDS.map((card) => { - const last = card.kind ? latest[card.kind] : undefined; - return ( - } - span={card.span} - > -
-
-
- -
-
-

- {card.description} -

-
-
- - {last && ( -

- Last:{" "} - {last.title}{" "} - · {relativeTime(last.createdAt)} -

- )} - - +
+
+ {SECTIONS.map((section) => ( +
+
+

+ {section.label} +

+ + {section.hint} + +
- - ); - })} + +
+ {section.tiles.map((tile) => { + const last = tile.kind ? latest[tile.kind] : undefined; + const Icon = tile.icon; + return ( + + ); + })} +
+
+ ))} + +

+ Press ⌘K to jump to any tool. +

+
); } \ No newline at end of file diff --git a/src/components/GeneratedImage.tsx b/src/components/GeneratedImage.tsx index 049b139..6108aee 100644 --- a/src/components/GeneratedImage.tsx +++ b/src/components/GeneratedImage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke, Channel } from "@tauri-apps/api/core"; import { ImageOff } from "lucide-react"; import { useToast } from "./Toast"; @@ -13,17 +13,30 @@ interface Props { aspect?: string; } +// ponytail: matches the Rust ImageEvent enum (tagged union). +type ImageEvent = + | { type: "progress"; data: { step: number | null; total: number | null } } + | { type: "done"; data: string } + | { type: "error"; data: 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. + * + * Uses the streaming command so the DM sees a real progress bar (driven from + * Ollama's NDJSON step/total) during the multi-second wait. The image still + * loads even if no progress events arrive — Done carries the data URL. */ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) { const [dataUrl, setDataUrl] = useState(null); const [loading, setLoading] = useState(false); const [unsupported, setUnsupported] = useState(false); const [expanded, setExpanded] = useState(false); + // ponytail: progress for the in-flight generation. step/total may be null + // if Ollama omits them; we show an indeterminate shimmer then. + const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null); const { addToast } = useToast(); useEffect(() => { @@ -31,22 +44,40 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as let cancelled = false; setLoading(true); setUnsupported(false); - invoke("generate_image", { req: { prompt } }) - .then((url) => { - if (!cancelled) setDataUrl(url); - }) - .catch((e) => { + setProgress(null); + + const channel = new Channel(); + channel.onmessage = (event) => { + if (cancelled) return; + if (event.type === "progress") { + setProgress(event.data); + } else if (event.type === "done") { + setDataUrl(event.data); + setLoading(false); + setProgress(null); + } else if (event.type === "error") { + const msg = String(event.data); + if (msg.includes("macOS-only")) setUnsupported(true); + else addToast(`Image failed: ${msg}`, "error"); + setLoading(false); + setProgress(null); + } + }; + + invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => { + if (!cancelled) { const msg = String(e); - if (!cancelled) { - if (msg.includes("macOS-only")) setUnsupported(true); - else addToast(`Image failed: ${msg}`, "error"); - } - }) - .finally(() => !cancelled && setLoading(false)); + if (msg.includes("macOS-only")) setUnsupported(true); + else addToast(`Image failed: ${msg}`, "error"); + setLoading(false); + setProgress(null); + } + }); + return () => { cancelled = true; }; - }, [prompt, nonce]); + }, [prompt, nonce, addToast]); if (!prompt) return null; @@ -64,10 +95,20 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as } if (loading) { + // ponytail: determinate bar when step/total are known, indeterminate shimmer otherwise. + const pct = progress && progress.step != null && progress.total ? Math.round((progress.step / progress.total) * 100) : null; return (
- Painting… +
+ {pct != null ? ( +
+
+
+ ) : ( + Painting… + )} +
); } diff --git a/src/components/ItemForge.tsx b/src/components/ItemForge.tsx index e3da16d..501ef1c 100644 --- a/src/components/ItemForge.tsx +++ b/src/components/ItemForge.tsx @@ -7,12 +7,20 @@ import { useToast } from "./Toast"; import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { usePrefillEffect } from "../lib/usePrefill"; +interface ItemMechanics { + attunement: string; // "none", "yes", or a condition string + charges: string; // "0" or "N (regen 1d6 per dawn)" + value: string; // "500 gp" + weight: string; // "2 lbs" + effect: string; // one-line effect description +} + interface ItemResponse { name: string; rarity: string; type: string; description: string; - mechanical: string | Record; + mechanical: string | Record | ItemMechanics; lore: string; } @@ -67,11 +75,11 @@ The JSON must have exactly these keys: - "rarity": "${rarity}" (string) - "type": "${itemType}" (string) - "description": a vivid 2-3 sentence physical description (string) -- "mechanical": the item's game mechanics as a single descriptive paragraph (string, NOT an object) +- "mechanical": an object with keys attunement (string: "none", "yes", or a condition), charges (string), value (string), weight (string), and effect (a one-line string describing the item's mechanical effect) - "lore": a 1-2 sentence piece of lore or history (string)`, 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, + max_tokens: 600, ragQuery: `${rarity} ${itemType} ${prompt}`, }, }); @@ -108,6 +116,12 @@ The JSON must have exactly these keys: }; const mechanicalText = item ? flattenValue(item.mechanical) : ""; + // ponytail: structured mechanics if the LLM returned the object form; fall + // back to a paragraph string for older generations. + const mech = item?.mechanical; + const isStructured = + typeof mech === "object" && mech !== null && "attunement" in mech && "effect" in mech; + const structured = isStructured ? (mech as ItemMechanics) : null; const hasParseError = !item && rawResponse; // ponytail: one-click "surprise me" — random rarity + type, no typed prompt. @@ -217,7 +231,24 @@ The JSON must have exactly these keys:
Mechanics -

{mechanicalText}

+ {structured ? ( +
+ {/* ponytail: structured fields — attunement/charges/value/weight + one-line effect */} +
+ Attunement: + {structured.attunement === "none" || !structured.attunement ? "No" : structured.attunement} + Charges: + {structured.charges || "—"} + Value: + {structured.value || "—"} + Weight: + {structured.weight || "—"} +
+

{structured.effect}

+
+ ) : ( +

{mechanicalText}

+ )}
diff --git a/src/components/LorePanel.tsx b/src/components/LorePanel.tsx index 488e3b1..561cf37 100644 --- a/src/components/LorePanel.tsx +++ b/src/components/LorePanel.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { Trash2 } from "lucide-react"; +import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react"; +import { useToast } from "./Toast"; +import { addToLore } from "../lib/lore"; interface RagSource { source: string; @@ -13,6 +15,11 @@ interface RagHit { score: number; } +interface RagChunk { + id: number; + preview: string; +} + export function LorePanel() { const [source, setSource] = useState(""); const [text, setText] = useState(""); @@ -24,6 +31,13 @@ export function LorePanel() { const [hits, setHits] = useState([]); const [searching, setSearching] = useState(false); const [confirmClear, setConfirmClear] = useState(false); + // ponytail: source filter — multi-select chips. Empty set = search all. + const [filterSources, setFilterSources] = useState>(new Set()); + // ponytail: chunk preview — expand a source to see its embedded chunks. + const [expanded, setExpanded] = useState(null); + const [chunks, setChunks] = useState([]); + const [loadingChunks, setLoadingChunks] = useState(false); + const { addToast } = useToast(); async function loadSources() { try { @@ -37,6 +51,25 @@ export function LorePanel() { loadSources(); }, []); + async function loadChunks(s: string) { + setLoadingChunks(true); + try { + setChunks(await invoke("rag_chunks", { source: s })); + } catch (e) { + console.error(e); + } + setLoadingChunks(false); + } + + function toggleSource(s: string) { + setFilterSources((prev) => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); + else next.add(s); + return next; + }); + } + async function add() { if (!source.trim() || !text.trim()) return; setAdding(true); @@ -55,20 +88,43 @@ export function LorePanel() { async function clearOne(s: string) { await invoke("rag_clear", { source: s }); + if (expanded === s) setExpanded(null); loadSources(); } async function clearAll() { await invoke("rag_clear", { source: null }); setConfirmClear(false); + setExpanded(null); loadSources(); } + // ponytail: file upload via native HTML input — no tauri-plugin-dialog + // needed. Reads .md/.txt contents in the webview and indexes each file as + // its own lore source. Multiple files supported. + function onFiles(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + e.target.value = ""; + for (const f of files) { + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result ?? ""); + addToLore(f.name, text).then(() => loadSources()); + }; + reader.onerror = () => addToast(`Failed to read ${f.name}`, "error"); + reader.readAsText(f); + } + if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info"); + } + async function search() { if (!query.trim()) return; setSearching(true); try { - setHits(await invoke("rag_search", { query, topK: 5 })); + const all = await invoke("rag_search", { query, topK: 12 }); + // ponytail: client-side source filter — the backend scans everything, + // we just hide hits from unselected sources. Fine at this corpus size. + setHits(filterSources.size === 0 ? all : all.filter((h) => filterSources.has(h.source))); } catch (e) { console.error(e); } @@ -76,91 +132,138 @@ export function LorePanel() { } return ( -
- {/* Add lore */} -
-

Add to Lore

- setSource(e.target.value)} - placeholder="Source name (e.g. World Bible, Session 3 Notes)" - /> -