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)
|
- [ ] 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
|
- [ ] 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)
|
- [ ] 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
|
- [ ] Gate image-gen UI behind macOS check; placeholder fallback elsewhere
|
||||||
- [ ] Add `image_model` to `LlmConfig` + settings picker (no second settings panel)
|
- [ ] Add `image_model` to `LlmConfig` + settings picker (no second settings panel)
|
||||||
- [x] Then proceed to lore RAG & polish below:
|
- [x] Then proceed to lore RAG & polish below:
|
||||||
- [x] Add lore chunking + embedding storage (SQLite + brute-force cosine; `sqlite-vec` upgrade path noted)
|
- [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)
|
- [x] Inject retrieved lore into LLM prompts (shared `generate` path)
|
||||||
- [ ] Soundboard / ambience module
|
- [x] Soundboard / ambience module
|
||||||
- [ ] Handout renderer (Markdown → PDF)
|
- [ ] Handout renderer (Markdown → PDF)
|
||||||
- [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states
|
- [ ] Polish all glassmorphism effects, micro-interactions, and gold glow states
|
||||||
- [ ] Ensure WCAG AA contrast on all text over glass cards
|
- [x] Ensure WCAG AA contrast on all text over glass cards
|
||||||
- [ ] Responsive bento collapse (2-column on narrow windows)
|
- [x] Responsive bento collapse (2-column on narrow windows)
|
||||||
- [ ] Accessibility pass (keyboard nav, screen reader labels, focus rings in `--gold-bright`)
|
- [x] Accessibility pass (keyboard nav, screen reader labels, focus rings in `--gold-bright`)
|
||||||
- [ ] Commit and tag `v0.6.0-lore-polish`
|
- [ ] Commit and tag `v0.6.0-lore-polish`
|
||||||
|
|
||||||
### Milestone 7 — Testing, CI, Distribution
|
### Milestone 7 — Testing, CI, Distribution
|
||||||
|
|||||||
+41
-33
@@ -4,8 +4,15 @@
|
|||||||
ordered by impact, not file order. Each item ships with a concrete, checkable
|
ordered by impact, not file order. Each item ships with a concrete, checkable
|
||||||
recommendation.*
|
recommendation.*
|
||||||
|
|
||||||
> **Status:** in progress — P0 and P1 shipped, P2 largely done. The
|
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
|
||||||
> checklist below is updated as items land.
|
> 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)
|
### P2 — Important (next month)
|
||||||
|
|
||||||
- [ ] **Initiative Tracker**:
|
- [x] **Initiative Tracker**:
|
||||||
- [x] Click-to-edit initiative number
|
- [x] Click-to-edit initiative number
|
||||||
- [x] Custom conditions
|
- [x] Custom conditions
|
||||||
- [x] Damage input (numeric, not +/- only)
|
- [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] Highlight next-up combatant
|
||||||
- [x] Confirm before Reset
|
- [x] Confirm before Reset
|
||||||
- [x] "Import from Encounter" button
|
- [x] "Import from Encounter" button
|
||||||
- [ ] **Dice Roller**:
|
- [x] **Dice Roller**:
|
||||||
- [x] Modifier input (saved per session)
|
- [x] Modifier input (saved per session)
|
||||||
- [x] Spacebar rolls
|
- [x] Spacebar rolls
|
||||||
- [x] Roll templates (Attack, Save, Check, Damage)
|
- [x] Roll templates (Attack, Save, Check, Damage)
|
||||||
- [x] Clear/copy history
|
- [x] Clear/copy history
|
||||||
- [ ] **Encounter Builder**:
|
- [x] **Encounter Builder**:
|
||||||
- [x] XP budget math (party size, level, difficulty)
|
- [x] XP budget math (party size, level, difficulty)
|
||||||
- [x] Color-coded difficulty
|
- [x] Color-coded difficulty
|
||||||
- [x] Send to Initiative Tracker
|
- [x] Send to Initiative Tracker
|
||||||
@@ -1070,43 +1077,43 @@ These are blockers or bugs that make the app feel broken.
|
|||||||
- [ ] **NPC Generator**:
|
- [ ] **NPC Generator**:
|
||||||
- [x] Rename "Class" to "Background" with curated list
|
- [x] Rename "Class" to "Background" with curated list
|
||||||
- [x] Expand races to 9 (Half-Orc, Goliath)
|
- [x] Expand races to 9 (Half-Orc, Goliath)
|
||||||
- [ ] NPC Roster view
|
- [ ] NPC Roster view (History view covers re-opening past NPCs)
|
||||||
- [ ] Stat block section (AC, HP, ability scores)
|
- [x] Stat block section (AC, HP, ability scores)
|
||||||
- [x] Random name button
|
- [x] Random name button
|
||||||
- [ ] Save portrait (right-click)
|
- [ ] Save portrait (right-click)
|
||||||
- [x] Edit personality/goals in-place
|
- [x] Edit personality/goals in-place
|
||||||
- [ ] **Item Forge**:
|
- [ ] **Item Forge**:
|
||||||
- [x] Click art to expand
|
- [x] Click art to expand
|
||||||
- [ ] Structured mechanics (attunement, charges, value, weight)
|
- [x] Structured mechanics (attunement, charges, value, weight)
|
||||||
- [ ] Save to inventory
|
- [ ] Save to inventory (History view covers re-opening past items)
|
||||||
- [x] Random item button
|
- [x] Random item button
|
||||||
- [ ] **Quest Designer**:
|
- [ ] **Quest Designer**:
|
||||||
- [ ] Branching quest graph (react-flow)
|
- [ ] Branching quest graph (react-flow)
|
||||||
- [x] Step status (Pending/Active/Done)
|
- [x] Step status (Pending/Active/Done)
|
||||||
- [x] Player-facing view toggle (hide Twist)
|
- [x] Player-facing view toggle (hide Twist)
|
||||||
- [ ] Reward breakdown (XP per character, gold, items)
|
- [x] Reward breakdown (XP per character, gold, items)
|
||||||
- [ ] Quest roster
|
- [ ] Quest roster (History view covers re-opening past quests)
|
||||||
- [ ] **Image Generator**:
|
- [ ] **Image Generator**:
|
||||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||||
- [ ] Batch mode (2×2 variants)
|
- [ ] Batch mode (2×2 variants)
|
||||||
- [ ] Gallery view with thumbnails
|
- [ ] Gallery view with thumbnails
|
||||||
- [x] Persist to campaign-scoped folder
|
- [x] Persist to campaign-scoped folder
|
||||||
- [x] Copy to clipboard
|
- [x] Copy to clipboard
|
||||||
- [ ] **Session Logger**:
|
- [x] **Session Logger**:
|
||||||
- [ ] Markdown editor + live preview
|
- [x] Markdown editor + live preview (minimal renderer; no dep)
|
||||||
- [ ] Sessions as first-class concept (multiple sessions)
|
- [x] Sessions as first-class concept (multiple sessions)
|
||||||
- [x] Streaming AI summary
|
- [x] Streaming AI summary
|
||||||
- [x] Export to Markdown
|
- [x] Export to Markdown
|
||||||
- [x] Per-entry actions (edit, delete, highlight)
|
- [x] Per-entry actions (edit, delete, highlight)
|
||||||
- [ ] Tags
|
- [x] Tags
|
||||||
- [ ] **Calendar**:
|
- [x] **Calendar**:
|
||||||
- [ ] Custom calendar editor
|
- [x] Custom calendar editor
|
||||||
- [x] Current campaign date with "Next day" button
|
- [x] Current campaign date with "Next day" button
|
||||||
- [x] Weather generator
|
- [x] Weather generator
|
||||||
- [x] Moon phases
|
- [x] Moon phases
|
||||||
- [ ] Event categories with colors
|
- [x] Event categories with colors
|
||||||
- [ ] Recurring events
|
- [x] Recurring events
|
||||||
- [ ] Agenda view
|
- [x] Agenda view
|
||||||
- [ ] **Soundboard**:
|
- [ ] **Soundboard**:
|
||||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||||
- [ ] Custom sound import (drag-drop)
|
- [ ] 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] Always-visible Stop All
|
||||||
- [x] Save/load board state
|
- [x] Save/load board state
|
||||||
- [x] Pre-warm AudioContext
|
- [x] Pre-warm AudioContext
|
||||||
- [ ] **Random Tables**:
|
- [x] **Random Tables**:
|
||||||
- [x] 10+ more built-in tables
|
- [x] 10+ more built-in tables
|
||||||
- [ ] User-defined tables (create/edit/delete)
|
- [x] User-defined tables (create/edit/delete)
|
||||||
- [ ] Table import (paste Markdown)
|
- [x] Table import (paste Markdown)
|
||||||
- [ ] Weighted tables
|
- [x] Weighted tables (weight % + gap warning)
|
||||||
- [x] Export table
|
- [x] Export table
|
||||||
- [ ] **World Builder**:
|
- [ ] **World Builder**:
|
||||||
- [x] Map canvas (react-konva)
|
- [x] Map canvas (react-konva)
|
||||||
- [x] Theme presets
|
- [x] Theme presets
|
||||||
- [ ] Hierarchy tree
|
- [ ] Hierarchy tree
|
||||||
- [x] Persist world
|
- [x] Persist world
|
||||||
- [ ] **Lore Panel**:
|
- [x] **Lore Panel**:
|
||||||
- [ ] Two-pane layout
|
- [x] Two-pane layout
|
||||||
- [ ] Drag-drop file upload
|
- [x] Drag-drop file upload (via native file input; directory picker needs tauri-plugin-dialog)
|
||||||
- [ ] Directory picker
|
- [ ] Directory picker (needs tauri-plugin-dialog)
|
||||||
- [ ] Chunk preview
|
- [x] Chunk preview
|
||||||
- [ ] Source filter
|
- [x] Source filter
|
||||||
- [x] ★ relevance (not raw cosine)
|
- [x] ★ relevance (not raw cosine)
|
||||||
- [x] Confirm before "Clear all"
|
- [x] Confirm before "Clear all"
|
||||||
- [ ] **Settings**:
|
- [x] **Settings**:
|
||||||
- [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
- [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
||||||
- [x] Connection test with model list
|
- [x] Connection test with model list
|
||||||
- [x] Model picker with autocomplete (`GET /api/tags`)
|
- [x] Model picker with autocomplete (`GET /api/tags`)
|
||||||
- [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
- [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
||||||
- [x] Reset to defaults
|
- [x] Reset to defaults
|
||||||
- [ ] Export/import config
|
- [x] Export/import config
|
||||||
- [ ] **Cross-cutting**:
|
- [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] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ?
|
||||||
- [x] Skeleton loaders for all async states
|
- [x] Skeleton loaders for all async states
|
||||||
- [x] Empty states for all tools
|
- [x] Empty states for all tools
|
||||||
|
|||||||
Generated
+10
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
@@ -1300,6 +1301,15 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@tauri-apps/plugin-log": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz",
|
"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/api": "^2",
|
||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
"@tauri-apps/plugin-store": "^2.4.3",
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
"konva": "^10.3.0",
|
"konva": "^10.3.0",
|
||||||
"lucide-react": "^0.525",
|
"lucide-react": "^0.525",
|
||||||
|
|||||||
Generated
+136
-2
@@ -752,6 +752,7 @@ name = "dm-pal"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -759,6 +760,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
@@ -2831,6 +2833,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
@@ -2852,12 +2855,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2891,10 +2896,34 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams 0.5.0",
|
||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -3781,6 +3810,24 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-fs"
|
name = "tauri-plugin-fs"
|
||||||
version = "2.5.1"
|
version = "2.5.1"
|
||||||
@@ -4607,6 +4654,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4945,6 +5005,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4978,13 +5047,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 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_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 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]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -5015,6 +5101,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5027,6 +5119,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5039,12 +5137,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5057,6 +5167,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5069,6 +5185,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5081,6 +5203,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -5093,6 +5221,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.5.40"
|
version = "0.5.40"
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ tauri = { version = "2.11.3", features = [] }
|
|||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-fs = "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"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"fs:allow-rename",
|
"fs:allow-rename",
|
||||||
"fs:allow-read-dir",
|
"fs:allow-read-dir",
|
||||||
"fs:allow-appdata-read-recursive",
|
"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 crate::llm::AppState;
|
||||||
use serde::Deserialize;
|
use futures_util::StreamExt;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
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.
|
// 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.
|
/// 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 {
|
struct OllamaImageLine {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
done: bool,
|
done: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
step: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
total: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
image: Option<String>,
|
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
|
/// Generate (or fetch from disk cache) an image for `prompt` via the configured
|
||||||
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`.
|
/// Ollama image 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.
|
/// so the front-end can fall back to a placeholder instead of a confusing timeout.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_image(
|
pub async fn generate_image(
|
||||||
app: tauri::AppHandle,
|
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
req: ImageRequest,
|
req: ImageRequest,
|
||||||
) -> Result<String, String> {
|
) -> 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 config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||||
let model = req.model.unwrap_or(config.image_model.clone());
|
let model = req.model.unwrap_or(config.image_model.clone());
|
||||||
|
|
||||||
let cache_dir = app
|
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?;
|
||||||
.path()
|
if cache_hit {
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.join("dm-toolkit")
|
|
||||||
.join("images");
|
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// Stable cache key over model + prompt. Regenerating with a tweaked prompt
|
|
||||||
// produces a new file; identical prompt reuses the cached PNG.
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
model.hash(&mut hasher);
|
|
||||||
req.prompt.hash(&mut hasher);
|
|
||||||
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
|
|
||||||
|
|
||||||
// Cache hit: return the stored PNG without calling the model.
|
|
||||||
if cache_path.exists() {
|
|
||||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||||
return Ok(data_url(&bytes));
|
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 client = reqwest::Client::new();
|
||||||
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
|
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false });
|
||||||
"model": model,
|
|
||||||
"prompt": req.prompt,
|
|
||||||
"stream": false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let res = client
|
let res = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
@@ -82,19 +183,47 @@ pub async fn generate_image(
|
|||||||
return Err(format!("image error {status}: {text}"));
|
return Err(format!("image error {status}: {text}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ollama returns newline-delimited JSON even with stream:false for image models;
|
// ponytail: Ollama image models emit NDJSON even with stream:false —
|
||||||
// the final line with `done: true` carries the singular `image` base64 field.
|
// progress lines (step/total) then a final done:true carrying the image.
|
||||||
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
|
// 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;
|
let mut png_b64: Option<String> = None;
|
||||||
for line in body_text.lines() {
|
let mut buffer = String::new();
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() {
|
let apply_line = |line: &str, png: &mut Option<String>, ch: Option<&Channel<ImageEvent>>| {
|
||||||
continue;
|
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 let Some(ch) = channel {
|
||||||
if parsed.done {
|
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;
|
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())?;
|
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)?;
|
let png_bytes = base64_decode(&b64)?;
|
||||||
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
|
Ok(png_bytes)
|
||||||
Ok(data_url(&png_bytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_url(png: &[u8]) -> String {
|
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 out = String::with_capacity((png.len() + 2) / 3 * 4);
|
||||||
let mut chunks = png.chunks_exact(3);
|
let mut chunks = png.chunks_exact(3);
|
||||||
for c in &mut chunks {
|
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 >> 18) & 63] as char);
|
||||||
out.push(table[(n >> 12) & 63] as char);
|
out.push(table[(n >> 12) & 63] as char);
|
||||||
out.push(table[(n >> 6) & 63] as char);
|
out.push(table[(n >> 6) & 63] as char);
|
||||||
@@ -200,4 +326,48 @@ mod tests {
|
|||||||
let s = format!("{:016x}", h.finish());
|
let s = format!("{:016x}", h.finish());
|
||||||
assert_eq!(s.len(), 16);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,3 +2,4 @@ pub mod llm_commands;
|
|||||||
pub mod image_commands;
|
pub mod image_commands;
|
||||||
pub mod rag_commands;
|
pub mod rag_commands;
|
||||||
pub mod generation_commands;
|
pub mod generation_commands;
|
||||||
|
pub mod data_commands;
|
||||||
@@ -56,3 +56,22 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, Str
|
|||||||
pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option<String>) -> Result<(), String> {
|
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())
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+36
-5
@@ -2,10 +2,37 @@ mod llm;
|
|||||||
mod rag;
|
mod rag;
|
||||||
mod generations;
|
mod generations;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod sql_viewer;
|
||||||
|
|
||||||
use llm::AppState;
|
use llm::AppState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -13,12 +40,9 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_log::Builder::default().build())
|
.plugin(tauri_plugin_log::Builder::default().build())
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let data_dir = app
|
let data_dir = resolve_data_dir(&app.handle());
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.expect("app data dir")
|
|
||||||
.join("dm-toolkit");
|
|
||||||
let rag = rag::RagStore::open(&data_dir.join("lore"))
|
let rag = rag::RagStore::open(&data_dir.join("lore"))
|
||||||
.expect("open lore db");
|
.expect("open lore db");
|
||||||
let gen = generations::GenerationStore::open(&data_dir)
|
let gen = generations::GenerationStore::open(&data_dir)
|
||||||
@@ -27,6 +51,7 @@ pub fn run() {
|
|||||||
config: Mutex::new(llm::LlmConfig::default()),
|
config: Mutex::new(llm::LlmConfig::default()),
|
||||||
rag,
|
rag,
|
||||||
gen,
|
gen,
|
||||||
|
data_dir,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -38,15 +63,21 @@ pub fn run() {
|
|||||||
commands::llm_commands::set_llm_config,
|
commands::llm_commands::set_llm_config,
|
||||||
commands::llm_commands::test_connection,
|
commands::llm_commands::test_connection,
|
||||||
commands::image_commands::generate_image,
|
commands::image_commands::generate_image,
|
||||||
|
commands::image_commands::generate_image_stream,
|
||||||
commands::rag_commands::rag_add,
|
commands::rag_commands::rag_add,
|
||||||
commands::rag_commands::rag_search,
|
commands::rag_commands::rag_search,
|
||||||
commands::rag_commands::rag_list,
|
commands::rag_commands::rag_list,
|
||||||
commands::rag_commands::rag_clear,
|
commands::rag_commands::rag_clear,
|
||||||
|
commands::rag_commands::rag_chunks,
|
||||||
commands::generation_commands::generation_add,
|
commands::generation_commands::generation_add,
|
||||||
commands::generation_commands::generation_list,
|
commands::generation_commands::generation_list,
|
||||||
commands::generation_commands::generation_get,
|
commands::generation_commands::generation_get,
|
||||||
commands::generation_commands::generation_delete,
|
commands::generation_commands::generation_delete,
|
||||||
commands::generation_commands::generation_counts,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pub struct AppState {
|
|||||||
pub config: Mutex<LlmConfig>,
|
pub config: Mutex<LlmConfig>,
|
||||||
pub rag: crate::rag::RagStore,
|
pub rag: crate::rag::RagStore,
|
||||||
pub gen: crate::generations::GenerationStore,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -179,6 +179,30 @@ impl RagStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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)]
|
#[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";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
const MONTHS = [
|
// ponytail: the calendar is user-configurable for homebrew campaigns.
|
||||||
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
|
// `months` carry their own day count so a custom calendar can have variable
|
||||||
"Flamerule", "Eleasis", "Eleint", "Marpenoth", "Uktar", "Nightal",
|
// 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 {
|
interface CalendarEvent {
|
||||||
day: number;
|
day: number;
|
||||||
month: number;
|
month: number;
|
||||||
text: string;
|
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() {
|
export function CalendarWidget() {
|
||||||
|
const [config, setConfig] = usePersistentState<CalConfig>("calendar.config", DEFAULT_CONFIG);
|
||||||
const [month, setMonth] = useState(0);
|
const [month, setMonth] = useState(0);
|
||||||
const [year, setYear] = useState(1492);
|
const [year, setYear] = useState(1492);
|
||||||
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
|
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
|
||||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
const [newEvent, setNewEvent] = useState("");
|
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.
|
// 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 }>(
|
const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>(
|
||||||
"calendar.today",
|
"calendar.today",
|
||||||
{ day: 15, month: 5, year: 1492 },
|
{ 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 = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];
|
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 {
|
function moonPhase(day: number, month: number, year: number): string {
|
||||||
const idx = Math.floor(((year * 360 + month * 30 + (day - 1)) % 30) / 30 * 8);
|
const idx = dayIndex(day, month, year, config);
|
||||||
return MOON_PHASES[((idx % 8) + 8) % 8];
|
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 = [
|
const WEATHER = [
|
||||||
"☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm",
|
"☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm",
|
||||||
"❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural",
|
"❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural",
|
||||||
];
|
];
|
||||||
function weatherFor(day: number, month: number, year: number): string {
|
function weatherFor(day: number, month: number, year: number): string {
|
||||||
const seed = (year * 360 + month * 30 + (day - 1)) % 97;
|
const idx = dayIndex(day, month, year, config);
|
||||||
return WEATHER[seed % WEATHER.length];
|
return WEATHER[Math.abs(idx) % WEATHER.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance the campaign date by one day (30-day months, 12-month year).
|
|
||||||
function nextDay() {
|
function nextDay() {
|
||||||
setToday((t) => {
|
setToday((t) => {
|
||||||
let { day, month, year } = t;
|
let { day, month, year } = t;
|
||||||
day += 1;
|
day += 1;
|
||||||
if (day > 30) { day = 1; month += 1; }
|
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
|
||||||
if (month > 11) { month = 0; year += 1; }
|
if (month >= config.months.length) { month = 0; year += 1; }
|
||||||
return { day, month, year };
|
return { day, month, year };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,7 +125,7 @@ export function CalendarWidget() {
|
|||||||
if (!newEvent.trim() || selectedDay === null) return;
|
if (!newEvent.trim() || selectedDay === null) return;
|
||||||
setEvents((prev) => [
|
setEvents((prev) => [
|
||||||
...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("");
|
setNewEvent("");
|
||||||
}
|
}
|
||||||
@@ -78,130 +134,303 @@ export function CalendarWidget() {
|
|||||||
setEvents((prev) => prev.filter((_, i) => i !== idx));
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
{/* Month navigation */}
|
{/* View toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={jumpToToday}
|
onClick={() => setView("month")}
|
||||||
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)]"
|
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)]"}`}
|
||||||
title="Jump the view to today"
|
|
||||||
>
|
>
|
||||||
Today
|
Month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={nextDay}
|
onClick={() => setView("agenda")}
|
||||||
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)]"
|
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)]"}`}
|
||||||
title="Advance the campaign date by one day"
|
|
||||||
>
|
>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Day grid */}
|
{view === "edit" ? (
|
||||||
<div className="grid grid-cols-7 gap-0.5">
|
<div className="flex-1 overflow-y-auto flex flex-col gap-2">
|
||||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
<div className="text-[10px] text-[var(--color-text-dim)]">
|
||||||
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
Edit months, day counts, and the year label. Changes apply immediately.
|
||||||
{d}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-2">
|
||||||
{/* Start offset - Faerûn calendar starts on first of month */}
|
<label className="text-[10px] text-[var(--color-text-secondary)]">Year label</label>
|
||||||
{Array.from({ length: 30 }, (_, i) => {
|
<input
|
||||||
const dayNum = i + 1;
|
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"
|
||||||
const isToday = dayNum === today.day && month === today.month && year === today.year;
|
value={config.yearLabel}
|
||||||
const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
|
onChange={(e) => setConfig({ ...config, yearLabel: e.target.value })}
|
||||||
const isSelected = dayNum === selectedDay;
|
placeholder="DR"
|
||||||
|
/>
|
||||||
return (
|
<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
|
<button
|
||||||
key={dayNum}
|
onClick={() => setConfig({ ...config, months: [...config.months, { name: `Month ${config.months.length + 1}`, days: 30 }] })}
|
||||||
onClick={() => setSelectedDay(dayNum)}
|
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"
|
||||||
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)]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{dayNum}
|
+ month
|
||||||
{hasEvent && !isToday && (
|
</button>
|
||||||
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
<button
|
||||||
)}
|
onClick={() => { setConfig(DEFAULT_CONFIG); setMonth(0); }}
|
||||||
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
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>
|
</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>
|
</div>
|
||||||
{dayEvents.length === 0 && (
|
</div>
|
||||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</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 }} />
|
// Agenda view — next events from today forward.
|
||||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
<div className="flex-1 overflow-y-auto">
|
||||||
<button
|
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
|
||||||
onClick={() => removeEvent(events.indexOf(e))}
|
Upcoming events
|
||||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
</div>
|
||||||
>
|
{agenda.length === 0 && (
|
||||||
×
|
<div className="text-[var(--color-text-dim)] text-[10px] italic">No upcoming events.</div>
|
||||||
</button>
|
)}
|
||||||
|
{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>
|
||||||
))}
|
))}
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="mt-2 flex justify-end">
|
||||||
<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…"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={addEvent}
|
onClick={nextDay}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+96
-132
@@ -12,10 +12,9 @@ import {
|
|||||||
Shuffle,
|
Shuffle,
|
||||||
Flag,
|
Flag,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ArrowRight,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BentoCard } from "./BentoCard";
|
|
||||||
import type { View } from "../App";
|
import type { View } from "../App";
|
||||||
import {
|
import {
|
||||||
listGenerations,
|
listGenerations,
|
||||||
@@ -28,111 +27,61 @@ interface DashboardProps {
|
|||||||
onNavigate: (view: View) => void;
|
onNavigate: (view: View) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: launcher cards — icon, title, one-line description, Open CTA.
|
// ponytail: a launcher tile is ONE button — the whole card is the click target
|
||||||
// No embedded mini-tools: the dashboard is a launcher, not an ant farm.
|
// (the old dashboard only wired the tiny "Open" link, so the boxes felt dead).
|
||||||
interface Card {
|
// No redundant double-header; just icon, title, one-line description, and an
|
||||||
icon: typeof Map;
|
// optional "last used" footer. Grouped into sections so 13 tools stop being a
|
||||||
|
// wall of identical tiles.
|
||||||
|
interface Tile {
|
||||||
|
icon: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
view: View;
|
view: View;
|
||||||
span?: string;
|
|
||||||
// when set, the card shows "Last: {title} · {relativeTime}" from history.
|
|
||||||
kind?: GenerationKind;
|
kind?: GenerationKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARDS: Card[] = [
|
interface Section {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
tiles: Tile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: Section[] = [
|
||||||
{
|
{
|
||||||
icon: Map,
|
label: "At the table",
|
||||||
title: "World Builder",
|
hint: "Live-session tools",
|
||||||
description: "Generate a world with regions, landmarks, and conflicts.",
|
tiles: [
|
||||||
view: "world",
|
{ icon: Timer, title: "Initiative", description: "Track combatants, HP, conditions, and turns.", view: "initiative" },
|
||||||
span: "sm:col-span-2",
|
{ icon: Dice5, title: "Dice Roller", description: "Roll with advantage, modifiers, and templates.", view: "dice" },
|
||||||
kind: "world",
|
{ 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,
|
label: "Prep & create",
|
||||||
title: "Initiative",
|
hint: "Generators for your next session",
|
||||||
description: "Track combatants, HP, conditions, and turn order.",
|
tiles: [
|
||||||
view: "initiative",
|
{ icon: Swords, title: "Encounter", description: "Build balanced encounters with an XP budget.", view: "encounter", kind: "encounter" },
|
||||||
kind: undefined,
|
{ 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,
|
label: "World & reference",
|
||||||
title: "Dice Roller",
|
hint: "Your campaign bible",
|
||||||
description: "Roll with advantage, modifiers, and roll templates.",
|
tiles: [
|
||||||
view: "dice",
|
{ 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: Swords,
|
{ icon: Shuffle, title: "Random Tables", description: "Roll on built-in and custom tables.", view: "tables" },
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Dashboard({ onNavigate }: DashboardProps) {
|
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.
|
// "Last: …" footer. listGenerations returns newest-first per kind.
|
||||||
const [latest, setLatest] = useState<Partial<Record<GenerationKind, GenerationSummary>>>({});
|
const [latest, setLatest] = useState<Partial<Record<GenerationKind, GenerationSummary>>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,47 +100,62 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="h-full overflow-y-auto bg-[var(--color-bg-deep)]">
|
||||||
{CARDS.map((card) => {
|
<div className="max-w-6xl mx-auto px-6 py-6 flex flex-col gap-7">
|
||||||
const last = card.kind ? latest[card.kind] : undefined;
|
{SECTIONS.map((section) => (
|
||||||
return (
|
<section key={section.label} className="flex flex-col gap-3">
|
||||||
<BentoCard
|
<div className="flex items-baseline gap-3">
|
||||||
key={card.view}
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-widest uppercase">
|
||||||
title={card.title}
|
{section.label}
|
||||||
icon={<card.icon size={16} />}
|
</h2>
|
||||||
span={card.span}
|
<span className="text-[10px] text-[var(--color-text-dim)] tracking-wide">
|
||||||
>
|
{section.hint}
|
||||||
<div className="flex flex-col h-full min-h-[120px]">
|
</span>
|
||||||
<div className="flex items-start gap-3 flex-1">
|
<div className="flex-1 h-px bg-[var(--color-border-subtle)]" />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { ImageOff } from "lucide-react";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
@@ -13,17 +13,30 @@ interface Props {
|
|||||||
aspect?: string;
|
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.
|
* 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.
|
* 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
|
* macOS-only: on other OSes the backend returns an error and we show a
|
||||||
* placeholder instead of a confusing timeout.
|
* 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) {
|
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
const [expanded, setExpanded] = 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();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,22 +44,40 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setUnsupported(false);
|
setUnsupported(false);
|
||||||
invoke<string>("generate_image", { req: { prompt } })
|
setProgress(null);
|
||||||
.then((url) => {
|
|
||||||
if (!cancelled) setDataUrl(url);
|
const channel = new Channel<ImageEvent>();
|
||||||
})
|
channel.onmessage = (event) => {
|
||||||
.catch((e) => {
|
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);
|
const msg = String(e);
|
||||||
if (!cancelled) {
|
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
else addToast(`Image failed: ${msg}`, "error");
|
||||||
else addToast(`Image failed: ${msg}`, "error");
|
setLoading(false);
|
||||||
}
|
setProgress(null);
|
||||||
})
|
}
|
||||||
.finally(() => !cancelled && setLoading(false));
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [prompt, nonce]);
|
}, [prompt, nonce, addToast]);
|
||||||
|
|
||||||
if (!prompt) return null;
|
if (!prompt) return null;
|
||||||
|
|
||||||
@@ -64,10 +95,20 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
<div className={box}>
|
<div className={box}>
|
||||||
<div className="absolute inset-0 skeleton" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,20 @@ import { useToast } from "./Toast";
|
|||||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
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 {
|
interface ItemResponse {
|
||||||
name: string;
|
name: string;
|
||||||
rarity: string;
|
rarity: string;
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
mechanical: string | Record<string, unknown>;
|
mechanical: string | Record<string, unknown> | ItemMechanics;
|
||||||
lore: string;
|
lore: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +75,11 @@ The JSON must have exactly these keys:
|
|||||||
- "rarity": "${rarity}" (string)
|
- "rarity": "${rarity}" (string)
|
||||||
- "type": "${itemType}" (string)
|
- "type": "${itemType}" (string)
|
||||||
- "description": a vivid 2-3 sentence physical description (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)`,
|
- "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.",
|
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||||
temperature: 0.85,
|
temperature: 0.85,
|
||||||
max_tokens: 500,
|
max_tokens: 600,
|
||||||
ragQuery: `${rarity} ${itemType} ${prompt}`,
|
ragQuery: `${rarity} ${itemType} ${prompt}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,6 +116,12 @@ The JSON must have exactly these keys:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
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;
|
const hasParseError = !item && rawResponse;
|
||||||
|
|
||||||
// ponytail: one-click "surprise me" — random rarity + type, no typed prompt.
|
// 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">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+232
-97
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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 {
|
interface RagSource {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -13,6 +15,11 @@ interface RagHit {
|
|||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RagChunk {
|
||||||
|
id: number;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function LorePanel() {
|
export function LorePanel() {
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
@@ -24,6 +31,13 @@ export function LorePanel() {
|
|||||||
const [hits, setHits] = useState<RagHit[]>([]);
|
const [hits, setHits] = useState<RagHit[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [confirmClear, setConfirmClear] = 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() {
|
async function loadSources() {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +51,25 @@ export function LorePanel() {
|
|||||||
loadSources();
|
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() {
|
async function add() {
|
||||||
if (!source.trim() || !text.trim()) return;
|
if (!source.trim() || !text.trim()) return;
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
@@ -55,20 +88,43 @@ export function LorePanel() {
|
|||||||
|
|
||||||
async function clearOne(s: string) {
|
async function clearOne(s: string) {
|
||||||
await invoke("rag_clear", { source: s });
|
await invoke("rag_clear", { source: s });
|
||||||
|
if (expanded === s) setExpanded(null);
|
||||||
loadSources();
|
loadSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearAll() {
|
async function clearAll() {
|
||||||
await invoke("rag_clear", { source: null });
|
await invoke("rag_clear", { source: null });
|
||||||
setConfirmClear(false);
|
setConfirmClear(false);
|
||||||
|
setExpanded(null);
|
||||||
loadSources();
|
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() {
|
async function search() {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
@@ -76,91 +132,138 @@ export function LorePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<div className="flex gap-4 h-full text-sm">
|
||||||
{/* Add lore */}
|
{/* Left pane: add + indexed sources */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3 w-1/2 min-w-0 overflow-y-auto">
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
<div className="flex flex-col gap-2">
|
||||||
<input
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
<input
|
||||||
value={source}
|
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"
|
||||||
onChange={(e) => setSource(e.target.value)}
|
value={source}
|
||||||
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
|
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"
|
<textarea
|
||||||
value={text}
|
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"
|
||||||
onChange={(e) => setText(e.target.value)}
|
value={text}
|
||||||
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
|
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}
|
<button
|
||||||
disabled={adding}
|
onClick={add}
|
||||||
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"
|
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>
|
{adding ? "Embedding…" : "Add to Lore"}
|
||||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
</button>
|
||||||
</div>
|
{/* 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 flex-col gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
{sources.length > 0 && (
|
||||||
{sources.length > 0 && (
|
confirmClear ? (
|
||||||
confirmClear ? (
|
<div className="flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||||
<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
|
<button
|
||||||
onClick={clearAll}
|
onClick={() => setConfirmClear(true)}
|
||||||
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Yes
|
clear all
|
||||||
</button>
|
</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"
|
</div>
|
||||||
>
|
{sources.length === 0 ? (
|
||||||
No
|
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
|
||||||
</button>
|
) : (
|
||||||
</div>
|
<ul className="flex flex-col gap-1">
|
||||||
) : (
|
{sources.map((s) => {
|
||||||
<button
|
const isOpen = expanded === s.source;
|
||||||
onClick={() => setConfirmClear(true)}
|
return (
|
||||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
<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">
|
||||||
clear all
|
<button
|
||||||
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Test retrieval */}
|
{/* Right pane: search + explore */}
|
||||||
<div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
<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">Test Retrieval</h3>
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Search Lore</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<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}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||||
@@ -174,28 +277,60 @@ export function LorePanel() {
|
|||||||
{searching ? "…" : "Search"}
|
{searching ? "…" : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hits.length > 0 && (
|
{/* Source filter chips */}
|
||||||
<ul className="flex flex-col gap-2">
|
{sources.length > 0 && (
|
||||||
{hits.map((h, i) => (
|
<div className="flex flex-wrap gap-1">
|
||||||
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
<span className="text-[10px] text-[var(--color-text-dim)] self-center">Filter:</span>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<button
|
||||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
onClick={() => setFilterSources(new Set())}
|
||||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||||
DM can't read. Clamp 0–1, gold fill. */}
|
filterSources.size === 0
|
||||||
<div className="flex items-center gap-1.5">
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
}`}
|
||||||
|
>
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
</li>
|
||||||
</li>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</ul>
|
) : (
|
||||||
)}
|
<p className="text-xs text-[var(--color-text-dim)] italic mt-2">
|
||||||
{hits.length === 0 && query && !searching && (
|
{query ? "No matches." : "Search to retrieve the most relevant lore chunks."}
|
||||||
<p className="text-xs text-[var(--color-text-dim)]">No matches.</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,22 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
|||||||
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
|
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
|
||||||
import { Dice5 } from "lucide-react";
|
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 {
|
interface NpcResponse {
|
||||||
bio: string;
|
bio: string;
|
||||||
personality: string[];
|
personality: string[];
|
||||||
goals: string[];
|
goals: string[];
|
||||||
|
stats: NpcStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -79,11 +91,12 @@ Provide the response as a JSON object with exactly these keys:
|
|||||||
- "bio": a 2-3 sentence backstory
|
- "bio": a 2-3 sentence backstory
|
||||||
- "personality": an array of 3-5 personality traits (each trait must be a string, not an object)
|
- "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)
|
- "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 }.`;
|
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
|
||||||
|
|
||||||
const result = await invoke<string>("generate", {
|
const result = await invoke<string>("generate", {
|
||||||
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512, 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
|
// 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.
|
// stay consistent with the growing cast.
|
||||||
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
|
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
|
||||||
const goals = ensureArray(parsed.goals).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(
|
void addToLore(
|
||||||
`NPC: ${name || race + " " + background}`,
|
`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 {
|
} else {
|
||||||
addToast("LLM returned an unparseable response", "error");
|
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"}
|
{name || "NPC"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
|
<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>
|
</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 { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
@@ -9,7 +9,16 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
|||||||
interface QuestStep {
|
interface QuestStep {
|
||||||
title: string;
|
title: string;
|
||||||
description: 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 {
|
interface QuestResponse {
|
||||||
@@ -17,7 +26,7 @@ interface QuestResponse {
|
|||||||
hook: string;
|
hook: string;
|
||||||
steps: QuestStep[];
|
steps: QuestStep[];
|
||||||
twist: string;
|
twist: string;
|
||||||
reward: string;
|
reward: string | QuestReward;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -28,6 +37,7 @@ interface Props {
|
|||||||
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [theme, setTheme] = useState("");
|
const [theme, setTheme] = useState("");
|
||||||
const [level, setLevel] = useState(5);
|
const [level, setLevel] = useState(5);
|
||||||
|
const [partySize, setPartySize] = useState(4);
|
||||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -52,20 +62,20 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
try {
|
try {
|
||||||
const result = await invoke<string>("generate", {
|
const result = await invoke<string>("generate", {
|
||||||
req: {
|
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}` : ""}
|
${theme ? `Theme: ${theme}` : ""}
|
||||||
|
|
||||||
Provide the response as a JSON object with exactly these keys:
|
Provide the response as a JSON object with exactly these keys:
|
||||||
- "title": a catchy quest name
|
- "title": a catchy quest name
|
||||||
- "hook": 1-2 sentences describing how the party gets involved
|
- "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
|
- "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 }.`,
|
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.",
|
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,
|
temperature: 0.85,
|
||||||
max_tokens: 700,
|
max_tokens: 800,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const parsed = parseLlmJson<QuestResponse>(result);
|
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}` : ""}`;
|
const source = `Level ${level}${theme ? `, ${theme}` : ""}`;
|
||||||
void addGeneration({ kind: "quest", title, data: result, source });
|
void addGeneration({ kind: "quest", title, data: result, source });
|
||||||
const steps = (parsed.steps || [])
|
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");
|
.join("\n");
|
||||||
void addToLore(
|
void addToLore(
|
||||||
`Quest: ${parsed.title || "Untitled"}`,
|
`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 {
|
} else {
|
||||||
addToast("LLM returned an unparseable response", "error");
|
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>
|
</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
|
<button
|
||||||
onClick={generate}
|
onClick={generate}
|
||||||
disabled={loading}
|
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">
|
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
|
||||||
{quest.steps[currentStep].description}
|
{quest.steps[currentStep].description}
|
||||||
</p>
|
</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">
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between mt-3">
|
<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 && (
|
{quest.reward && (
|
||||||
<div className="rounded-lg border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-4 py-3">
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 {
|
interface TableEntry {
|
||||||
min: number;
|
min: number;
|
||||||
@@ -10,6 +12,8 @@ interface RandomTable {
|
|||||||
name: string;
|
name: string;
|
||||||
dice: string;
|
dice: string;
|
||||||
entries: TableEntry[];
|
entries: TableEntry[];
|
||||||
|
/** true for user-created tables (editable / deletable). */
|
||||||
|
custom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUILTIN_TABLES: RandomTable[] = [
|
const BUILTIN_TABLES: RandomTable[] = [
|
||||||
@@ -206,6 +210,59 @@ function rollDice(notation: string): number {
|
|||||||
return total + modifier;
|
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 {
|
interface RollLog {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
roll: number;
|
roll: number;
|
||||||
@@ -216,8 +273,24 @@ export function RandomTables() {
|
|||||||
const [selected, setSelected] = useState(0);
|
const [selected, setSelected] = useState(0);
|
||||||
const [log, setLog] = useState<RollLog[]>([]);
|
const [log, setLog] = useState<RollLog[]>([]);
|
||||||
const [customRoll, setCustomRoll] = useState<number | null>(null);
|
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() {
|
function exportTable() {
|
||||||
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
|
||||||
@@ -237,23 +310,129 @@ export function RandomTables() {
|
|||||||
setCustomRoll(rollVal);
|
setCustomRoll(rollVal);
|
||||||
}, [table]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
{/* Table selector */}
|
{/* Table selector */}
|
||||||
<select
|
<div className="flex gap-1.5">
|
||||||
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"
|
<select
|
||||||
value={selected}
|
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"
|
||||||
onChange={(e) => {
|
value={selected}
|
||||||
setSelected(parseInt(e.target.value));
|
onChange={(e) => {
|
||||||
setCustomRoll(null);
|
setSelected(parseInt(e.target.value));
|
||||||
}}
|
setCustomRoll(null);
|
||||||
>
|
setEditing(false);
|
||||||
{BUILTIN_TABLES.map((t, i) => (
|
}}
|
||||||
<option key={t.name} value={i}>
|
>
|
||||||
{t.name} ({t.dice})
|
{allTables.map((t, i) => (
|
||||||
</option>
|
<option key={`${t.name}-${i}`} value={i}>
|
||||||
))}
|
{t.custom ? "✎ " : ""}{t.name} ({t.dice})
|
||||||
</select>
|
</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 */}
|
{/* Roll + export */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -270,8 +449,87 @@ export function RandomTables() {
|
|||||||
>
|
>
|
||||||
⬇
|
⬇
|
||||||
</button>
|
</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>
|
</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 */}
|
{/* Result */}
|
||||||
{customRoll !== null && (
|
{customRoll !== null && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-center">
|
<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 */}
|
{/* Table preview */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
{table.entries.map((e) => (
|
{table.entries.map((e) => (
|
||||||
<div
|
<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="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>
|
||||||
))}
|
))}
|
||||||
</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 { invoke, Channel } from "@tauri-apps/api/core";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, type Generation } from "../lib/generations";
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
import { renderMarkdown } from "../lib/markdown";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prefill?: Generation | null;
|
prefill?: Generation | null;
|
||||||
onPrefillConsumed?: () => void;
|
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 = {}) {
|
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 [notes, setNotes] = useState("");
|
||||||
const [summary, setSummary] = usePersistentState<string>("session.summary", "");
|
const [tag, setTag] = useState(TAGS[0]);
|
||||||
const [loading, setLoading] = useState(false);
|
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 [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();
|
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) => {
|
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");
|
addToast(`Loaded "${g.title}" from history`, "info");
|
||||||
});
|
});
|
||||||
|
|
||||||
function addEntry() {
|
function addEntry() {
|
||||||
if (!notes.trim()) return;
|
if (!notes.trim() || !active) return;
|
||||||
setEntries((prev) => [
|
updateActive((s) => ({
|
||||||
{ text: notes.trim(), time: new Date().toLocaleTimeString() },
|
...s,
|
||||||
...prev,
|
entries: [{ text: notes.trim(), time: new Date().toLocaleString(), tag }, ...s.entries],
|
||||||
]);
|
}));
|
||||||
setNotes("");
|
setNotes("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: export the session as a Markdown file the DM can share or
|
function removeEntry(i: number) {
|
||||||
// paste into Discord. Reverses the entry list so it reads chronologically.
|
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() {
|
function exportMarkdown() {
|
||||||
if (entries.length === 0) return;
|
if (!active || active.entries.length === 0) return;
|
||||||
const lines = [...entries].reverse().map((e) => `- [${e.time}] ${e.text}`);
|
const lines = [...active.entries].reverse().map((e) => `- [${e.time}] ${e.tag ? `${e.tag} ` : ""}${e.text}`);
|
||||||
const md = `# Session ${new Date().toLocaleDateString()}\n\n## Notes\n\n${lines.join("\\n")}\n\n${summary ? `## AI Summary\n\n${summary}` : ""}`;
|
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 blob = new Blob([md], { type: "text/markdown" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
addToast("Exported session to Markdown", "success");
|
addToast("Exported session to Markdown", "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEntry(i: number) {
|
|
||||||
setEntries((prev) => prev.filter((_, j) => j !== i));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function summarize() {
|
async function summarize() {
|
||||||
if (entries.length === 0) return;
|
if (!active || active.entries.length === 0) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSummary("");
|
updateActive((s) => ({ ...s, summary: "" }));
|
||||||
setGotFirstToken(false);
|
setGotFirstToken(false);
|
||||||
const sessionText = entries.map((e) => `[${e.time}] ${e.text}`).join("\n");
|
const sessionText = active.entries.map((e) => `[${e.time}] ${e.tag ? `${e.tag} ` : ""}${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 channel = new Channel<{ type: string; data: string }>();
|
const channel = new Channel<{ type: string; data: string }>();
|
||||||
let streamed = "";
|
let streamed = "";
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
if (event.type === "token") {
|
if (event.type === "token") {
|
||||||
setGotFirstToken(true);
|
setGotFirstToken(true);
|
||||||
streamed = streamed + event.data;
|
streamed = streamed + event.data;
|
||||||
setSummary(streamed);
|
updateActive((s) => ({ ...s, summary: streamed }));
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
setGotFirstToken(true);
|
setGotFirstToken(true);
|
||||||
streamed = event.data;
|
streamed = event.data;
|
||||||
setSummary(streamed);
|
updateActive((s) => ({ ...s, summary: streamed }));
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
addToast(`Summary failed: ${event.data}`, "error");
|
addToast(`Summary failed: ${event.data}`, "error");
|
||||||
}
|
}
|
||||||
@@ -91,90 +157,201 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
});
|
});
|
||||||
addToast("Session summary generated", "success");
|
addToast("Session summary generated", "success");
|
||||||
const result = streamed;
|
const result = streamed;
|
||||||
const title = `Session ${new Date().toLocaleDateString()}`;
|
const title = active.title;
|
||||||
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
void addGeneration({ kind: "session", title, data: result, source: `${active.entries.length} notes` });
|
||||||
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
void addToLore(`Session: ${title}`, `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(`Summary failed: ${e}`, "error");
|
addToast(`Summary failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="flex gap-3 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
{/* Notes column */}
|
{/* Session switcher */}
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<textarea
|
{renaming ? (
|
||||||
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"
|
<input
|
||||||
value={notes}
|
autoFocus
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
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"
|
||||||
onKeyDown={(e) => {
|
value={active.title}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
onChange={(e) => updateActive((s) => ({ ...s, title: e.target.value }))}
|
||||||
e.preventDefault();
|
onBlur={() => setRenaming(false)}
|
||||||
addEntry();
|
onKeyDown={(e) => e.key === "Enter" && setRenaming(false)}
|
||||||
}
|
/>
|
||||||
}}
|
) : (
|
||||||
placeholder="Type a session note, press Enter to add…"
|
<select
|
||||||
/>
|
value={active.id}
|
||||||
<div className="flex gap-2">
|
onChange={(e) => { setActiveId(e.target.value); setFilterTag(null); setNotes(""); }}
|
||||||
<button
|
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"
|
||||||
onClick={addEntry}
|
aria-label="Switch session"
|
||||||
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
|
{sessions.map((s) => (
|
||||||
</button>
|
<option key={s.id} value={s.id}>
|
||||||
<button
|
{s.title} ({s.entries.length})
|
||||||
onClick={summarize}
|
</option>
|
||||||
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"
|
</select>
|
||||||
>
|
)}
|
||||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
<button
|
||||||
</button>
|
onClick={() => setRenaming((r) => !r)}
|
||||||
<button
|
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"
|
||||||
onClick={exportMarkdown}
|
title="Rename session"
|
||||||
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"
|
</button>
|
||||||
>
|
<button
|
||||||
⬇ Export
|
onClick={addSession}
|
||||||
</button>
|
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"
|
||||||
</div>
|
title="New session"
|
||||||
<div className="flex-1 overflow-y-auto">
|
>
|
||||||
{entries.map((e, i) => (
|
+ New
|
||||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
</button>
|
||||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
<button
|
||||||
{e.time}
|
onClick={deleteSession}
|
||||||
</span>
|
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"
|
||||||
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
title="Delete this session"
|
||||||
<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"
|
</button>
|
||||||
title="Delete entry"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Summary column */}
|
<div className="flex gap-3 flex-1 min-h-0">
|
||||||
<div className="flex-1 flex flex-col">
|
{/* Notes column */}
|
||||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
|
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
||||||
<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">
|
<textarea
|
||||||
{loading && !gotFirstToken ? (
|
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"
|
||||||
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
value={notes}
|
||||||
<span className="thinking-dot">•</span>
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
<span className="thinking-dot">•</span>
|
onKeyDown={(e) => {
|
||||||
<span className="thinking-dot">•</span>
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
</span>
|
e.preventDefault();
|
||||||
) : summary ? (
|
addEntry();
|
||||||
<span>{summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
}
|
||||||
) : (
|
}}
|
||||||
<span className="text-[var(--color-text-dim)] italic">
|
placeholder="Type a session note, press Enter to add…"
|
||||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
/>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface LlmConfig {
|
interface LlmConfig {
|
||||||
@@ -13,6 +14,12 @@ interface LlmConfig {
|
|||||||
embed_model: string;
|
embed_model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DataDirInfo {
|
||||||
|
current: string;
|
||||||
|
configured: string | null;
|
||||||
|
default: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPanel() {
|
export function SettingsPanel() {
|
||||||
const [config, setConfig] = useState<LlmConfig | null>(null);
|
const [config, setConfig] = useState<LlmConfig | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -21,6 +28,14 @@ export function SettingsPanel() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
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();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
// ponytail: auto-load on mount so users don't see a gate before the form.
|
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||||
@@ -32,9 +47,78 @@ export function SettingsPanel() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(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() {
|
async function saveConfig() {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -102,6 +186,38 @@ export function SettingsPanel() {
|
|||||||
addToast("Reset to defaults — click Save to apply", "info");
|
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) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
@@ -115,6 +231,51 @@ export function SettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<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>
|
<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 */}
|
{/* Provider presets */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -287,7 +448,7 @@ export function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={saveConfig}
|
onClick={saveConfig}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -301,7 +462,98 @@ export function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,3 +243,27 @@ body,
|
|||||||
transition: none;
|
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 {
|
try {
|
||||||
return JSON.parse(jsonStr) as T;
|
return JSON.parse(jsonStr) as T;
|
||||||
} catch (e) {
|
} 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);
|
console.error("JSON parse error:", e, "Input:", jsonStr);
|
||||||
return null;
|
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.
|
* Flatten nested objects into display-friendly strings.
|
||||||
* If a value is an object, stringify it nicely.
|
* 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