building out the components, updating the UI in overview and adding a little sqlite viewer
This commit is contained in:
+5
-5
@@ -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
|
||||
|
||||
+41
-33
@@ -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
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+136
-2
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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<DataDirInfo, String> {
|
||||
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<DataDirInfo, String> {
|
||||
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<String>,
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// 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<SqlQueryResult, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
#[serde(default)]
|
||||
total: Option<u32>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<u32>,
|
||||
total: Option<u32>,
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_image_line(line: &str) -> Option<ImageProgress> {
|
||||
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<u32>, total: Option<u32> },
|
||||
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
|
||||
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 `<img src>`.
|
||||
///
|
||||
@@ -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<String, String> {
|
||||
@@ -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<ImageEvent>,
|
||||
) -> 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<std::sync::Arc<Channel<ImageEvent>>>,
|
||||
) -> Result<Vec<u8>, 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<String> = 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<String>, ch: Option<&Channel<ImageEvent>>| {
|
||||
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::<OllamaImageLine>(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<String> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod llm_commands;
|
||||
pub mod image_commands;
|
||||
pub mod rag_commands;
|
||||
pub mod generation_commands;
|
||||
pub mod generation_commands;
|
||||
pub mod data_commands;
|
||||
@@ -55,4 +55,23 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, Str
|
||||
#[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())
|
||||
}
|
||||
|
||||
#[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<Vec<RagChunk>, String> {
|
||||
state
|
||||
.rag
|
||||
.list_chunks(&source)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
||||
.collect()
|
||||
}
|
||||
@@ -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<String>, Vec<Vec<serde_json::Value>>)> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
crate::sql_viewer::run_readonly_query(&db, sql)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+36
-5
@@ -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");
|
||||
|
||||
@@ -17,6 +17,10 @@ pub struct AppState {
|
||||
pub config: Mutex<LlmConfig>,
|
||||
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)]
|
||||
|
||||
@@ -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<Vec<(i64, String)>> {
|
||||
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<String>, Vec<Vec<serde_json::Value>>)> {
|
||||
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
|
||||
crate::sql_viewer::run_readonly_query(&db, sql)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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<String>, Vec<Vec<JsonValue>>)> {
|
||||
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<String> = (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!("<blob {} bytes>", 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());
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<motion.div
|
||||
className={`glass-card flex flex-col overflow-hidden p-4 ${span} ${className}`}
|
||||
// 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 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 pb-2 mb-3 border-b border-[var(--color-border-glass)]">
|
||||
<span className="text-[var(--color-gold-bright)]">{icon}</span>
|
||||
<h2 className="font-heading text-sm font-semibold tracking-wide text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+349
-120
@@ -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<CalConfig>("calendar.config", DEFAULT_CONFIG);
|
||||
const [month, setMonth] = useState(0);
|
||||
const [year, setYear] = useState(1492);
|
||||
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [newEvent, setNewEvent] = useState("");
|
||||
const [newCategory, setNewCategory] = useState(CATEGORIES[0].label);
|
||||
const [newRecur, setNewRecur] = useState<Recur>("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 (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Month navigation */}
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { if (month === 0) { setMonth(11); setYear(year - 1); } else setMonth(month - 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
||||
{MONTHS[month]}
|
||||
</div>
|
||||
<div className="text-[var(--color-text-dim)] text-[10px]">{year} DR</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (month === 11) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Campaign date controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">
|
||||
Today: {MONTHS[today.month]} {today.day}, {today.year} DR {moonPhase(today.day, today.month, today.year)}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={jumpToToday}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
|
||||
title="Jump the view to today"
|
||||
onClick={() => setView("month")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "month" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
>
|
||||
Today
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
title="Advance the campaign date by one day"
|
||||
onClick={() => setView("agenda")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "agenda" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
>
|
||||
Next day →
|
||||
Agenda
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("edit")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "edit" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
title="Edit the calendar (months, day counts, year label)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">
|
||||
Today: {monthName(config, today.month)} {today.day}, {today.year} {config.yearLabel} {moonPhase(today.day, today.month, today.year)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
||||
{d}
|
||||
{view === "edit" ? (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-2">
|
||||
<div className="text-[10px] text-[var(--color-text-dim)]">
|
||||
Edit months, day counts, and the year label. Changes apply immediately.
|
||||
</div>
|
||||
))}
|
||||
{/* 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-[var(--color-text-secondary)]">Year label</label>
|
||||
<input
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] w-20"
|
||||
value={config.yearLabel}
|
||||
onChange={(e) => setConfig({ ...config, yearLabel: e.target.value })}
|
||||
placeholder="DR"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">→ shown as “{year} {config.yearLabel}”</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.months.map((m, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<span className="text-[9px] text-[var(--color-text-dim)] w-4 font-mono shrink-0">{i + 1}</span>
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={m.name}
|
||||
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
className="w-14 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={m.days}
|
||||
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, days: Math.max(1, parseInt(e.target.value) || 1) } : x) })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, months: config.months.filter((_, j) => j !== i) })}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer shrink-0"
|
||||
title="Remove month"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
key={dayNum}
|
||||
onClick={() => setSelectedDay(dayNum)}
|
||||
className={`rounded text-[10px] py-1 cursor-pointer transition-colors ${
|
||||
isToday
|
||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
|
||||
: isSelected
|
||||
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
onClick={() => setConfig({ ...config, months: [...config.months, { name: `Month ${config.months.length + 1}`, days: 30 }] })}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
>
|
||||
{dayNum}
|
||||
{hasEvent && !isToday && (
|
||||
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
||||
)}
|
||||
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
||||
+ month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfig(DEFAULT_CONFIG); setMonth(0); }}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] cursor-pointer"
|
||||
title="Reset to the Forgotten Realms calendar"
|
||||
>
|
||||
Reset to Forgotten Realms
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("month")}
|
||||
className="ml-auto rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events for selected day */}
|
||||
{selectedDay !== null && (
|
||||
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{MONTHS[month]} {selectedDay}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
|
||||
{weatherFor(selectedDay, month, year)}
|
||||
</span>
|
||||
</div>
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||
</div>
|
||||
) : view === "month" ? (
|
||||
<>
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { if (month === 0) { setMonth(config.months.length - 1); setYear(year - 1); } else setMonth(month - 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
||||
{monthName(config, month)}
|
||||
</div>
|
||||
<div className="text-[var(--color-text-dim)] text-[10px]">{year} {config.yearLabel}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (month === config.months.length - 1) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={jumpToToday}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
|
||||
title="Jump the view to today"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
title="Advance the campaign date by one day"
|
||||
>
|
||||
Next day →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{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 (
|
||||
<button
|
||||
key={dayNum}
|
||||
onClick={() => setSelectedDay(dayNum)}
|
||||
className={`rounded text-[10px] py-1 cursor-pointer transition-colors relative ${
|
||||
isToday
|
||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
|
||||
: isSelected
|
||||
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
{dayNum}
|
||||
{dayEvts.length > 0 && !isToday && (
|
||||
<span className="flex justify-center gap-0.5 mt-0.5">
|
||||
{dayEvts.slice(0, 3).map((e, j) => (
|
||||
<span key={j} className="w-1 h-1 rounded-full" style={{ backgroundColor: catColor(e.category) }} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events for selected day */}
|
||||
{selectedDay !== null && (
|
||||
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{monthName(config, month)} {selectedDay}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
|
||||
{weatherFor(selectedDay, month, year)}
|
||||
</span>
|
||||
</div>
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||
)}
|
||||
{dayEvents.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(e.category) }} />
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
||||
{e.recur !== "none" && (
|
||||
<span className="text-[8px] text-[var(--color-text-dim)]" title={`Repeats ${e.recur}`}>↻{e.recur === "monthly" ? "m" : "y"}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeEvent(events.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<input
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={newEvent}
|
||||
onChange={(e) => setNewEvent(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addEvent()}
|
||||
placeholder="Add event…"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
|
||||
>
|
||||
{CATEGORIES.map((c) => <option key={c.label} value={c.label}>{c.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={newRecur}
|
||||
onChange={(e) => setNewRecur(e.target.value as Recur)}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
|
||||
title="Repeat"
|
||||
>
|
||||
<option value="none">Once</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={addEvent}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dayEvents.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: e.color }} />
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEvent(events.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Agenda view — next events from today forward.
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
|
||||
Upcoming events
|
||||
</div>
|
||||
{agenda.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No upcoming events.</div>
|
||||
)}
|
||||
{agenda.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-1 border-b border-[var(--color-border-subtle)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(a.e.category) }} />
|
||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-mono shrink-0">
|
||||
{monthName(config, a.month).slice(0, 3)} {a.day}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{a.e.text}</span>
|
||||
{a.e.recur !== "none" && (
|
||||
<span className="text-[8px] text-[var(--color-text-dim)]">↻{a.e.recur === "monthly" ? "m" : "y"}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1 mt-1">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={newEvent}
|
||||
onChange={(e) => setNewEvent(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addEvent()}
|
||||
placeholder="Add event…"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={addEvent}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
>
|
||||
+
|
||||
Next day →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+96
-132
@@ -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<Partial<Record<GenerationKind, GenerationSummary>>>({});
|
||||
useEffect(() => {
|
||||
@@ -151,47 +100,62 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto">
|
||||
{CARDS.map((card) => {
|
||||
const last = card.kind ? latest[card.kind] : undefined;
|
||||
return (
|
||||
<BentoCard
|
||||
key={card.view}
|
||||
title={card.title}
|
||||
icon={<card.icon size={16} />}
|
||||
span={card.span}
|
||||
>
|
||||
<div className="flex flex-col h-full min-h-[120px]">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)]">
|
||||
<card.icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{last && (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-2 truncate">
|
||||
Last:{" "}
|
||||
<span className="text-[var(--color-text-secondary)]">{last.title}</span>{" "}
|
||||
· {relativeTime(last.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate(card.view)}
|
||||
className="mt-2 self-start text-xs font-medium text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] flex items-center gap-1 cursor-pointer transition-colors"
|
||||
aria-label={`Open ${card.title}`}
|
||||
>
|
||||
Open <ArrowRight size={12} />
|
||||
</button>
|
||||
<div className="h-full overflow-y-auto bg-[var(--color-bg-deep)]">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6 flex flex-col gap-7">
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section.label} className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-widest uppercase">
|
||||
{section.label}
|
||||
</h2>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] tracking-wide">
|
||||
{section.hint}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[var(--color-border-subtle)]" />
|
||||
</div>
|
||||
</BentoCard>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{section.tiles.map((tile) => {
|
||||
const last = tile.kind ? latest[tile.kind] : undefined;
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<button
|
||||
key={tile.view}
|
||||
onClick={() => onNavigate(tile.view)}
|
||||
className="glass-card group text-left p-4 rounded-2xl flex flex-col gap-2 min-h-[112px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-gold-bright)]"
|
||||
aria-label={`Open ${tile.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)] transition-colors group-hover:bg-[var(--color-gold-bright)] group-hover:text-[var(--color-bg-deep)]">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<h3 className="font-heading text-[var(--color-text-primary)] text-sm font-semibold leading-tight">
|
||||
{tile.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2 flex-1">
|
||||
{tile.description}
|
||||
</p>
|
||||
{last ? (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] truncate">
|
||||
Last: <span className="text-[var(--color-text-secondary)]">{last.title}</span> · {relativeTime(last.createdAt)}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Open →
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<p className="text-center text-[10px] text-[var(--color-text-dim)] pt-1">
|
||||
Press <kbd className="px-1 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] font-mono text-[9px]">⌘K</kbd> to jump to any tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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<string>("generate_image", { req: { prompt } })
|
||||
.then((url) => {
|
||||
if (!cancelled) setDataUrl(url);
|
||||
})
|
||||
.catch((e) => {
|
||||
setProgress(null);
|
||||
|
||||
const channel = new Channel<ImageEvent>();
|
||||
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 (
|
||||
<div className={box}>
|
||||
<div className="absolute inset-0 skeleton" />
|
||||
<span className="relative text-[10px] text-[var(--color-text-dim)]">Painting…</span>
|
||||
<div className="relative flex flex-col items-center gap-1 w-full px-3">
|
||||
{pct != null ? (
|
||||
<div className="w-full h-1 rounded-full bg-[var(--color-bg-surface)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)] transition-all duration-200" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">Painting…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
mechanical: string | Record<string, unknown> | 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:
|
||||
|
||||
<div className="rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-3">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Mechanics</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
|
||||
{structured ? (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{/* ponytail: structured fields — attunement/charges/value/weight + one-line effect */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
||||
<span className="text-[var(--color-text-dim)]">Attunement: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.attunement === "none" || !structured.attunement ? "No" : structured.attunement}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Charges: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.charges || "—"}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Value: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.value || "—"}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Weight: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.weight || "—"}</span>
|
||||
</div>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-1">{structured.effect}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
+232
-97
@@ -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<RagHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
// ponytail: source filter — multi-select chips. Empty set = search all.
|
||||
const [filterSources, setFilterSources] = useState<Set<string>>(new Set());
|
||||
// ponytail: chunk preview — expand a source to see its embedded chunks.
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [chunks, setChunks] = useState<RagChunk[]>([]);
|
||||
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<RagChunk[]>("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<HTMLInputElement>) {
|
||||
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<RagHit[]>("rag_search", { query, topK: 5 }));
|
||||
const all = await invoke<RagHit[]>("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 (
|
||||
<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>
|
||||
<div className="flex gap-4 h-full text-sm">
|
||||
{/* Left pane: add + indexed sources */}
|
||||
<div className="flex flex-col gap-3 w-1/2 min-w-0 overflow-y-auto">
|
||||
<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>
|
||||
{/* ponytail: file upload — native input, no dialog plugin. */}
|
||||
<label className="flex items-center gap-2 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-sm text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer">
|
||||
<FileUp size={14} />
|
||||
Upload .md / .txt
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.txt,.markdown,text/plain,text/markdown"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
{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 && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||
<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 && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
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>
|
||||
) : (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||
>
|
||||
Yes
|
||||
clear all
|
||||
</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>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
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) => {
|
||||
const isOpen = expanded === s.source;
|
||||
return (
|
||||
<li key={s.source} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isOpen) { setExpanded(null); }
|
||||
else { setExpanded(s.source); loadChunks(s.source); }
|
||||
}}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer shrink-0"
|
||||
aria-label={isOpen ? "Collapse chunks" : "Preview chunks"}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<span className="text-[var(--color-text-primary)] text-xs truncate flex-1">
|
||||
{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>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="px-2 pb-2 border-t border-[var(--color-border-subtle)] pt-1.5">
|
||||
{loadingChunks ? (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">Loading chunks…</span>
|
||||
) : chunks.length === 0 ? (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">No chunks.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{chunks.map((c) => (
|
||||
<div key={c.id} className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[10px] text-[var(--color-text-secondary)] leading-relaxed">
|
||||
<span className="text-[var(--color-text-dim)] font-mono">#{c.id}</span>{" "}
|
||||
{c.preview}
|
||||
{c.preview.length >= 200 ? "…" : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
{/* Right pane: search + explore */}
|
||||
<div className="flex flex-col gap-2 w-1/2 min-w-0 border-l border-[var(--color-border-subtle)] pl-4">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Search Lore</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"
|
||||
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={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
@@ -174,28 +277,60 @@ export function LorePanel() {
|
||||
{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>
|
||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||
DM can't read. Clamp 0–1, gold fill. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
||||
{/* Source filter chips */}
|
||||
{sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] self-center">Filter:</span>
|
||||
<button
|
||||
onClick={() => setFilterSources(new Set())}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterSources.size === 0
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.source}
|
||||
onClick={() => toggleSource(s.source)}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors truncate max-w-32 ${
|
||||
filterSources.has(s.source)
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{s.source}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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>
|
||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||
DM can't read. Clamp 0–1, gold fill. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--color-text-dim)] italic mt-2">
|
||||
{query ? "No matches." : "Search to retrieve the most relevant lore chunks."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,22 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
|
||||
import { Dice5 } from "lucide-react";
|
||||
|
||||
interface NpcStats {
|
||||
ac: number;
|
||||
hp: number;
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wis: number;
|
||||
cha: number;
|
||||
}
|
||||
|
||||
interface NpcResponse {
|
||||
bio: string;
|
||||
personality: string[];
|
||||
goals: string[];
|
||||
stats: NpcStats;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -79,11 +91,12 @@ Provide the response as a JSON object with exactly these keys:
|
||||
- "bio": a 2-3 sentence backstory
|
||||
- "personality": an array of 3-5 personality traits (each trait must be a string, not an object)
|
||||
- "goals": an array of 2-3 goals or motivations (each goal must be a string, not an object)
|
||||
- "stats": an object with keys ac, hp, str, dex, con, int, wis, cha — each a number. Fit the NPC's role (a blacksmith is strong, a sage is wise). Use 5e ranges (3-20 for abilities, 10-18 for AC, 1-60 for HP).
|
||||
|
||||
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, ragQuery: `${race} ${background} ${alignment} NPC` },
|
||||
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: 600, ragQuery: `${race} ${background} ${alignment} NPC` },
|
||||
});
|
||||
|
||||
// Try to parse JSON from the response
|
||||
@@ -99,9 +112,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
// stay consistent with the growing cast.
|
||||
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
|
||||
const goals = ensureArray(parsed.goals).map(flattenValue).join("; ");
|
||||
const statsLine = parsed.stats ? `AC ${parsed.stats.ac}, HP ${parsed.stats.hp}; Str ${parsed.stats.str} Dex ${parsed.stats.dex} Con ${parsed.stats.con} Int ${parsed.stats.int} Wis ${parsed.stats.wis} Cha ${parsed.stats.cha}` : "";
|
||||
void addToLore(
|
||||
`NPC: ${name || race + " " + background}`,
|
||||
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
|
||||
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}${statsLine ? `\nStats: ${statsLine}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
@@ -196,6 +210,25 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{name || "NPC"}
|
||||
</h3>
|
||||
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
|
||||
{/* ponytail: stat block — AC/HP prominent, six abilities in a grid. */}
|
||||
{npc.stats && (
|
||||
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-2">
|
||||
<div className="flex items-center gap-3 mb-1.5">
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] uppercase tracking-wider">AC</span>
|
||||
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">{npc.stats.ac}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] uppercase tracking-wider ml-1">HP</span>
|
||||
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">{npc.stats.hp}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-1 text-center">
|
||||
{[["STR", npc.stats.str], ["DEX", npc.stats.dex], ["CON", npc.stats.con], ["INT", npc.stats.int], ["WIS", npc.stats.wis], ["CHA", npc.stats.cha]].map(([ab, val]) => (
|
||||
<div key={ab as string} className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] py-1">
|
||||
<div className="text-[8px] text-[var(--color-text-dim)] uppercase">{ab}</div>
|
||||
<div className="font-mono text-xs font-bold text-[var(--color-text-primary)]">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseLlmJson } from "../lib/llm-parse";
|
||||
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { addToLore } from "../lib/lore";
|
||||
@@ -9,7 +9,16 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
||||
interface QuestStep {
|
||||
title: string;
|
||||
description: string;
|
||||
choice?: string;
|
||||
// ponytail: the LLM occasionally returns an object/array here despite the
|
||||
// prompt; coerce to string at use sites so React never renders a raw object.
|
||||
choice?: unknown;
|
||||
}
|
||||
|
||||
interface QuestReward {
|
||||
xp: number; // XP per character
|
||||
gold: number; // total gold (split as the party sees fit)
|
||||
items: string[]; // magic items / treasure
|
||||
notes: string; // any other reward (boons, favors, reputation)
|
||||
}
|
||||
|
||||
interface QuestResponse {
|
||||
@@ -17,7 +26,7 @@ interface QuestResponse {
|
||||
hook: string;
|
||||
steps: QuestStep[];
|
||||
twist: string;
|
||||
reward: string;
|
||||
reward: string | QuestReward;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -28,6 +37,7 @@ interface Props {
|
||||
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [theme, setTheme] = useState("");
|
||||
const [level, setLevel] = useState(5);
|
||||
const [partySize, setPartySize] = useState(4);
|
||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -52,20 +62,20 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
try {
|
||||
const result = await invoke<string>("generate", {
|
||||
req: {
|
||||
prompt: `Design a D&D quest for level ${level} characters.
|
||||
prompt: `Design a D&D quest for ${partySize} level ${level} characters.
|
||||
${theme ? `Theme: ${theme}` : ""}
|
||||
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "title": a catchy quest name
|
||||
- "hook": 1-2 sentences describing how the party gets involved
|
||||
- "steps": an array of 3-5 quest steps, each with "title" and "description" (2-3 sentences each), and optionally "choice" (a meaningful decision the party faces)
|
||||
- "steps": an array of 3-5 quest steps, each with "title" (string), "description" (2-3 sentence string), and optionally "choice" — a SINGLE short string describing a meaningful decision the party faces (e.g. "Spare the bandit or turn him in?"). "choice" MUST be a string or null, NEVER an array or object. Example step: {"title":"The Crossroads","description":"...","choice":"Fight or flee?"}
|
||||
- "twist": a surprise revelation or complication
|
||||
- "reward": what the party gains on completion
|
||||
- "reward": an object with keys xp (number, XP awarded to EACH character), gold (number, total gp), items (array of strings, magic items or notable treasure), and notes (string, any other reward like boons or favors)
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
|
||||
system: "You are a creative D&D quest 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: 700,
|
||||
max_tokens: 800,
|
||||
},
|
||||
});
|
||||
const parsed = parseLlmJson<QuestResponse>(result);
|
||||
@@ -76,11 +86,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
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})` : ""}`)
|
||||
.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.choice ? ` (Choice: ${flattenValue(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}`,
|
||||
`Quest: ${parsed.title || "Untitled"} (level ${level}, ${partySize} PCs${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${typeof parsed.reward === "string" ? parsed.reward : `XP ${parsed.reward?.xp}/each, ${parsed.reward?.gold} gp, items: ${(parsed.reward?.items || []).join(", ")}; ${parsed.reward?.notes || ""}`}`,
|
||||
);
|
||||
} else {
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
@@ -117,6 +127,17 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 max-w-32">
|
||||
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Size</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
value={partySize}
|
||||
onChange={(e) => setPartySize(parseInt(e.target.value) || 1)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading}
|
||||
@@ -195,10 +216,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
|
||||
{quest.steps[currentStep].description}
|
||||
</p>
|
||||
{quest.steps[currentStep].choice && !playerView && (
|
||||
{!!quest.steps[currentStep].choice && !playerView && (
|
||||
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{flattenValue(quest.steps[currentStep].choice)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between mt-3">
|
||||
@@ -233,7 +254,27 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{quest.reward && (
|
||||
<div className="rounded-lg border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-4 py-3">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Reward</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.reward}</p>
|
||||
{/* ponytail: structured reward breakdown if the LLM returned the
|
||||
object form; fall back to a paragraph for older generations. */}
|
||||
{typeof quest.reward === "object" && quest.reward !== null && "xp" in quest.reward ? (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-sm">
|
||||
<span><span className="text-[var(--color-text-dim)]">XP</span> <span className="font-mono font-bold text-[var(--color-gold-bright)]">{quest.reward.xp}</span> <span className="text-[var(--color-text-dim)]">/ character</span></span>
|
||||
<span><span className="text-[var(--color-text-dim)]">Gold</span> <span className="font-mono font-bold text-[var(--color-gold-bright)]">{quest.reward.gold} gp</span></span>
|
||||
</div>
|
||||
{quest.reward.items?.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--color-text-dim)]">Items: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{quest.reward.items.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{quest.reward.notes && (
|
||||
<p className="text-[var(--color-text-secondary)] text-sm italic">{quest.reward.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{typeof quest.reward === "string" ? quest.reward : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+281
-17
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface TableEntry {
|
||||
min: number;
|
||||
@@ -10,6 +12,8 @@ interface RandomTable {
|
||||
name: string;
|
||||
dice: string;
|
||||
entries: TableEntry[];
|
||||
/** true for user-created tables (editable / deletable). */
|
||||
custom?: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_TABLES: RandomTable[] = [
|
||||
@@ -206,6 +210,59 @@ function rollDice(notation: string): number {
|
||||
return total + modifier;
|
||||
}
|
||||
|
||||
// ponytail: total faces of the table's dice (e.g. "1d20" → 20). Used to show
|
||||
// per-entry weights and flag gaps where a roll would hit "No result".
|
||||
function diceSides(notation: string): number {
|
||||
const m = notation.toLowerCase().match(/^(\d+)d(\d+)/);
|
||||
return m ? parseInt(m[2]) * parseInt(m[1]) : 20;
|
||||
}
|
||||
|
||||
// ponytail: parse a pasted markdown/plain list into table entries. Accepts
|
||||
// "1d20" style dice on a header line, or defaults to 1dN where N = entry count.
|
||||
// Each non-empty line becomes one entry; ranges like "3-4 result" are honoured.
|
||||
function parseMarkdownTable(raw: string): { name: string; dice: string; entries: TableEntry[] } | null {
|
||||
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
let name = "Imported Table";
|
||||
let dice = "";
|
||||
const entryLines: string[] = [];
|
||||
for (const l of lines) {
|
||||
const hdr = l.match(/^#{1,6}\s+(.+)$/);
|
||||
const diceMatch = l.match(/(\d+d\d+(?:[+-]\d+)?)/i);
|
||||
if (hdr) {
|
||||
name = hdr[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (diceMatch && !dice && entryLines.length === 0) {
|
||||
dice = diceMatch[1];
|
||||
// keep the line if it also has a result, else it's a dice-only header
|
||||
if (l.replace(diceMatch[1], "").trim()) entryLines.push(l);
|
||||
continue;
|
||||
}
|
||||
entryLines.push(l);
|
||||
}
|
||||
if (entryLines.length === 0) return null;
|
||||
const entries: TableEntry[] = entryLines.map((l, i) => {
|
||||
// strip leading list markers / numbers
|
||||
const cleaned = l.replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "");
|
||||
const range = cleaned.match(/^(\d+)\s*[-–]\s*(\d+)\s+(.+)$/);
|
||||
if (range) {
|
||||
return { min: parseInt(range[1]), max: parseInt(range[2]), result: range[3].trim() };
|
||||
}
|
||||
// single leading number: "5 Gilded Flask" → entry 5
|
||||
const single = cleaned.match(/^(\d+)\s+(.+)$/);
|
||||
if (single) {
|
||||
return { min: parseInt(single[1]), max: parseInt(single[1]), result: single[2].trim() };
|
||||
}
|
||||
return { min: i + 1, max: i + 1, result: cleaned };
|
||||
});
|
||||
if (!dice) {
|
||||
const max = entries.reduce((m, e) => Math.max(m, e.max), 0);
|
||||
dice = `1d${Math.max(2, max)}`;
|
||||
}
|
||||
return { name, dice, entries };
|
||||
}
|
||||
|
||||
interface RollLog {
|
||||
tableName: string;
|
||||
roll: number;
|
||||
@@ -216,8 +273,24 @@ export function RandomTables() {
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [log, setLog] = useState<RollLog[]>([]);
|
||||
const [customRoll, setCustomRoll] = useState<number | null>(null);
|
||||
// ponytail: user-defined tables persist alongside builtins. Editing only
|
||||
// affects custom tables; builtins are read-only.
|
||||
const [customTables, setCustomTables] = usePersistentState<RandomTable[]>("tables.custom", []);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importName, setImportName] = useState("");
|
||||
const [importDice, setImportDice] = useState("");
|
||||
const { addToast } = useToast();
|
||||
|
||||
const table = BUILTIN_TABLES[selected];
|
||||
const allTables = useMemo(() => [...BUILTIN_TABLES, ...customTables], [customTables]);
|
||||
const table = allTables[selected] ?? BUILTIN_TABLES[0];
|
||||
const isCustom = table.custom === true;
|
||||
// ponytail: weighted tables - count dice faces not covered by any entry.
|
||||
const sides = diceSides(table.dice);
|
||||
const coveredFaces = new Set<number>();
|
||||
for (const e of table.entries) for (let v = e.min; v <= e.max; v++) coveredFaces.add(v);
|
||||
const gaps = sides - coveredFaces.size;
|
||||
|
||||
function exportTable() {
|
||||
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
|
||||
@@ -237,23 +310,129 @@ export function RandomTables() {
|
||||
setCustomRoll(rollVal);
|
||||
}, [table]);
|
||||
|
||||
function newCustomTable() {
|
||||
const t: RandomTable = {
|
||||
name: `My Table ${customTables.length + 1}`,
|
||||
dice: "1d6",
|
||||
custom: true,
|
||||
entries: [
|
||||
{ min: 1, max: 1, result: "First result" },
|
||||
{ min: 2, max: 2, result: "Second result" },
|
||||
],
|
||||
};
|
||||
setCustomTables((prev) => [...prev, t]);
|
||||
setSelected(BUILTIN_TABLES.length + customTables.length);
|
||||
setEditing(true);
|
||||
addToast("New table created — edit it below", "info");
|
||||
}
|
||||
|
||||
function updateCurrent(updater: (t: RandomTable) => RandomTable) {
|
||||
if (!isCustom) return;
|
||||
const idx = selected - BUILTIN_TABLES.length;
|
||||
setCustomTables((prev) => prev.map((t, i) => (i === idx ? updater(t) : t)));
|
||||
}
|
||||
|
||||
function deleteCurrent() {
|
||||
if (!isCustom) return;
|
||||
const idx = selected - BUILTIN_TABLES.length;
|
||||
setCustomTables((prev) => prev.filter((_, i) => i !== idx));
|
||||
setSelected(0);
|
||||
setEditing(false);
|
||||
addToast("Table deleted", "info");
|
||||
}
|
||||
|
||||
function doImport() {
|
||||
let parsed: { name: string; dice: string; entries: TableEntry[] } | null = null;
|
||||
if (importText.trim()) {
|
||||
parsed = parseMarkdownTable(importText);
|
||||
if (!parsed) {
|
||||
addToast("Couldn't parse the pasted table", "error");
|
||||
return;
|
||||
}
|
||||
} else if (importName.trim()) {
|
||||
parsed = { name: importName.trim(), dice: importDice.trim() || "1d6", entries: [] };
|
||||
}
|
||||
if (!parsed) return;
|
||||
const t: RandomTable = { ...parsed, custom: true };
|
||||
setCustomTables((prev) => [...prev, t]);
|
||||
setSelected(BUILTIN_TABLES.length + customTables.length);
|
||||
setImportOpen(false);
|
||||
setImportText("");
|
||||
setImportName("");
|
||||
setImportDice("");
|
||||
addToast(`Imported "${t.name}"`, "success");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Table selector */}
|
||||
<select
|
||||
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"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(parseInt(e.target.value));
|
||||
setCustomRoll(null);
|
||||
}}
|
||||
>
|
||||
{BUILTIN_TABLES.map((t, i) => (
|
||||
<option key={t.name} value={i}>
|
||||
{t.name} ({t.dice})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
className="flex-1 min-w-0 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"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(parseInt(e.target.value));
|
||||
setCustomRoll(null);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{allTables.map((t, i) => (
|
||||
<option key={`${t.name}-${i}`} value={i}>
|
||||
{t.custom ? "✎ " : ""}{t.name} ({t.dice})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={newCustomTable}
|
||||
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-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
title="New custom table"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportOpen((o) => !o)}
|
||||
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-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
title="Import a table from pasted Markdown"
|
||||
>
|
||||
⬆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import panel */}
|
||||
{importOpen && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2 flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)] font-medium">Import table</span>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)]">
|
||||
Paste a list — one result per line. A <code># Title</code> line sets the name, a line with dice (e.g. <code>1d20</code>) sets the dice. Lines like <code>3-4 result</code> set ranges.
|
||||
</p>
|
||||
<textarea
|
||||
className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs min-h-24 resize-y font-mono"
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder={"# Tavern Names\n1d20\n1-2 The Rusty Tankard\n3-4 The Sleeping Dragon"}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
placeholder="Name (optional, overrides #)"
|
||||
/>
|
||||
<input
|
||||
className="w-20 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={importDice}
|
||||
onChange={(e) => setImportDice(e.target.value)}
|
||||
placeholder="1d20"
|
||||
/>
|
||||
<button
|
||||
onClick={doImport}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roll + export */}
|
||||
<div className="flex gap-2">
|
||||
@@ -270,8 +449,87 @@ export function RandomTables() {
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
editing
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title="Edit this custom table"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={deleteCurrent}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
|
||||
title="Delete this custom table"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor for custom tables */}
|
||||
{isCustom && editing && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2 flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={table.name}
|
||||
onChange={(e) => updateCurrent((t) => ({ ...t, name: e.target.value }))}
|
||||
placeholder="Table name"
|
||||
/>
|
||||
<input
|
||||
className="w-20 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||
value={table.dice}
|
||||
onChange={(e) => updateCurrent((t) => ({ ...t, dice: e.target.value }))}
|
||||
placeholder="1d20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto">
|
||||
{table.entries.map((e, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="w-10 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={e.min}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, min: parseInt(ev.target.value) || 0 } : x) }))}
|
||||
/>
|
||||
<span className="text-[var(--color-text-dim)] text-[10px]">-</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-10 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={e.max}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, max: parseInt(ev.target.value) || 0 } : x) }))}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={e.result}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, result: ev.target.value } : x) }))}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateCurrent((t) => ({ ...t, entries: t.entries.filter((_, j) => j !== i) }))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer shrink-0"
|
||||
title="Remove entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateCurrent((t) => ({ ...t, entries: [...t.entries, { min: t.entries.length + 1, max: t.entries.length + 1, result: "" }] }))}
|
||||
className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
+ entry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{customRoll !== null && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-center">
|
||||
@@ -286,6 +544,11 @@ export function RandomTables() {
|
||||
|
||||
{/* Table preview */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{gaps > 0 ? (
|
||||
<div className="text-[9px] text-[var(--color-danger)] mb-1">
|
||||
! {gaps} of {sides} faces uncovered - some rolls hit No result.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{table.entries.map((e) => (
|
||||
<div
|
||||
@@ -297,7 +560,8 @@ export function RandomTables() {
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono w-8 text-right shrink-0">{e.min === e.max ? e.min : `${e.min}-${e.max}`}</span>
|
||||
<span className="truncate">{e.result}</span>
|
||||
<span className="truncate flex-1">{e.result}</span>
|
||||
{(() => { const s = diceSides(table.dice); const w = Math.round(((e.max - e.min + 1) / s) * 100); return w !== 100 ? <span className="font-mono text-[9px] text-[var(--color-text-dim)] shrink-0" title="Entry weight">{w}%</span> : null; })()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+280
-103
@@ -1,80 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { invoke, Channel } 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";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { renderMarkdown } from "../lib/markdown";
|
||||
|
||||
interface Props {
|
||||
prefill?: Generation | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
text: string;
|
||||
time: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
entries: SessionEntry[];
|
||||
summary: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const TAGS = ["#combat", "#roleplay", "#loot", "#quest", "#npc", "#misc"];
|
||||
|
||||
function newSession(title: string): Session {
|
||||
return { id: crypto.randomUUID(), title, entries: [], summary: "", createdAt: Date.now() };
|
||||
}
|
||||
|
||||
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
// ponytail: multiple sessions as a first-class concept. The whole roster
|
||||
// persists under one key; the active session id is stored separately so a
|
||||
// reload lands on the session the DM was last editing.
|
||||
const [sessions, setSessions] = usePersistentState<Session[]>("session.list", [newSession("Session 1")]);
|
||||
const [activeId, setActiveId] = usePersistentState<string>("session.active", "");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [summary, setSummary] = usePersistentState<string>("session.summary", "");
|
||||
const [tag, setTag] = useState(TAGS[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// ponytail: streaming state — tokens arrive via the Channel and append to
|
||||
// the summary live with a gold cursor. `gotFirstToken` flips the thinking
|
||||
// dots to streaming text.
|
||||
const [gotFirstToken, setGotFirstToken] = useState(false);
|
||||
const [entries, setEntries] = usePersistentState<{ text: string; time: string }[]>("session.entries", []);
|
||||
const [filterTag, setFilterTag] = useState<string | null>(null);
|
||||
const [previewSummary, setPreviewSummary] = useState(true);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: ensure an active session exists (first run / migration).
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) {
|
||||
const s = newSession("Session 1");
|
||||
setSessions([s]);
|
||||
setActiveId(s.id);
|
||||
} else if (!sessions.some((s) => s.id === activeId) && activeId !== "") {
|
||||
setActiveId(sessions[0].id);
|
||||
} else if (activeId === "") {
|
||||
setActiveId(sessions[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessions.length]);
|
||||
|
||||
const active = useMemo(
|
||||
() => sessions.find((s) => s.id === activeId) ?? sessions[0],
|
||||
[sessions, activeId],
|
||||
);
|
||||
|
||||
// ponytail: helpers that update the active session within the roster.
|
||||
function updateActive(updater: (s: Session) => Session) {
|
||||
setSessions((prev) => prev.map((s) => (s.id === active?.id ? updater(s) : s)));
|
||||
}
|
||||
|
||||
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
||||
setSummary(g.data);
|
||||
if (!active) return;
|
||||
updateActive((s) => ({ ...s, summary: g.data }));
|
||||
addToast(`Loaded "${g.title}" from history`, "info");
|
||||
});
|
||||
|
||||
function addEntry() {
|
||||
if (!notes.trim()) return;
|
||||
setEntries((prev) => [
|
||||
{ text: notes.trim(), time: new Date().toLocaleTimeString() },
|
||||
...prev,
|
||||
]);
|
||||
if (!notes.trim() || !active) return;
|
||||
updateActive((s) => ({
|
||||
...s,
|
||||
entries: [{ text: notes.trim(), time: new Date().toLocaleString(), tag }, ...s.entries],
|
||||
}));
|
||||
setNotes("");
|
||||
}
|
||||
|
||||
// ponytail: export the session as a Markdown file the DM can share or
|
||||
// paste into Discord. Reverses the entry list so it reads chronologically.
|
||||
function removeEntry(i: number) {
|
||||
updateActive((s) => ({ ...s, entries: s.entries.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
function addSession() {
|
||||
const s = newSession(`Session ${sessions.length + 1}`);
|
||||
setSessions((prev) => [...prev, s]);
|
||||
setActiveId(s.id);
|
||||
setNotes("");
|
||||
setFilterTag(null);
|
||||
}
|
||||
|
||||
function deleteSession() {
|
||||
if (sessions.length <= 1) {
|
||||
addToast("Keep at least one session", "info");
|
||||
return;
|
||||
}
|
||||
const idx = sessions.findIndex((s) => s.id === active?.id);
|
||||
const next = sessions.filter((s) => s.id !== active?.id);
|
||||
setSessions(next);
|
||||
setActiveId(next[Math.max(0, idx - 1)].id);
|
||||
addToast("Session deleted", "info");
|
||||
}
|
||||
|
||||
function exportMarkdown() {
|
||||
if (entries.length === 0) return;
|
||||
const lines = [...entries].reverse().map((e) => `- [${e.time}] ${e.text}`);
|
||||
const md = `# Session ${new Date().toLocaleDateString()}\n\n## Notes\n\n${lines.join("\\n")}\n\n${summary ? `## AI Summary\n\n${summary}` : ""}`;
|
||||
if (!active || active.entries.length === 0) return;
|
||||
const lines = [...active.entries].reverse().map((e) => `- [${e.time}] ${e.tag ? `${e.tag} ` : ""}${e.text}`);
|
||||
const md = `# ${active.title} (${new Date(active.createdAt).toLocaleDateString()})\n\n## Notes\n\n${lines.join("\n")}\n\n${active.summary ? `## AI Summary\n\n${active.summary}` : ""}`;
|
||||
const blob = new Blob([md], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `session-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.download = `${active.title.toLowerCase().replace(/\s+/g, "-")}-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast("Exported session to Markdown", "success");
|
||||
}
|
||||
|
||||
function removeEntry(i: number) {
|
||||
setEntries((prev) => prev.filter((_, j) => j !== i));
|
||||
}
|
||||
|
||||
async function summarize() {
|
||||
if (entries.length === 0) return;
|
||||
if (!active || active.entries.length === 0) return;
|
||||
setLoading(true);
|
||||
setSummary("");
|
||||
updateActive((s) => ({ ...s, summary: "" }));
|
||||
setGotFirstToken(false);
|
||||
const sessionText = entries.map((e) => `[${e.time}] ${e.text}`).join("\n");
|
||||
// ponytail: stream tokens via the existing generate_stream Channel so the
|
||||
// DM sees the summary appear word-by-word instead of staring at a button.
|
||||
const sessionText = active.entries.map((e) => `[${e.time}] ${e.tag ? `${e.tag} ` : ""}${e.text}`).join("\n");
|
||||
const channel = new Channel<{ type: string; data: string }>();
|
||||
let streamed = "";
|
||||
channel.onmessage = (event) => {
|
||||
if (event.type === "token") {
|
||||
setGotFirstToken(true);
|
||||
streamed = streamed + event.data;
|
||||
setSummary(streamed);
|
||||
updateActive((s) => ({ ...s, summary: streamed }));
|
||||
} else if (event.type === "done") {
|
||||
setGotFirstToken(true);
|
||||
streamed = event.data;
|
||||
setSummary(streamed);
|
||||
updateActive((s) => ({ ...s, summary: streamed }));
|
||||
} else if (event.type === "error") {
|
||||
addToast(`Summary failed: ${event.data}`, "error");
|
||||
}
|
||||
@@ -91,90 +157,201 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
});
|
||||
addToast("Session summary generated", "success");
|
||||
const result = streamed;
|
||||
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}`);
|
||||
const title = active.title;
|
||||
void addGeneration({ kind: "session", title, data: result, source: `${active.entries.length} notes` });
|
||||
void addToLore(`Session: ${title}`, `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||
} catch (e) {
|
||||
addToast(`Summary failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const visibleEntries = filterTag === null ? active.entries : active.entries.filter((e) => e.tag === filterTag);
|
||||
const usedTags = TAGS.filter((t) => active.entries.some((e) => e.tag === t));
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 h-full">
|
||||
{/* Notes column */}
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<textarea
|
||||
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addEntry();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a session note, press Enter to add…"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addEntry}
|
||||
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"
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Session switcher */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={active.title}
|
||||
onChange={(e) => updateActive((s) => ({ ...s, title: e.target.value }))}
|
||||
onBlur={() => setRenaming(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={active.id}
|
||||
onChange={(e) => { setActiveId(e.target.value); setFilterTag(null); setNotes(""); }}
|
||||
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
aria-label="Switch session"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
onClick={summarize}
|
||||
disabled={loading || entries.length === 0}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportMarkdown}
|
||||
disabled={entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Export notes + summary to a Markdown file"
|
||||
>
|
||||
⬇ Export
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{entries.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
||||
{e.time}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEntry(i)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title} ({s.entries.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setRenaming((r) => !r)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
title="Rename session"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={addSession}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
title="New session"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] cursor-pointer"
|
||||
title="Delete this session"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Summary column */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
|
||||
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
||||
{loading && !gotFirstToken ? (
|
||||
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
</span>
|
||||
) : summary ? (
|
||||
<span>{summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-dim)] italic">
|
||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-3 flex-1 min-h-0">
|
||||
{/* Notes column */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
||||
<textarea
|
||||
className="flex-1 min-h-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addEntry();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a session note, press Enter to add…"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
aria-label="Tag for new note"
|
||||
>
|
||||
{TAGS.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={addEntry}
|
||||
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"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
onClick={summarize}
|
||||
disabled={loading || active.entries.length === 0}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportMarkdown}
|
||||
disabled={active.entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Export notes + summary to a Markdown file"
|
||||
>
|
||||
⬇ Export
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* ponytail: tag filter — click a chip to filter, click again to clear. */}
|
||||
{usedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
<button
|
||||
onClick={() => setFilterTag(null)}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterTag === null
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
{usedTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilterTag((f) => (f === t ? null : t))}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterTag === t
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{visibleEntries.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
||||
{e.time}
|
||||
</span>
|
||||
{e.tag && <span className="text-[var(--color-gold-bright)] text-[10px] shrink-0">{e.tag}</span>}
|
||||
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEntry(active.entries.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary column */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)]">AI Summary</h3>
|
||||
{active.summary ? (
|
||||
<button
|
||||
onClick={() => setPreviewSummary((p) => !p)}
|
||||
className={`rounded px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
previewSummary
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
title="Toggle Markdown preview"
|
||||
>
|
||||
{previewSummary ? "Preview" : "Raw"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
||||
{loading && !gotFirstToken ? (
|
||||
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
</span>
|
||||
) : active.summary ? (
|
||||
previewSummary ? (
|
||||
<div className="md-preview" dangerouslySetInnerHTML={{ __html: renderMarkdown(active.summary) }} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{active.summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[var(--color-text-dim)] italic">
|
||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface LlmConfig {
|
||||
@@ -13,6 +14,12 @@ interface LlmConfig {
|
||||
embed_model: string;
|
||||
}
|
||||
|
||||
interface DataDirInfo {
|
||||
current: string;
|
||||
configured: string | null;
|
||||
default: string;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
const [config, setConfig] = useState<LlmConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -21,6 +28,14 @@ export function SettingsPanel() {
|
||||
const [error, setError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
||||
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null);
|
||||
const [relocating, setRelocating] = useState(false);
|
||||
// ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db).
|
||||
const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations");
|
||||
const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||
const [sqlResult, setSqlResult] = useState<{ columns: string[]; rows: (string | number | null)[][] } | null>(null);
|
||||
const [sqlError, setSqlError] = useState("");
|
||||
const [sqlLoading, setSqlLoading] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||
@@ -32,9 +47,78 @@ export function SettingsPanel() {
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
try {
|
||||
setDataDir(await invoke<DataDirInfo>("get_data_dir"));
|
||||
} catch {
|
||||
// non-fatal — data dir control just stays hidden
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ponytail: pick a folder, copy existing campaign data into it, persist the
|
||||
// preference. The open SQLite connections stay on the old paths until the
|
||||
// DM restarts, so we tell them to relaunch.
|
||||
async function chooseDataDir() {
|
||||
try {
|
||||
const picked = await open({ directory: true, multiple: false, title: "Choose where DM-Pal saves campaign data" });
|
||||
if (!picked || typeof picked !== "string") return;
|
||||
setRelocating(true);
|
||||
const info = await invoke<DataDirInfo>("set_data_dir", { newDir: picked });
|
||||
setDataDir(info);
|
||||
addToast(`Campaign data will save to ${picked} — restart DM-Pal to apply. Existing data was copied there.`, "success");
|
||||
} catch (e) {
|
||||
addToast(`Couldn't set data location: ${e}`, "error");
|
||||
}
|
||||
setRelocating(false);
|
||||
}
|
||||
|
||||
async function resetDataDir() {
|
||||
try {
|
||||
await invoke("reset_data_dir");
|
||||
setDataDir(await invoke<DataDirInfo>("get_data_dir"));
|
||||
addToast("Reverted to default location — restart DM-Pal to apply", "info");
|
||||
} catch (e) {
|
||||
addToast(`Couldn't reset: ${e}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: run a read-only query against the selected DB. The backend gates
|
||||
// on SELECT/PRAGMA/WITH, so this is safe to expose in Settings.
|
||||
async function runSql() {
|
||||
setSqlLoading(true);
|
||||
setSqlError("");
|
||||
setSqlResult(null);
|
||||
try {
|
||||
const res = await invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db: sqlDb, sql: sqlInput } });
|
||||
setSqlResult(res);
|
||||
if (res.rows.length === 0) setSqlError("(0 rows)");
|
||||
} catch (e) {
|
||||
setSqlError(String(e));
|
||||
}
|
||||
setSqlLoading(false);
|
||||
}
|
||||
|
||||
function quickQuery(q: string) {
|
||||
setSqlInput(q);
|
||||
// run after the state flushes
|
||||
setTimeout(runSql, 0);
|
||||
}
|
||||
|
||||
function switchDb(db: "lore" | "generations") {
|
||||
setSqlDb(db);
|
||||
setSqlResult(null);
|
||||
setSqlError("");
|
||||
const q = "SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name";
|
||||
setSqlInput(q);
|
||||
// ponytail: runSql reads sqlDb from closure (stale here), so inline the
|
||||
// invoke with the new db to avoid a second render round-trip.
|
||||
setSqlLoading(true);
|
||||
invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db, sql: q } })
|
||||
.then((res) => { setSqlResult(res); if (res.rows.length === 0) setSqlError("(0 rows)"); })
|
||||
.catch((e) => setSqlError(String(e)))
|
||||
.finally(() => setSqlLoading(false));
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!config) return;
|
||||
setLoading(true);
|
||||
@@ -102,6 +186,38 @@ export function SettingsPanel() {
|
||||
addToast("Reset to defaults — click Save to apply", "info");
|
||||
}
|
||||
|
||||
// ponytail: export/import the whole config as JSON. Export downloads a file;
|
||||
// import reads a file and merges into the form (Save still required to persist).
|
||||
function exportConfig() {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "dm-pal-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast("Config exported", "success");
|
||||
}
|
||||
|
||||
function importConfig(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result)) as Partial<LlmConfig>;
|
||||
setConfig((c) => ({ ...c, ...parsed } as LlmConfig));
|
||||
setConn(null);
|
||||
addToast("Config imported — click Save to apply", "info");
|
||||
} catch {
|
||||
addToast("Invalid config file", "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
@@ -115,6 +231,51 @@ export function SettingsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{/* Storage - where campaign data (lore db, generations db, generated images) lives. */}
|
||||
{dataDir ? (
|
||||
<>
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Storage</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Campaign data location</label>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||
Where the lore database, generations database, and generated images are saved.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 truncate rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] text-xs" title={dataDir.current}>
|
||||
{dataDir.current}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chooseDataDir}
|
||||
disabled={relocating}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{relocating ? "Moving…" : "Choose…"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetDataDir}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-xs hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
|
||||
title="Use the OS default location"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{dataDir.configured ? (
|
||||
dataDir.configured !== dataDir.current ? (
|
||||
<div className="rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-gold-bright)]/40 px-3 py-2 text-xs text-[var(--color-gold-bright)] flex items-center justify-between gap-2">
|
||||
<span>
|
||||
⏳ Pending move to <code className="font-mono">{dataDir.configured}</code> - restart DM-Pal to apply.
|
||||
</span>
|
||||
<button onClick={resetDataDir} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer underline" title="Cancel the pending move">
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">LLM Connection</h3>
|
||||
{/* Provider presets */}
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -287,7 +448,7 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={loading}
|
||||
@@ -301,7 +462,98 @@ export function SettingsPanel() {
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
<button
|
||||
onClick={exportConfig}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
⬆ Export
|
||||
</button>
|
||||
<label className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center">
|
||||
⬇ Import
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={importConfig}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced — read-only SQLite viewer for inspecting the backend DBs. */}
|
||||
<details
|
||||
className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)]"
|
||||
onToggle={(e) => { if ((e.target as HTMLDetailsElement).open && !sqlResult && !sqlLoading) runSql(); }}
|
||||
>
|
||||
<summary className="px-3 py-2 text-xs font-medium text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)] select-none">
|
||||
Advanced · SQLite viewer
|
||||
</summary>
|
||||
<div className="p-3 flex flex-col gap-2 border-t border-[var(--color-border-subtle)]">
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||
Read-only inspection of the local databases (lore.db, generations.db). Only SELECT / PRAGMA / WITH statements run. Embedding vectors show as <code><blob N bytes></code>.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={sqlDb}
|
||||
onChange={(e) => switchDb(e.target.value as "lore" | "generations")}
|
||||
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"
|
||||
aria-label="Database"
|
||||
>
|
||||
<option value="generations">Generations DB</option>
|
||||
<option value="lore">Lore DB</option>
|
||||
</select>
|
||||
<button onClick={() => quickQuery("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Tables</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "PRAGMA table_info(chunks)" : "PRAGMA table_info(generations)")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Schema</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "SELECT id, source, substr(text,1,80) AS preview FROM chunks ORDER BY id DESC LIMIT 25" : "SELECT * FROM generations ORDER BY created_at DESC LIMIT 25")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Browse</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "SELECT source, COUNT(*) AS chunks FROM chunks GROUP BY source ORDER BY chunks DESC" : "SELECT kind, COUNT(*) AS n FROM generations GROUP BY kind")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Count</button>
|
||||
</div>
|
||||
<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-xs font-mono min-h-20 resize-y"
|
||||
value={sqlInput}
|
||||
onChange={(e) => setSqlInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.metaKey || e.ctrlKey) && runSql()}
|
||||
spellCheck={false}
|
||||
placeholder="SELECT * FROM … (⌘Enter to run)"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={runSql}
|
||||
disabled={sqlLoading}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{sqlLoading ? "Running…" : "Run query"}
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">⌘Enter to run · read-only</span>
|
||||
</div>
|
||||
|
||||
{sqlError ? (
|
||||
<p className="text-[10px] text-[var(--color-danger)] font-mono whitespace-pre-wrap">{sqlError}</p>
|
||||
) : null}
|
||||
|
||||
{sqlResult ? (
|
||||
<div className="rounded-lg border border-[var(--color-border-subtle)] overflow-hidden">
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full text-[10px] font-mono">
|
||||
<thead className="sticky top-0 bg-[var(--color-bg-surface)]">
|
||||
<tr>
|
||||
{sqlResult.columns.map((c) => <th key={c} className="text-left text-[var(--color-gold-bright)] px-2 py-1 border-b border-[var(--color-border-subtle)] whitespace-nowrap">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sqlResult.rows.map((row, i) => (
|
||||
<tr key={i} className="odd:bg-[var(--color-bg-surface)]/30">
|
||||
{row.map((cell, j) => <td key={j} className="px-2 py-1 text-[var(--color-text-secondary)] max-w-64 truncate" title={String(cell)}>{cell === null ? <span className="text-[var(--color-text-dim)]">NULL</span> : String(cell)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-[var(--color-text-dim)] border-t border-[var(--color-border-subtle)]">
|
||||
{sqlResult.rows.length} row{sqlResult.rows.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
-1
@@ -242,4 +242,28 @@ body,
|
||||
[data-tooltip]::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ponytail: rendered Markdown for the session summary. Uses design tokens so
|
||||
it matches the glass-card surface. */
|
||||
.md-preview h1, .md-preview h2, .md-preview h3 {
|
||||
font-family: var(--font-heading), serif;
|
||||
color: var(--color-gold-bright);
|
||||
font-weight: 600;
|
||||
margin: 0.6em 0 0.3em;
|
||||
}
|
||||
.md-preview h1 { font-size: 1.15rem; }
|
||||
.md-preview h2 { font-size: 1.05rem; }
|
||||
.md-preview h3 { font-size: 0.95rem; }
|
||||
.md-preview p { margin: 0.35em 0; line-height: 1.5; }
|
||||
.md-preview ul { margin: 0.35em 0; padding-left: 1.2em; list-style: disc; }
|
||||
.md-preview li { margin: 0.15em 0; }
|
||||
.md-preview code {
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--color-bg-deep);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.md-preview a { color: var(--color-gold-bright); text-decoration: underline; }
|
||||
.md-preview strong { color: var(--color-text-primary); }
|
||||
@@ -34,11 +34,102 @@ export function parseLlmJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch (e) {
|
||||
// ponytail: LLMs often emit *almost*-valid JSON. Strict parse runs first so
|
||||
// valid output is never altered; only on failure do we try a conservative
|
||||
// repair pass (surface fixes → merge stray array literals → balance brackets).
|
||||
const repaired = repairJson(jsonStr);
|
||||
if (repaired !== jsonStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(repaired) as T;
|
||||
if (parsed) return parsed;
|
||||
} catch (e2) {
|
||||
console.error("JSON parse error (after repair):", e2, "Input:", jsonStr);
|
||||
}
|
||||
}
|
||||
console.error("JSON parse error:", e, "Input:", jsonStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative repairs for common LLM JSON mistakes. String-aware (never
|
||||
* touches text inside double-quoted strings — that was the bug that turned
|
||||
* "Coop's" / "crows'" into garbage). Only applied on parse failure. */
|
||||
function repairJson(input: string): string {
|
||||
return balanceJson(mergeSeparateArrays(surfaceFixes(input)));
|
||||
}
|
||||
|
||||
/** Drop trailing commas before closers and convert smart quotes → straight,
|
||||
* only outside double-quoted strings. */
|
||||
function surfaceFixes(s: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let inStr = false;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (inStr) {
|
||||
out += ch;
|
||||
if (ch === "\\") { out += s[i + 1] ?? ""; i += 2; continue; }
|
||||
if (ch === '"') inStr = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inStr = true; out += ch; i++; continue; }
|
||||
if (ch === "“" || ch === "”" || ch === "„") { out += '"'; i++; continue; }
|
||||
if (ch === ",") {
|
||||
let j = i + 1;
|
||||
while (j < s.length && /\s/.test(s[j])) j++;
|
||||
if (s[j] === "}" || s[j] === "]") { i++; continue; } // drop trailing comma
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collapse the common "array of strings written as separate array literals"
|
||||
* mistake: ["a"], ["b"], ["c"] → "a", "b", "c" (inside one array). */
|
||||
function mergeSeparateArrays(s: string): string {
|
||||
return s.replace(/"\s*\]\s*,\s*\[\s*"/g, '", "');
|
||||
}
|
||||
|
||||
/** Stack-based bracket balance. On a closer that doesn't match the top, close
|
||||
* the open intermediate openers first (inserts the `}` the LLM forgot before a
|
||||
* `]`), then match. Closers with no matching opener anywhere are stray → skip.
|
||||
* Any still-open openers at the end get their closers appended. */
|
||||
function balanceJson(s: string): string {
|
||||
let out = "";
|
||||
const st: string[] = [];
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
const closeOf = (o: string) => (o === "{" ? "}" : "]");
|
||||
for (const ch of s) {
|
||||
if (inStr) {
|
||||
out += ch;
|
||||
if (esc) esc = false;
|
||||
else if (ch === "\\") esc = true;
|
||||
else if (ch === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inStr = true; out += ch; continue; }
|
||||
if (ch === "{" || ch === "[") { st.push(ch); out += ch; continue; }
|
||||
if (ch === "}" || ch === "]") {
|
||||
const want = ch === "}" ? "{" : "[";
|
||||
if (st.includes(want)) {
|
||||
while (st.length) {
|
||||
const top = st.pop()!;
|
||||
out += top === want ? ch : closeOf(top);
|
||||
if (top === want) break;
|
||||
}
|
||||
}
|
||||
// else: no matching opener anywhere → stray closer, skip
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
while (st.length) out += closeOf(st.pop()!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested objects into display-friendly strings.
|
||||
* If a value is an object, stringify it nicely.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// ponytail: minimal Markdown → HTML for session notes/summaries. Renders the
|
||||
// 80% a DM actually uses (headings, bold/italic, lists, code, paragraphs) in
|
||||
// ~40 lines. No dependency. Escapes HTML first so LLM output can't inject.
|
||||
// Upgrade path: swap for @uiw/react-md-editor if you need tables/images/GFM.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render a subset of Markdown to HTML. Input is treated as untrusted. */
|
||||
export function renderMarkdown(md: string): string {
|
||||
const lines = md.split("\n");
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim()) { closeList(); continue; }
|
||||
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) {
|
||||
closeList();
|
||||
const level = h[1].length;
|
||||
html.push(`<h${level}>${inline(h[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const li = line.match(/^[-*+]\s+(.*)$/);
|
||||
if (li) {
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${inline(li[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
const ol = line.match(/^\d+[.)]\s+(.*)$/);
|
||||
if (ol) {
|
||||
// ponytail: render ordered items as an unordered list too — keeping it
|
||||
// one-element-type saves a state machine. A DM reading notes won't mind.
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${inline(ol[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
// inline formatting: **bold**, *italic*, `code`, [text](url).
|
||||
function inline(s: string): string {
|
||||
let out = escapeHtml(s);
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
return out;
|
||||
}
|
||||
|
||||
// (self-check lives in the repo's node test mirror; this module is pure).
|
||||
Reference in New Issue
Block a user