Compare commits

9 Commits

Author SHA1 Message Date
itsamejms 3eb0a2da2e fix: scrub PII from apple-signing env example + docs that quoted it 2026-09-06 23:20:40 +01:00
itsamejms edeeb7a5cc docs: repo review + master roadmap 2026-09-06 23:19:05 +01:00
itsamejms f3e06d2bd3 updating to use stable-diffusion.cpp for the image generation as ollama has since removed it 2026-08-24 11:23:10 +01:00
itsamejms da443175c5 adding the first run wizard again 2026-08-16 13:53:22 +01:00
itsamejms 65aee44d6b working through a review 2026-08-09 13:49:54 +01:00
itsamejms abf3668081 adding apple signing and updating the build script 2026-08-05 09:20:06 +01:00
itsamejms 35ec227531 chore: release v0.1.3 2026-08-04 17:22:47 +01:00
itsamejms d2eea494fe chore: release v0.1.2 2026-08-04 17:19:20 +01:00
itsamejms 639d768bf1 chore: release v0.1.1 2026-08-04 17:15:32 +01:00
35 changed files with 2322 additions and 528 deletions
+4
View File
@@ -15,6 +15,9 @@ dist-ssr
# Rust / Tauri # Rust / Tauri
src-tauri/target/ src-tauri/target/
# Apple signing credentials (never commit)
scripts/apple-signing.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -25,3 +28,4 @@ src-tauri/target/
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
secrets/
+120 -22
View File
@@ -1,32 +1,130 @@
# React + TypeScript + Vite # DM-Pal
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. An offline-first desktop toolkit for Dungeon Masters, powered by a local LLM.
Built with **Tauri v2** + **Rust** on the back end and **React 19** + **TypeScript**
on the front. Every generator is grounded in your own world bible via a local
RAG index — no data leaves your machine unless you point it at a remote API.
Currently, two official plugins are available: ![DM-Pal](public/favicon.svg)
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ## What's in the box
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler DM-Pal packs every tool a DM reaches for at and between the table, grouped
into **Session** (live) and **World** (prep) tools:
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - **Initiative Tracker** — combatants, HP, conditions, death saves, turn timer
- **Dice Roller** — notation parsing, advantage/disadvantage, roll templates
- **Encounter Builder** — AI-generated encounters with a 5e XP budget
- **NPC Generator** — portraits, personality, goals, stat blocks
- **Quest Designer** — multi-step quests with twists and reward breakdown
- **Item Forge** — magic items with art and structured mechanics
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama)
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
- **Soundboard** — synthesized ambience/SFX with one-click scenes
- **World Builder** — generated regions, landmarks, a draggable-pin map
- **Lore (RAG)** — index your world bible, ground every generation in it
- **Calendar** — custom fantasy calendars, weather, moon phases, events
- **Random Tables** — built-in and custom tables, weighted rolls
## Expanding the Oxlint configuration A **⌘K command palette**, **History view** (re-open any past generation), and
persistent state round it out — reload loses nothing.
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: ## Quick start
```json ### Prerequisites
{
"$schema": "./node_modules/oxlint/configuration_schema.json", - **Rust** + **Cargo** — https://rustup.rs
"plugins": ["react", "typescript", "oxc"], - **Node.js** 20+ — https://nodejs.org
"options": { - **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
"typeAware": true
}, ### Install & run
"rules": {
"react/rules-of-hooks": "error", ```bash
"react/only-export-components": ["warn", { "allowConstantExport": true }] npm install
} npm run tauri dev
}
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. On first launch open **Settings (⌘,)** and confirm the API URL, then pull a
text model (e.g. `llama3.2`) and an embedding model (e.g. `nomic-embed-text`):
```bash
ollama pull llama3.2
ollama pull nomic-embed-text
```
For image generation, DM-Pal talks to a [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) `sd-server` (cross-platform) via its AUTOMATIC1111-compatible API. Download a model (e.g. Z-Image-Turbo or FLUX.2-klein weights), start the server, then point DM-Pal at it under **Settings → Image Server** (default `http://localhost:1234`):
```bash
# build sd-server, then run with your chosen model
sd-server -m path/to/model.safetensors --vae path/to/vae.safetensors
```
## Where your data lives
All campaign data stays on disk under your OS app-data dir (default
`$APPDATA/dm-toolkit/`, configurable in Settings):
| File | Contents |
|------|----------|
| `lore.db` | RAG chunks + embeddings (SQLite) |
| `generations.db` | History of every generated NPC/encounter/item/quest/… |
| `images/` | Cached generated PNGs, keyed by prompt hash |
| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
Settings → **Data location** lets you relocate everything to an external drive
and migrates existing data for you.
## Architecture
```
React 19 + TypeScript ──invoke()/Channel──▶ Rust (Tauri v2)
Zustand · framer-motion LLM (Ollama / OpenAI-compatible)
react-konva (world map) SQLite (rusqlite, bundled)
Tailwind v4 + glassmorphism RAG (brute-force cosine → sqlite-vec path)
```
Each tool is a self-contained component mounted behind a single `renderView`
switch; the dashboard is a launcher of at-a-glance tiles. The design system is
dark-only (navy + Cinzel + gold), self-hosts its fonts for true offline use,
and meets WCAG AA contrast.
## Scripts
```bash
npm run dev # Vite dev server (frontend only)
npm run tauri dev # Full app, hot-reload
npm run build # tsc -b && vite build
npm run lint # oxlint
node scripts/check-worldmap.ts # world-map layout self-check
node scripts/check-encounter-budget.ts # XP-budget self-check
node scripts/check-dice.ts # dice-notation parser self-check
```
## Project layout
```
src/ React front end
components/ one file per tool
lib/ pure logic + persistence hooks
src-tauri/src/ Rust back end
commands/ Tauri IPC commands (llm, image, rag, data, generation)
llm/ Ollama/OpenAI client + config
rag/ embedding + cosine search
generations/ history store
docs/ plan + UI/UX review
```
## Roadmap
See [`docs/plan.md`](docs/plan.md) for the full plan and `docs/ui-ux-improvements.md`
for the living UI/UX review with a prioritized checklist.
Planned / in progress: first-run model wizard, quest branching graph, world
hierarchy tree, real ambience packs, GitHub Actions CI, code signing, and an
auto-updater.
## License
TBD. Built-in rule references use only 5e SRD / OGL / CC-BY content. Model files
ship under their own licenses (e.g. Meta's Llama license) — accept them in
Ollama before pulling.
+149
View File
@@ -911,6 +911,155 @@ A consolidated checklist covering all phases:
--- ---
## 17. Post-Review Action Items (Fresh Pass)
*Findings from a fresh codebase review (shell, components, backend, git
history). Ordered by impact within each tier. Each item ships as a concrete,
checkable change — no redesigns.*
### 17.1 Repo hygiene (do first — cheap)
- [x] **Rewrite `README.md`.** It is still the Vite template boilerplate
("React + TypeScript + Vite") and describes nothing about DM-Pal.
Replace with: one-paragraph pitch, screenshot, `npm run tauri dev`
quickstart, prerequisites (Ollama), where data lives, license note.
Highest-ROI 20-minute task in the repo.
- [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a
real Apple ID email in a tracked file.
Replace with `you@example.com`.
- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet`
Tauri command in `src-tauri/src/lib.rs`.** Leftover from
`npm create tauri-app`; nothing in the shell references Greet.
Verify with `rg Greet` first.
- [x] **Fix pointless ternary in `App.tsx` NavButton:**
`<item.icon size={item.view === "tables" ? 18 : 18} />` — both
branches are 18. Just `size={18}`.
- [x] **Collapse the three nav→kind maps in `App.tsx`** (`PREFILLABLE`,
`PREFILLABLE_TOOLS`, `viewMaxWidth`) into a single
`Record<View, { kind?, Comp?, maxWidth }>` and derive the inverse
map for `rehydrate`. Today adding a tool means editing five places;
this makes it one.
### 17.2 Tests & CI (Milestone 7, currently unshipped)
- [x] **`src/lib/encounter-budget.ts` self-check.** Non-trivial combat
math with no test. Add an `assert`-based `demo()` / one small
`test_encounter_budget.ts` so a tweaked XP number fails loudly.
- [x] **Dice-parser test.** `DiceRoller` parses `NdX±M`, advantage
(`kh1`/`kl1`), templates — a parser bug = wrong rolls at the table.
One `test_dice.ts` asserting `{count, sides, mod, keep}` for
`4d6+3`, `2d20kh1`, `1d20-2` is the smallest thing that catches
regressions. (Rust side has good `generations` + image-base64
tests; frontend has almost none — only `worldMap.ts`.)
- [ ] **Gitea Actions CI** (not GitHub — repo ships via `scripts/gitea-release.sh`).
A `.gitea/workflows/ci.yml` running `oxlint`, `tsc -b`, the three
`node scripts/check-*.ts` self-checks, and `cargo test` on each push.
Even lint-only is better than none. Same YAML syntax as GitHub
Actions, just a different directory.
- [ ] **Code signing + notarization** for macOS and Windows.
- [ ] **Auto-updater** via `tauri-plugin-updater` (+ delta updates for
model packs), then package with `npm run tauri build`.
### 17.3 Features still missing (verified unchecked in §16)
- [ ] **Quest branching graph (`reactflow`).** Quests are a linear
carousel today. Branching quests with conditional edges ("if they
spare the bandit → step 3; if they kill him → step 5") are what
make a quest *designer* vs a quest *outliner*. The single biggest
remaining "wow" gap in the generators.
- [ ] **World hierarchy tree** (continent → country → region → city).
Today `WorldBuilder` emits a flat `regions[]` + `landmarks[]`
pinned on a map, with no nesting or "drill into a region to
generate its sub-regions." A collapsible tree on the left of the
existing two-pane layout turns World Builder from a one-shot
generator into a living campaign bible.
- [x] **Lore directory picker.** Doc said "needs tauri-plugin-dialog" —
but the plugin is **already installed and used** in
`SettingsPanel`. This is unblocked: add an "Add directory…" button
in `LorePanel` that calls `open({ directory: true })` and walks
`*.md`/`*.txt`. Low effort, high value for DMs with an existing
world-bible folder.
- [ ] **Real ambience packs + custom sound import (Soundboard).** Ship
2–3 CC0 loops (Freesound/Pixabay) in `public/sounds/`, keep
synthesis as fallback, and add drag-an-MP3 onto a tile. The
difference between "demo" and "session-ready."
- [ ] **First-run model download / license wizard.** Today a new user
must hand-configure API URL + model name with no guidance. A
one-time wizard on first launch (detect Ollama → list
`GET /api/tags` → pick text + image + embed model → save) collapses
the "open Settings, stare at empty form, give up" funnel. Reuse
the existing connection-test + model-list plumbing from
`SettingsPanel`.
- [ ] **Image-gen into World Builder (map) + Encounter Builder**
(battle map + loot), per §16 item 10 — still only wired into NPC +
Item Forge.
- [ ] **Handout renderer** (Markdown → PDF), still pending from §15.
### 17.4 UX refinements (beyond the existing ui-ux-improvements.md)
- [ ] **Campaign concept.** Everything is one global store
(`dm-pal-state.json`) + two global SQLite DBs; a DM running two
campaigns can't separate them. A lightweight "active campaign"
selector in the title bar that namespaces store keys + DB files
(`<dataDir>/<campaign>/`) unlocks multi-campaign without a schema
migration. The data-dir relocation machinery already exists —
generalize it per-campaign.
- [x] **`?` shortcut help overlay.** Checklist marks `?` as shipped but
there's no visible cheatsheet — shortcuts are documented only
here. A small `?`-triggered modal listing
`⌘K / 1–9 / Space / ⌘, / ⌘S / Esc` is the difference between
"shortcuts exist" and "shortcuts are discoverable." ~40 lines.
- [x] **Global streaming indicator.** `ConnectionPill` shows
LLM/image/lore status but there's no "an LLM call is in flight"
signal. During a 15s image gen the only feedback is a skeleton in
one card. A subtle gold pulse on the pill (or a thin top-of-window
progress bar) tells the DM something is working even after they've
navigated away from the generating tool.
- [x] **Consistent image-gen macOS gating.** `ImageGenerator` shows a
clear "macOS only" message, but the `GeneratedImage` ✨ buttons
inside NPC/Item silently fall back. Show the same inline
"macOS only" notice there so a Windows DM isn't left wondering why
nothing happens.
- [ ] **Finish the empty-state set for Initiative** (and verify the
others) — one `<p>` + CTA each, already done elsewhere.
- [ ] **i18n** — extract strings to a `t()` helper (P3, only if shipping
beyond EN).
- [ ] **Draggable bento layout** (`react-grid-layout`) with persisted
layout per campaign (P3).
- [ ] **Console mode** for the dashboard — embed 2–3 chosen tools as
live-session cards (P3).
- [ ] **Player view** — a web app showing the DM's screen (initiative,
dice) to phones on the local network (P3).
- [ ] **Compendium integration** — pull monster stat blocks from a
local SRD JSON (P3).
- [ ] **Voice-to-text** for session notes (Whisper) (P3).
- [ ] **Macros** — named dice-roll buttons (P3).
- [ ] **Session replay** — record rolls/initiative/soundboard state
and replay (P3).
### 17.5 Suggested order of attack
| # | Item | Effort | Impact |
|---|------|--------|--------|
| 1 | Rewrite README | 20 min | High |
| 2 | Delete Greet + `greet` cmd, fix `18:18` ternary | 10 min | Low |
| 3 | Collapse the 3 nav→kind maps into one | 30 min | Medium |
| 4 | `encounter-budget` + dice-parser self-tests | 1 hr | Medium |
| 5 | Lore directory picker (dialog already installed) | 1 hr | High |
| 6 | First-run model wizard (reuse connection test) | 3 hrs | High |
| 7 | Quest branching graph (`reactflow`) | 4 hrs | High |
| 8 | World hierarchy tree | 3 hrs | High |
| 9 | `?` help overlay + global streaming indicator | 1 hr | Medium |
| 10 | Real ambience packs + sound import | 2 hrs | Medium |
| 11 | GitHub Actions CI | 1 hr | Medium |
| 12 | Campaign namespace selector | 4 hrs | High |
Items 1–5 form a single low-risk PR: README, dead-code cleanup, map
consolidation, two self-tests, lore directory picker — all independently
shippable in one session.
---
**You're now ready to spin up your own AI-powered Dungeon Master toolkit!** **You're now ready to spin up your own AI-powered Dungeon Master toolkit!**
Happy crafting — may your dice always land favorably! 🎲 Happy crafting — may your dice always land favorably! 🎲
+212
View File
@@ -0,0 +1,212 @@
# DM-Pal — Repo Review & Next Steps (2026-09-06)
*A fresh whole-repo pass: shell, components, Rust backend, scripts, git history,
and the two existing docs. Findings are verified against the code, not copied
from `plan.md` — several items in `plan.md` §17 have since shipped, and a few
claims there are stale (noted below). Each finding ships with a concrete fix.*
**Overall: healthy.** The architecture is clean (self-contained tools behind one
`renderView`, single shared `generate` path with lore injection, two SQLite
stores + a debounced UI-state store), the ponytail discipline is visible
everywhere, and the Rust side has real unit tests. The P0/P1 items from
`ui-ux-improvements.md` have genuinely landed. The biggest problems now are
one P0 functional bug (settings don't survive restart), one UX gap (streaming
is fake), and repo/distribution hygiene (CI, signing, stale docs).
---
## 1. Findings — ranked
### P0-1. LLM config is never persisted to disk — restart wipes Settings
`set_llm_config` (`src-tauri/src/commands/llm_commands.rs`) only mutates an
in-memory `Mutex<LlmConfig>`; `lib.rs` setup always starts from
`LlmConfig::default()`. Nothing writes the config to the prefs store —
`dm-pal-prefs.json` only holds `dataDir`. So **every app restart silently
resets API URL, API key, model, embed model, and image server URL to
defaults**. Worse, the wizard-completed flag lives in `localStorage`, so after
restart the DM isn't even re-prompted — they just get a broken connection.
**Fix (~30 lines):** in `set_llm_config`, `store.set("llmConfig", config)` on
the same `dm-pal-prefs.json` store used for `dataDir` (the store plumbing is
already there in `data_commands.rs`); in `lib.rs` setup, read it back before
`manage(AppState{...})`. The API key lands in plaintext in the OS app-data dir
— same trust level as every other local LLM client; fine for now, note it in
the README's data-locations table.
### P0-2. PII still in `scripts/apple-signing.env.example`
`plan.md` §17.1 marks "Scrub PII" as done. It isn't: the file still contains
the real Apple ID email and Team ID (and both docs quote them). 5-minute fix,
do it before the next push. Note: the values are already in git history from
earlier commits — scrubbing stops future copies, not the past.
### P1-1. "Streaming" is a single fake token — the biggest UX lever left
`generate_stream` does a **non-streaming** call and emits the whole response as
one `Token` (see the comment in `llm_commands.rs`). SessionLogger's "streaming
AI summary" and every generator therefore show 10–20 s of nothing on a local
model. Both transport deps are **already installed**: `reqwest` (`stream`
feature) + `futures-util`. Ollama needs `"stream": true` + NDJSON line
parsing; OpenAI-compatible needs SSE `data:` line parsing. Emit
`LlmEvent::Token` per line; `inject_lore`, busy-balancing, and the frontend
Channel plumbing all stay as-is. SessionLogger's progressive display already
handles multi-token arrival. ~3 h, highest UX-per-line-of-code item in the repo.
### P1-2. RAG: three real issues in the lore pipeline
1. **UTF-8 chunk-drop bug** (`rag/mod.rs::chunk`): long paragraphs are split
with `para.as_bytes().chunks(MAX_CHUNK_CHARS)` then
`if let Ok(s) = std::str::from_utf8(chunk)` — when the 1000-byte boundary
lands mid multibyte character (any accented name, CJK, emoji), `from_utf8`
errors and the **whole chunk is silently dropped**. Fix: back the split
index off to a `char_boundary`, e.g. scan back while
`!chunk.is_char_boundary(i)` (~6 lines). Add a test with a long accented
paragraph. (Also: `MAX_CHUNK_CHARS` is actually bytes — rename or note.)
2. **Re-adding a source duplicates every chunk**: `add_document` always
INSERTs. Adding the same file twice doubles its embedding weight and skews
search. Fix: `DELETE FROM chunks WHERE source = ?` before the insert loop
(one line).
3. **Changing `embed_model` silently poisons the index**: old-model vectors
and new-model query vectors are compared by dot product — garbage results.
Fix options: store the embed model per source and warn/refuse on mismatch
in `list_sources`, or a "Reindex all" button in the Lore panel (needs
re-chunk from `text`, which is already stored — feasible).
### P1-3. No CI, and the self-checks aren't aggregated
The repo has `oxlint`, `tsc -b`, three `node scripts/check-*.ts` self-checks,
and `cargo test` — **nothing runs them automatically**. Two-part fix:
1. Add to `package.json`:
`"check": "node scripts/check-dice.ts && node scripts/check-encounter-budget.ts && node scripts/check-worldmap.ts"`
2. A `.gitea/workflows/ci.yml` (release ships via `gitea-release.sh`, so
Gitea, not GitHub) running `npm ci`, `npm run lint`, `npm run build`,
`npm run check`, `cargo test` on push. Even lint-only beats none.
**Gotcha:** the check scripts are bare `.ts` run via Node type-stripping —
that needs **Node ≥ 23.6** (22.6 with `--experimental-strip-types`), but the
README says "Node.js 20+". Bump the README prerequisite or note it.
### P1-4. Version drift + stale docs
- `tauri.conf.json` says `0.1.3`, `Cargo.toml` says `0.1.0`, `package.json`
says `0.1.3`. Pick one source (tauri.conf is what ships) and have
`gitea-release.sh` assert they match.
- `README.md` line 21 still says image generation is "(macOS, via Ollama)" —
the backend moved to stable-diffusion.cpp `sd-server` (cross-platform) and
the rest of the README says so. Fix the one line.
- `plan.md` §4.4 documents the old Ollama image path + "macOS-only" gating as
current design; §10 has the same. The ui-ux doc's image section and
`GeneratedImage` notes reference macOS gating too. One short
"superseded by sd-server (A1111 API)" banner at the top of each stale
section beats rewriting them.
- `public/icons.svg` is referenced by nothing (`favicon.svg` is the real icon)
— delete it, and the stale `dist/` copy with it.
### P2-1. `csp: null` in `tauri.conf.json`
All LLM/image HTTP calls happen in Rust (reqwest), so the WebView needs almost
nothing: a minimal CSP like
`default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost`
closes the standard Tauri warning. Verify against dev-mode HMR before
committing (Vite may need `ws:` under `connect-src` in dev).
### P2-2. `is_ollama()` is URL-substring sniffing
`url.contains("localhost:11434")` breaks the moment someone runs Ollama on a
custom port or behind a proxy (falls into the OpenAI branch and fails
confusingly). Cheapest robust fix: add a `provider: "ollama" | "openai"`
field to `LlmConfig` defaulted by URL sniff, selectable in Settings presets
(the ui-ux doc already prescribes provider presets). Alternatively probe
`/api/tags` then `/v1/models`.
### P2-3. Small nits (batch into one cleanup PR)
- `App.tsx` wizard effect re-checks `localStorage` on **every view change** —
harmless (modal blocks) but a one-time mount check reads cleaner.
- `HistoryView.tsx` is 806 lines — fine today; split only when the next
feature lands there, not before.
- `tokio` is pulled with `features = ["full"]`; only the async-runtime
subset is used. Harmless (Tauri drags tokio in anyway) — trim only if
compile time bothers you.
- Consider `store.get("llmConfig")` versioning (`configVersion: 1` field) so
future shape changes don't brick startup — cheap insurance while touching
P0-1.
---
## 2. Over-engineering audit (ponytail pass)
**Verdict: lean already, ship.** Specifically checked and *kept*:
- Hand-rolled base64 encode/decode in `image_commands.rs` — no stdlib
equivalent in Rust; avoids a dep; has a roundtrip test. Correct call.
- `src/lib/bus.ts` (59 lines) — used by 3 components
(Encounter → Initiative/Soundboard push). Not dead.
- `sql_viewer.rs` read-only SQL passthrough — gated power-user tool with
proper read-only enforcement. Justified.
- `zustand` + `framer-motion` — both actually used (Toast/HistoryView);
framer-motion is only in `HistoryView`, but replacing working animation
with CSS is churn, not deletion.
Only real delete: `public/icons.svg` (see P1-4) and the tracked-vs-reality
doc drift above. Net: ~0 lines to remove; the codebase discipline is good.
---
## 3. Verified status of `plan.md` §17 (what actually shipped)
Items marked done there that are **confirmed done in code**: README rewrite,
Greet deletion (marker comment in `lib.rs`), nav-map consolidation
(`TOOL_META` in `App.tsx`), dice/encounter/worldmap self-checks, lore
directory picker (`rag_add_directory` is in the invoke handler), first-run
wizard, `?` shortcut help, global busy indicator (`emit_busy` +
`GeneratingIndicator`), and the sd-server migration (cross-platform image gen,
macOS gate gone from the components).
Still genuinely open from §17/§ui-ux (re-verified): Gitea CI, code signing +
notarization, auto-updater, quest branching graph, world hierarchy tree, real
ambience packs + sound import, campaign namespacing, image-gen into World
Builder (battle maps), handout renderer. Don't re-plan those here — this doc
just re-ranks them against the fresh findings.
---
## 4. Next steps — one ranked list
Merges fresh findings with the still-open roadmap items. Do them roughly in
order; #1–5 are one focused week.
| # | Item | Effort | Impact | Tracked in |
|---|------|--------|--------|------------|
| 1 | Scrub PII from `apple-signing.env.example` | 5 min | High (before next push) | here |
| 2 | Persist `LlmConfig` to prefs store (P0-1) | 1 h | **Critical** | here |
| 3 | RAG: UTF-8 boundary fix + dedupe on re-add (P1-2.1–2) | 2 h | High | here |
| 4 | `npm run check` + Gitea Actions CI (P1-3) | 2 h | High | §17.2 |
| 5 | Real token streaming from Ollama/OpenAI (P1-1) | 3 h | **High** | here |
| 6 | Doc drift cleanup: README line 21, plan §4.4/§10 banners, version sync, delete `icons.svg`, Node ≥ 23.6 note (P1-4) | 1 h | Medium | here |
| 7 | Minimal CSP (P2-1) | 30 min | Medium | here |
| 8 | Provider select instead of URL sniff (P2-2) | 1 h | Medium | here |
| 9 | RAG embed-model mismatch warning / Reindex (P1-2.3) | 2 h | Medium | here |
| 10 | Code signing + notarization (env example + script already exist) | 3 h | High (distribution) | §17.2 |
| 11 | Campaign namespacing (`<dataDir>/<campaign>/`, title-bar selector) | 4 h | High (DMs run 2+ campaigns) | §17.4 |
| 12 | Quest branching graph | 4 h | High | §17.3 |
| 13 | World hierarchy tree (drill into region) | 3 h | High | §17.3 |
| 14 | Auto-updater (`tauri-plugin-updater`) | 3 h | Medium | §17.2 |
| 15 | Image-gen into World Builder maps + Encounter battle maps | 3 h | Medium | §16.10 |
| 16 | Real ambience packs (CC0) + custom sound import | 2 h | Medium | §17.3 |
| 17 | Handout renderer (Markdown → PDF) | 3 h | Low–Med | §15 |
**Skipped deliberately:** i18n, player-facing web view, compendium/SRD stat
blocks, voice-to-text, draggable bento, session replay — all P3 in §17.4 and
correctly deferred. YAGNI until a real user asks.
---
## 5. How to verify the P0 fix in 60 seconds
1. `npm run tauri dev`, set a non-default model in Settings, quit, relaunch.
2. Settings shows the saved model (today: it shows `llama3.2` default).
3. `cat $APPDATA/../dm-pal-prefs.json`-equivalent (the prefs store next to
`dm-toolkit/`) now has an `llmConfig` key.
+119
View File
@@ -0,0 +1,119 @@
# DM-Pal — Master Roadmap (every known gap)
*Consolidates `docs/repo-review.md` (2026-09-06), the open items in
`plan.md` §15–17, the unchecked P2/P3 items in `ui-ux-improvements.md`, and
new feature proposals into one ordered plan. This is now the working list;
`plan.md` remains design history. Every item is verified as **not shipped**
as of 2026-09-06.*
Source tags: **[R]** repo-review · **[P]** plan.md §17/§15 ·
**[U]** ui-ux-improvements P2/P3 · **[N]** new proposal.
---
## Phase 0 — Fix & harden → tag `v0.1.4` (~1.5 days)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 0.1 | Scrub PII from `scripts/apple-signing.env.example` | 5 m | [R] real email + Team ID still present |
| 0.2 | Persist `LlmConfig` to prefs store; load on startup | 1 h | [R] **P0** — restart currently wipes Settings. Add a `configVersion` field for future shape changes |
| 0.3 | RAG: UTF-8 boundary fix in `chunk()` + test with accented text | 1 h | [R] multibyte paragraphs silently drop chunks today |
| 0.4 | RAG: `DELETE WHERE source = ?` before re-insert in `add_document` | 15 m | [R] re-adding a file doubles its chunks |
| 0.5 | RAG: embed-model mismatch guard (store model per source; refuse/warn) + "Reindex all" | 2 h | [R] changing embed model currently poisons cosine scores |
| 0.6 | `npm run check` + `.gitea/workflows/ci.yml` (lint, tsc, checks, `cargo test`) | 2 h | [R][P] |
| 0.7 | Doc drift: README line 21 ("macOS, via Ollama"), Node ≥ 23.6 prerequisite, version sync (Cargo vs tauri.conf), banner on stale plan.md §4.4/§10, delete `public/icons.svg` | 1 h | [R] |
| 0.8 | Minimal CSP in `tauri.conf.json` (self + `data:` img; check dev HMR) | 30 m | [R] |
| 0.9 | Replace `is_ollama()` URL sniff with `provider` field in `LlmConfig` (wizard presets set it) | 1.5 h | [R] breaks on custom Ollama ports |
**Done when:** settings survive restart; CI green on push; re-indexed lore
returns sane results; no PII in the repo.
## Phase 1 — Core UX enablers → tag `v0.2.0` (~1 week)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 1.1 | Real token streaming: Ollama `stream:true` NDJSON + OpenAI SSE via `reqwest::bytes_stream` (dep already present) | 3 h | [R][N] unlocks visible generation everywhere; SessionLogger already renders progressive tokens |
| 1.2 | Model pull in first-run wizard: `POST /api/pull` NDJSON → Channel progress bar (same shape as image-gen poller) | 3 h | [N] wizard currently "hopes it exists" |
| 1.3 | `ragQuery` on Encounter, Quest, Session summary | 30 m | [N] NPC/Item/World already pass it |
| 1.4 | Auto-log session events: bus emits for dice rolls, initiative round/turn changes, encounter start → SessionLogger appends | 3 h | [N] makes the AI summary summarize the actual fight |
| 1.5 | NPC → Initiative push ("⚔ Add to tracker" on NPC card via `AddCombatants` bus event; stats already generated) | 30 m | [N] |
| 1.6 | Calendar "Next day" → session-log entry incl. rolled weather | 1 h | [N] ties calendar into the timeline |
| 1.7 | Dice macros: persisted named rolls (`usePersistentState`), button row + edit sheet | 2 h | [U P3] |
| 1.8 | Initiative condition durations (auto-decrement per round, expire) | 2 h | [U-adjacent] concentration/Hex is table-stakes |
**Done when:** a brand-new user pulls a model in-wizard and watches it
progress; a combat logs itself; the AI summary streams and is grounded.
## Phase 2 — Flagship features → tag `v0.3.0` (~2 weeks)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 2.1 | **Ask the Oracle**: chat over the world bible — question → `rag_search` → `generate` → answer with source chips. New view, no backend change | 1 d | [N] the flagship for the RAG pipeline |
| 2.2 | Quest branching graph (`react-flow`): conditional edges, step status colors | 1 d | [P][U] |
| 2.3 | World hierarchy tree: continent → region → city; drill into a region to generate sub-regions | 1 d | [P][U] |
| 2.4 | NPC roster view: grid of generated NPCs w/ portraits, filter by race/alignment, click to open | 1 d | [U] reads generations.db |
| 2.5 | Item inventory view (rarity-coded grid) | 0.5 d | [U] |
| 2.6 | Quest roster view (status badges) | 0.5 d | [U] |
| 2.7 | Campaign namespacing: `<dataDir>/<campaign>/` + title-bar selector + migration of existing data; generalize the existing data-dir machinery | 1 d | [P] biggest data-model gap |
| 2.8 | Campaign backup/restore: `export_campaign` zip command (Rust `zip` crate) + import w/ confirm | 0.5 d | [N] no backup story today |
| 2.9 | Save portrait / right-click save image | 1 h | [U] |
**Done when:** two campaigns coexist with separate lore/history; the cast
and inventory are browsable surfaces, not just history rows.
## Phase 3 — Media & atmosphere → tag `v0.4.0` (~1.5 weeks)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 3.1 | Real ambience packs: 2–3 CC0 loops in `public/sounds/` (synthesis stays as fallback) + custom sound import (drag MP3 onto tile) | 1 d | [P][U] |
| 3.2 | Image advanced params: negative prompt, seed, aspect ratio, steps, guidance → sd-server passthrough + cache key includes them | 0.5 d | [U] |
| 3.3 | Image-gen into World Builder (region map art per landmark) | 0.5 d | [P] §16.10 |
| 3.4 | Image-gen into Encounter (battle map tile + loot item art) | 0.5 d | [P] §16.10 |
| 3.5 | Handout renderer: Markdown → styled handout → export PNG/PDF | 1 d | [P][U] last §15 M6 item |
| 3.6 | Dice tumble animation (CSS 2.5D — no three.js) | 0.5 d | [N] |
**Done when:** a full session can run without leaving the app: ambience,
maps, item art, handouts.
## Phase 4 — Distribution → tag `v1.0.0` (~1 week)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 4.1 | Cross-platform build matrix in Gitea CI (macOS/Windows/Linux) | 0.5 d | [P] |
| 4.2 | Finish code signing + notarization (macOS cert exists in `secrets/`; add Windows) | 1 d | [P] scripts + env already scaffolded |
| 4.3 | Auto-updater: `tauri-plugin-updater` + update manifest hosted beside Gitea releases | 1 d | [P] |
| 4.4 | i18n scaffolding: extract strings to `t()` (EN-only content for v1) | 1 d | [U] structure now, translations later |
| 4.5 | Final polish pass: glassmorphism consistency, `prefers-reduced-motion`, focus rings | 0.5 d | [P] §15 M6 |
| 4.6 | Tag `v1.0.0`, update README screenshots | 30 m | |
**Done when:** a DM who downloads v1.0 gets a signed app that updates itself.
## Phase 5 — Post-1.0 long tail (ordered by value)
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 5.1 | Compendium: local SRD 5e JSON → stat-block popovers in Encounter + Initiative import | 2 d | [U] |
| 5.2 | Player view: LAN read-only web page (initiative, dice, HP) from the DM's screen | 2–3 d | [U][P] |
| 5.3 | Console mode dashboard: embed 2–3 chosen tools as live-session cards | 1 d | [U] |
| 5.4 | Draggable bento (`react-grid-layout`), persisted per campaign | 1 d | [U] |
| 5.5 | Voice-to-text session notes (whisper.cpp sidecar or audio-capable model) | 2 d | [P][U] |
| 5.6 | Session replay: record the bus event stream → timeline replay | 2 d | [U] builds on 1.4's event logging |
| 5.7 | AI auto-tagging of session entries (#combat/#loot/#roleplay) | 0.5 d | [N] |
| 5.8 | Lore embedding visualization (t-SNE scatter) | 1 d | [N] |
| 5.9 | True 3D dice (react-three-fiber) — only if 3.6's CSS tumble proves insufficient | 2 d | [N] |
| 5.10 | Plugin system: dynamically registered commands/content packs | 3 d+ | [P] |
| 5.11 | Prompt library + fine-tuning on campaign data | research | [P] |
---
## Ordering notes
- **0.2 before 1.2** — the wizard can't save what it configures until config persists.
- **1.1 before 2.1** — the Oracle wants streaming; both ride the same Channel plumbing.
- **2.7 before 5.x** — campaign namespacing changes the data layout; rosters,
backup, and replay should key on campaign from day one.
- **1.4 before 5.6** — replay is just a recording of the event stream 1.4 introduces.
- Rust tests + self-checks grow with each phase; CI (0.6) runs them all from day one.
**Totals to v1.0:** ~22 working days of focused effort
(1.5 + 5 + 6.5 + 5 + 4). Long tail adds ~15 days beyond that.
+9 -9
View File
@@ -7,12 +7,12 @@ recommendation.*
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative, > **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger, > Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/ > Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
> reward fields shipped; remaining P2 items are larger-effort or backend- > reward fields shipped; soundboard scenes + image gallery/batch shipped;
> gated: image-gen advanced params (Ollama API doesn't support them), > remaining P2 items are larger-effort or backend-gated: image-gen advanced
> soundboard scenes + real ambience packs, world hierarchy tree, lore > params (Ollama API doesn't support them), soundboard real ambience packs +
> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory / > custom sound import, world hierarchy tree, lore directory picker (needs
> quest roster (History view covers re-opening). The checklist below is > tauri-plugin-dialog), NPC roster / item inventory / quest roster (History
> updated as items land. > view covers re-opening). The checklist below is updated as items land.
--- ---
@@ -1095,8 +1095,8 @@ These are blockers or bugs that make the app feel broken.
- [ ] Quest roster (History view covers re-opening past quests) - [ ] 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) - [x] Batch mode (2×2 variants)
- [ ] Gallery view with thumbnails - [x] Gallery view with thumbnails
- [x] Persist to campaign-scoped folder - [x] Persist to campaign-scoped folder
- [x] Copy to clipboard - [x] Copy to clipboard
- [x] **Session Logger**: - [x] **Session Logger**:
@@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken.
- [ ] **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)
- [ ] Scenes (one-click combinations) - [x] Scenes (one-click combinations)
- [x] Master mute + per-sound volume - [x] Master mute + per-sound volume
- [x] Always-visible Stop All - [x] Always-visible Stop All
- [x] Save/load board state - [x] Save/load board state
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "dm-pal", "name": "dm-pal",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15
View File
@@ -0,0 +1,15 @@
# Copy to scripts/apple-signing.env and fill in. This file is gitignored.
# Required for a signed + notarized DMG that Gatekeeper accepts on download.
#
# 1. APPLE_SIGNING_IDENTITY: the exact string from `security find-identity -v -p codesigning`
# that starts with "Developer ID Application: ...". You must create that certificate
# first at https://developer.apple.com/account/resources/certificates/list
# (Cert type = "Developer ID Application", NOT "Apple Development").
# 2. APPLE_ID: your Apple ID email.
# 3. APPLE_PASSWORD: an app-specific password from https://account.apple.com
# (Sign-In and Security -> App-Specific Passwords). NOT your normal password.
# 4. APPLE_TEAM_ID: 10-char Team ID from https://developer.apple.com/account#MembershipDetailsCard
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (XXXXXXXXXX)"
export APPLE_ID="you@example.com"
export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"
+5
View File
@@ -0,0 +1,5 @@
// ponytail: runnable self-check for the dice-notation parser. Not part of
// the app build (scripts/ is outside tsconfig.app's include). Run with:
// node scripts/check-dice.ts
import { demo } from "../src/lib/dice.ts";
demo();
+5
View File
@@ -0,0 +1,5 @@
// ponytail: runnable self-check for the encounter XP-budget math. Not part
// of the app build (scripts/ is outside tsconfig.app's include). Run with:
// node scripts/check-encounter-budget.ts
import { demo } from "../src/lib/encounter-budget.ts";
demo();
+52 -4
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Upload the built macOS artifacts to a Gitea release. No runners, no CI. # Upload the built macOS artifacts to a Gitea release. No runners, no CI.
# Auth comes from the macOS keychain (same creds `git` uses to push). # Auth comes from the macOS keychain (same creds `git` uses to push).
# Usage: ./scripts/gitea-release.sh [v0.1.0] # Usage: ./scripts/gitea-release.sh [v0.2.0]
# Requires: jq, curl, and a prior `npm run tauri build`. # No arg = auto patch-bump (0.1.0 -> 0.1.1). Explicit arg syncs version files.
# Requires: jq, curl, npm run tauri build deps. If scripts/apple-signing.env is
# present, builds a signed + notarized DMG automatically; else ad-hoc (unsigned).
set -euo pipefail set -euo pipefail
GITEA_URL="https://gitea.jms.rocks" GITEA_URL="https://gitea.jms.rocks"
@@ -23,11 +25,49 @@ host=$HOST" 2>/dev/null || true)
printf 'Authorization: Basic %s' "$(printf '%s:%s' "$user" "$pass" | base64)" printf 'Authorization: Basic %s' "$(printf '%s:%s' "$user" "$pass" | base64)"
} }
TAG="${1:-v$(jq -r .version package.json)}" # Resolve the release tag. No arg => auto-bump patch from the current version.
# An explicit arg (v0.2.0) wins and also syncs both version files to it, so the
# built DMG's embedded version always matches the release tag.
# ponytail: patch-only auto-bump; pass v0.2.0 / v1.0.0 for minor/major.
if [ -n "${1:-}" ]; then
VER="${1#v}"
else
VER=$(node -e 'const v=require("./package.json").version;const [a,b,c]=v.split(".").map(Number);process.stdout.write(`${a}.${b}.${c+1}`)')
fi
TAG="v$VER"
node -e '
const fs=require("fs"),ver=process.argv[1];
for(const f of ["package.json","src-tauri/tauri.conf.json"]){
const t=fs.readFileSync(f,"utf8");
const out=t.replace(/("version"\s*:\s*")\d+\.\d+\.\d+(")/,`$1${ver}$2`);
if(out===t)throw new Error("version not replaced in "+f);
fs.writeFileSync(f,out);
}
' "$VER"
echo "release $TAG (version files synced)"
git add package.json src-tauri/tauri.conf.json
if ! git diff --cached --quiet; then
git commit -m "chore: release $TAG" -q
echo "committed version bump"
fi
# If Apple signing creds are present, do a signed + notarized build now so the
# uploaded DMG passes Gatekeeper on a browser download. Without these the DMG
# is only ad-hoc signed and macOS shows it as "damaged" when downloaded.
ENV_FILE="$(dirname "$0")/apple-signing.env"
if [ -f "$ENV_FILE" ]; then
set -a; . "$ENV_FILE"; set +a
echo "Building signed + notarized DMG ..."
npm run tauri build -- --bundles app,dmg
else
echo "warn: scripts/apple-signing.env missing — building unsigned (ad-hoc) DMG."
echo " Browser downloads will be blocked by Gatekeeper. See apple-signing.env.example."
npm run tauri build -- --bundles app,dmg
fi
DMG=$(ls -t src-tauri/target/release/bundle/dmg/*.dmg | head -1) DMG=$(ls -t src-tauri/target/release/bundle/dmg/*.dmg | head -1)
APP_BUNDLE=$(ls -d src-tauri/target/release/bundle/macos/*.app | head -1) APP_BUNDLE=$(ls -d src-tauri/target/release/bundle/macos/*.app | head -1)
[ -f "$DMG" ] || { echo "no dmg found — run 'npm run tauri build' first"; exit 1; } [ -f "$DMG" ] || { echo "no dmg found — build failed"; exit 1; }
[ -d "$APP_BUNDLE" ] || { echo "no .app found"; exit 1; } [ -d "$APP_BUNDLE" ] || { echo "no .app found"; exit 1; }
# ponytail: ship the dmg as the installer; also zip the .app for unmanaged # ponytail: ship the dmg as the installer; also zip the .app for unmanaged
@@ -52,4 +92,12 @@ for f in "$DMG" "$ZIP"; do
-H "$AUTH" -F "attachment=@$f" -o /dev/null -H "$AUTH" -F "attachment=@$f" -o /dev/null
done done
# ponytail: guard against shipping an unsigned bundle by mistake — if creds
# were configured, confirm the app is actually Developer-ID signed.
if [ -f "$ENV_FILE" ]; then
codesign -dv --verbose=2 "$APP_BUNDLE" 2>&1 | grep -q "Authority=Developer ID" \
|| { echo "error: APPLE creds set but app is not Developer-ID signed — notarization failed or skipped"; exit 1; }
echo "verified: Developer ID signature present"
fi
echo "done -> $GITEA_URL/$OWNER_REPO/releases/tag/$TAG" echo "done -> $GITEA_URL/$OWNER_REPO/releases/tag/$TAG"
+180 -177
View File
@@ -1,64 +1,32 @@
use crate::commands::emit_busy;
use crate::llm::AppState; use crate::llm::AppState;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize}; 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 std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tauri::AppHandle;
// ponytail: we talk to a stable-diffusion.cpp `sd-server` over HTTP using its
// AUTOMATIC1111-compatible API (/sdapi/v1/txt2img + /sdapi/v1/progress). This
// reuses the existing HTTP+channel plumbing and drops the old Ollama/macOS-only
// limitation — sd-server runs on macOS, Linux, and Windows. The model is chosen
// at server startup, so there's no per-request model field anymore.
// 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.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ImageRequest { pub struct ImageRequest {
pub prompt: String, pub prompt: String,
/// Override the configured image model for this call.
pub model: Option<String>,
}
/// One line of Ollama's NDJSON image-generation response.
/// `step`/`total` carry progress; the final `done: true` line carries the
/// singular `image` base64 field. All fields optional per-line.
#[derive(Debug, Default, Deserialize)]
struct OllamaImageLine {
#[serde(default)]
done: bool,
#[serde(default)]
step: Option<u32>,
#[serde(default)]
total: Option<u32>,
#[serde(default)]
image: Option<String>,
}
/// Parsed view of one NDJSON line used by both the streaming and the
/// buffered paths. Extracted so the parsing logic is unit-testable without
/// touching the network.
#[derive(Debug, PartialEq)]
struct ImageProgress {
done: bool,
step: Option<u32>,
total: Option<u32>,
image: Option<String>,
}
fn parse_image_line(line: &str) -> Option<ImageProgress> {
let line = line.trim();
if line.is_empty() {
return None;
}
let parsed: OllamaImageLine = serde_json::from_str(line).ok()?;
Some(ImageProgress {
done: parsed.done,
step: parsed.step,
total: parsed.total,
image: parsed.image,
})
} }
/// Channel events for streaming image generation. /// Channel events for streaming image generation.
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")] #[serde(tag = "type", content = "data", rename_all = "camelCase")]
pub enum ImageEvent { pub enum ImageEvent {
/// Progress update: (step, total). Either may be None if Ollama omits it. /// Progress update: (step, total). Either may be None if the server omits it.
Progress { step: Option<u32>, total: Option<u32> }, Progress { step: Option<u32>, total: Option<u32> },
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`. /// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
Done(String), Done(String),
@@ -66,54 +34,66 @@ pub enum ImageEvent {
Error(String), Error(String),
} }
/// Generate (or fetch from disk cache) an image for `prompt` via the configured /// A1111 `/sdapi/v1/txt2img` response. Only the base64 PNG list matters to us.
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`. #[derive(Debug, Deserialize)]
/// struct Txt2ImgResponse {
/// Ollama image models are macOS-only today; on other platforms we return an error #[serde(default)]
/// so the front-end can fall back to a placeholder instead of a confusing timeout. images: Vec<String>,
}
/// A1111 `/sdapi/v1/progress` response. `progress` is 0.0–1.0 of the current job.
#[derive(Debug, Deserialize)]
struct ProgressResponse {
#[serde(default)]
progress: f32,
}
/// Generate (or fetch from disk cache) an image for `prompt` via a local
/// stable-diffusion.cpp `sd-server`. Returns a `data:image/png;base64,...` URL
/// ready for `<img src>`.
#[tauri::command] #[tauri::command]
pub async fn generate_image( pub async fn generate_image(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app: AppHandle,
req: ImageRequest, req: ImageRequest,
) -> Result<String, String> { ) -> Result<String, String> {
if cfg!(not(target_os = "macos")) { // ponytail: emit a global busy signal so the shell shows a working
return Err("image generation is macOS-only via Ollama (for now)".into()); // indicator even if the DM navigates away. Paired with the false below.
emit_busy(&app, true);
let result = generate_image_inner(state, req).await;
emit_busy(&app, false);
result
} }
async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result<String, String> {
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 api_url = config.image_api_url.clone();
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?;
if cache_hit { if cache_hit {
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?; let png_bytes = txt2img(&api_url, &req.prompt).await?;
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?; std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
Ok(data_url(&png_bytes)) Ok(data_url(&png_bytes))
} }
/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`, /// Streaming variant: emits `ImageEvent::Progress` (from `/sdapi/v1/progress`)
/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache /// while the txt2img POST is in flight, then `ImageEvent::Done` with the data
/// as `generate_image`. The DM gets a real progress bar for the multi-second wait. /// URL (or `Error`). Reuses the same disk cache as `generate_image`.
#[tauri::command] #[tauri::command]
pub async fn generate_image_stream( pub async fn generate_image_stream(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app: AppHandle,
req: ImageRequest, req: ImageRequest,
channel: Channel<ImageEvent>, channel: Channel<ImageEvent>,
) -> Result<(), String> { ) -> 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 config = state.config.lock().map_err(|e| e.to_string())?.clone();
let model = req.model.unwrap_or(config.image_model.clone()); let api_url = config.image_api_url.clone();
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?; let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?;
if cache_hit { if cache_hit {
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())?;
let _ = channel.send(ImageEvent::Done(data_url(&bytes))); let _ = channel.send(ImageEvent::Done(data_url(&bytes)));
@@ -122,60 +102,104 @@ pub async fn generate_image_stream(
// Drive the request on a background task so the command returns immediately // Drive the request on a background task so the command returns immediately
// and progress flows through the channel. Errors become ImageEvent::Error. // and progress flows through the channel. Errors become ImageEvent::Error.
let channel = std::sync::Arc::new(channel); let channel = Arc::new(channel);
let ch = channel.clone(); let ch = channel.clone();
// ponytail: only emit busy around the actual generation (not cache hits),
// and always balance it in the spawn — even on error.
emit_busy(&app, true);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await { // Poll /sdapi/v1/progress while the txt2img POST runs so the DM sees a
// real progress bar. Best-effort: poll errors are silent (bar falls
// back to indeterminate). 200ms is smooth without spamming the server.
let poll_client = reqwest::Client::new();
let done = Arc::new(AtomicBool::new(false));
let done_p = done.clone();
let api_p = api_url.clone();
let ch_p = ch.clone();
let poller = tauri::async_runtime::spawn(async move {
while !done_p.load(Ordering::SeqCst) {
if let Ok(p) = poll_progress(&poll_client, &api_p).await {
let step = (p.clamp(0.0, 1.0) * 100.0) as u32;
let _ = ch_p.send(ImageEvent::Progress { step: Some(step), total: Some(100) });
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
});
let result = txt2img(&api_url, &req.prompt).await;
done.store(true, Ordering::SeqCst);
let _ = poller.await; // let the poller flush its last iteration
match result {
Ok(png_bytes) => { Ok(png_bytes) => {
let _ = std::fs::write(&cache_path, &png_bytes); let _ = std::fs::write(&cache_path, &png_bytes);
let _ = channel.send(ImageEvent::Done(data_url(&png_bytes))); let _ = ch.send(ImageEvent::Done(data_url(&png_bytes)));
} }
Err(e) => { Err(e) => {
let _ = channel.send(ImageEvent::Error(e)); let _ = ch.send(ImageEvent::Error(e));
} }
} }
emit_busy(&app, false);
}); });
Ok(()) Ok(())
} }
/// Resolve the on-disk cache path for (model, prompt) and report a cache hit. /// Resolve the on-disk cache path for (api_url, prompt) and report a cache hit.
/// `base` is the configured data dir (AppState.data_dir). /// `base` is the configured data dir (AppState.data_dir).
fn prepare_cache( fn prepare_cache(
base: &std::path::Path, base: &std::path::Path,
model: &str, api_url: &str,
prompt: &str, prompt: &str,
) -> Result<(std::path::PathBuf, bool), String> { ) -> Result<(std::path::PathBuf, bool), String> {
let cache_dir = base.join("images"); let cache_dir = base.join("images");
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
model.hash(&mut hasher); api_url.hash(&mut hasher);
prompt.hash(&mut hasher); prompt.hash(&mut hasher);
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish())); let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
let hit = cache_path.exists(); let hit = cache_path.exists();
Ok((cache_path, hit)) Ok((cache_path, hit))
} }
/// POST the image request to Ollama and collect the PNG bytes. When a channel /// POST `/sdapi/v1/txt2img` to the sd-server and return the raw PNG bytes of
/// is given, parse the NDJSON body incrementally and emit progress; otherwise /// the first generated image. A1111 returns base64 PNGs *without* a `data:`
/// read the whole body at once (legacy buffered path). /// prefix, so we decode to bytes for the disk cache.
async fn request_image_bytes( async fn txt2img(api_url: &str, prompt: &str) -> Result<Vec<u8>, String> {
config: &crate::llm::LlmConfig, // ponytail: width/height/steps/cfg are fixed — diffusion can take tens of
model: &str, // seconds; add config knobs only if the DM wants to tune them.
prompt: &str, let client = reqwest::Client::builder()
channel: Option<std::sync::Arc<Channel<ImageEvent>>>, .timeout(Duration::from_secs(300))
) -> Result<Vec<u8>, String> { .build()
let client = reqwest::Client::new(); .map_err(|e| e.to_string())?;
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/')); let url = format!("{}/sdapi/v1/txt2img", api_url.trim_end_matches('/'));
let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false }); let body = serde_json::json!({
"prompt": prompt,
"width": 1024,
"height": 1024,
"steps": 20,
"cfg_scale": 7.0,
"seed": -1,
"batch_size": 1,
});
let res = client let res = client
.post(&url) .post(&url)
.json(&body) .json(&body)
.send() .send()
.await .await
.map_err(|e| format!("image request failed: {e}"))?; .map_err(|e| {
// ponytail: non-technical DMs see a connect error as a mystery —
// name the fix (start sd-server) and point at the in-app guide.
if e.is_connect() || e.is_timeout() {
format!(
"Can't reach the image server at {api_url}. Is sd-server running? Open the Image tab → 'First time setup' for step-by-step instructions. ({e})"
)
} else {
format!("image request failed: {e}")
}
})?;
if !res.status().is_success() { if !res.status().is_success() {
let status = res.status(); let status = res.status();
@@ -183,56 +207,71 @@ async fn request_image_bytes(
return Err(format!("image error {status}: {text}")); return Err(format!("image error {status}: {text}"));
} }
// ponytail: Ollama image models emit NDJSON even with stream:false — let parsed: Txt2ImgResponse = res
// progress lines (step/total) then a final done:true carrying the image. .json()
// The buffered path reads it all at once; the streaming path splits lines .await
// as they arrive so the bar animates. .map_err(|e| format!("image parse error: {e}"))?;
let mut png_b64: Option<String> = None; let b64 = parsed
let mut buffer = String::new(); .images
.into_iter()
let apply_line = |line: &str, png: &mut Option<String>, ch: Option<&Channel<ImageEvent>>| { .next()
if let Some(p) = parse_image_line(line) { .ok_or_else(|| "no image in sd-server response".to_string())?;
if let Some(c) = ch { base64_decode(&b64)
let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total });
}
if let Some(b64) = p.image {
*png = Some(b64);
}
}
};
if let Some(ch) = channel {
let mut stream = res.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("image read error: {e}"))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete lines; keep the trailing partial line in buffer.
while let Some(idx) = buffer.find('\n') {
let line = buffer.split_off(idx + 1);
let complete = std::mem::replace(&mut buffer, line);
apply_line(&complete, &mut png_b64, Some(&ch));
}
}
if !buffer.trim().is_empty() {
apply_line(&buffer, &mut png_b64, Some(&ch));
}
} else {
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
for line in body_text.lines() {
apply_line(line, &mut png_b64, None);
// ponytail: stop after the done line in the buffered path — the
// final image is the one we want.
if let Some(p) = parse_image_line(line) {
if p.done && p.image.is_some() {
break;
}
}
}
} }
let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?; /// GET `/sdapi/v1/progress` → fraction (0.0–1.0) of the current job. Best-effort.
let png_bytes = base64_decode(&b64)?; async fn poll_progress(client: &reqwest::Client, api_url: &str) -> Result<f32, String> {
Ok(png_bytes) let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/'));
let res = client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("progress HTTP {}", res.status()));
}
let p: ProgressResponse = res.json().await.map_err(|e| e.to_string())?;
Ok(p.progress)
}
/// GET `/sdapi/v1/progress` as a liveness probe for the configured sd-server.
/// Returns ok=true when the server responds (even idle — progress is 0.0).
/// Mirrors the LLM `test_connection` so Settings can show a green check.
#[derive(serde::Serialize)]
pub struct ImageConnectionTest {
pub ok: bool,
pub error: String,
}
#[tauri::command]
pub async fn test_image_connection(
state: tauri::State<'_, AppState>,
) -> Result<ImageConnectionTest, String> {
let api_url = state
.config
.lock()
.map_err(|e| e.to_string())?
.image_api_url
.clone();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| e.to_string())?;
let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(res) if res.status().is_success() => Ok(ImageConnectionTest {
ok: true,
error: String::new(),
}),
Ok(res) => Ok(ImageConnectionTest {
ok: false,
error: format!("HTTP {} — is this an sd-server?", res.status()),
}),
Err(e) => Ok(ImageConnectionTest {
ok: false,
error: format!("Can't reach {api_url} — is sd-server running? ({e})"),
}),
}
} }
fn data_url(png: &[u8]) -> String { fn data_url(png: &[u8]) -> String {
@@ -319,55 +358,19 @@ mod tests {
#[test] #[test]
fn cache_key_is_stable() { fn cache_key_is_stable() {
// sanity: same inputs → same filename shape (16 hex digits) // sanity: same inputs → same 16-hex-digit filename shape
let mut h = DefaultHasher::new(); let mut h = DefaultHasher::new();
"x/flux2-klein:4b".hash(&mut h); "http://localhost:1234".hash(&mut h);
"prompt".hash(&mut h); "prompt".hash(&mut h);
let s = format!("{:016x}", h.finish()); let s = format!("{:016x}", h.finish());
assert_eq!(s.len(), 16); assert_eq!(s.len(), 16);
} }
#[test] #[test]
fn parse_progress_and_image_lines() { fn progress_fraction_maps_to_step() {
// intermediate progress line, no image // The streaming poller emits step = clamp(progress,0,1)*100.
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap(); assert_eq!((0.0f32 * 100.0) as u32, 0);
assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None }); assert_eq!((0.5f32 * 100.0) as u32, 50);
assert_eq!((1.5f32.clamp(0.0, 1.0) * 100.0) as u32, 100);
// 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"));
} }
} }
+21 -1
View File
@@ -1,11 +1,27 @@
use crate::commands::emit_busy;
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse}; use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
use serde_json::json; use serde_json::json;
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tauri::AppHandle;
// ─── Simple (non-streaming) generate ───────────────────────── // ─── Simple (non-streaming) generate ─────────────────────────
#[tauri::command] #[tauri::command]
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> { pub async fn generate(
state: tauri::State<'_, AppState>,
app: AppHandle,
req: GenerateRequest,
) -> Result<String, String> {
// ponytail: emit a global busy signal so the shell can show a working
// indicator even after the DM navigates away. Paired with the false
// emit below — every return path balances the counter.
emit_busy(&app, true);
let result = generate_inner(state, req).await;
emit_busy(&app, false);
result
}
async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
let config = { let config = {
let guard = state.config.lock().map_err(|e| e.to_string())?; let guard = state.config.lock().map_err(|e| e.to_string())?;
guard.clone() guard.clone()
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
#[tauri::command] #[tauri::command]
pub async fn generate_stream( pub async fn generate_stream(
state: tauri::State<'_, AppState>, state: tauri::State<'_, AppState>,
app: AppHandle,
req: GenerateRequest, req: GenerateRequest,
channel: Channel<LlmEvent>, channel: Channel<LlmEvent>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -43,6 +60,7 @@ pub async fn generate_stream(
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens); let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
emit_busy(&app, true);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
// For now, we do a non-streaming call and emit the full response as one token // For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later // Real SSE streaming from Ollama/OpenAI can be added later
@@ -61,6 +79,8 @@ pub async fn generate_stream(
let _ = channel.send(LlmEvent::Error(e)); let _ = channel.send(LlmEvent::Error(e));
} }
} }
// ponytail: always balance the busy counter, even on error.
emit_busy(&app, false);
}); });
Ok(()) Ok(())
+17
View File
@@ -3,3 +3,20 @@ pub mod image_commands;
pub mod rag_commands; pub mod rag_commands;
pub mod generation_commands; pub mod generation_commands;
pub mod data_commands; pub mod data_commands;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
// ponytail: a global "generation in flight" signal. Every generate command
// emits busy:true at start and busy:false at end so the shell can show a
// working indicator even after the DM navigates away from the generating
// tool. The frontend maintains a counter (true = +1, false = -1) so
// concurrent generations balance out.
#[derive(Serialize, Clone)]
pub struct GenBusy {
pub busy: bool,
}
pub fn emit_busy(app: &AppHandle, busy: bool) {
let _ = app.emit("gen-busy", GenBusy { busy });
}
+90
View File
@@ -75,3 +75,93 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<V
.map(|(id, preview)| Ok(RagChunk { id, preview })) .map(|(id, preview)| Ok(RagChunk { id, preview }))
.collect() .collect()
} }
#[derive(Debug, Deserialize)]
pub struct RagAddDirRequest {
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct RagAddDirReport {
pub files: usize,
pub chunks: usize,
pub skipped: Vec<String>,
}
/// Walk a directory (recursive, skipping hidden entries) and index every
/// `.md`/`.markdown`/`.txt` file as its own lore source, named by its path
/// relative to the picked root. Done in Rust rather than the webview `fs`
/// plugin so a DM can pick an external world-bible folder without needing an
/// fs scope permission for that path. ponytail: one embed batch per file is
/// fine here — directory import is a one-off prep action, not a hot path.
#[tauri::command]
pub async fn rag_add_directory(
state: tauri::State<'_, AppState>,
req: RagAddDirRequest,
) -> Result<RagAddDirReport, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let root = std::path::PathBuf::from(&req.path);
if !root.is_dir() {
return Err(format!("not a directory: {}", req.path));
}
let mut files = 0usize;
let mut chunks = 0usize;
let mut skipped: Vec<String> = Vec::new();
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) => {
skipped.push(format!("{}: {e}", dir.display()));
continue;
}
};
for entry in entries.flatten() {
let p = entry.path();
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
if p.is_dir() {
stack.push(p);
continue;
}
let is_text = matches!(
p.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref(),
Some("md") | Some("markdown") | Some("txt")
);
if !is_text {
continue;
}
let text = match std::fs::read_to_string(&p) {
Ok(t) => t,
Err(e) => {
skipped.push(format!("{}: {e}", p.display()));
continue;
}
};
// ponytail: source = path relative to the picked root, extension
// stripped, so two files with the same name in different folders
// don't silently merge into one lore source.
let rel = p.strip_prefix(&root).unwrap_or(&p);
let source = rel.with_extension("").to_string_lossy().to_string();
let source = if source.is_empty() {
p.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default()
} else {
source
};
match state.rag.add_document(&config, &source, &text).await {
Ok(n) => {
files += 1;
chunks += n;
}
Err(e) => skipped.push(format!("{}: {e}", p.display())),
}
}
}
Ok(RagAddDirReport { files, chunks, skipped })
}
+5 -5
View File
@@ -56,7 +56,6 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
greet,
commands::llm_commands::generate, commands::llm_commands::generate,
commands::llm_commands::generate_stream, commands::llm_commands::generate_stream,
commands::llm_commands::get_llm_config, commands::llm_commands::get_llm_config,
@@ -64,11 +63,13 @@ pub fn run() {
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::image_commands::generate_image_stream,
commands::image_commands::test_image_connection,
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::rag_commands::rag_chunks,
commands::rag_commands::rag_add_directory,
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,
@@ -83,7 +84,6 @@ pub fn run() {
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[tauri::command] // ponytail: removed the scaffold `greet` command + its frontend component —
fn greet(name: &str) -> String { // nothing in the shell referenced it. Kept here as a marker so the
format!("Hello, {}! Welcome to DM-Pal ⚔", name) // invoke_handler list above stays in sync.
}
+4 -3
View File
@@ -31,8 +31,9 @@ pub struct LlmConfig {
pub temperature: f32, pub temperature: f32,
pub max_tokens: u32, pub max_tokens: u32,
pub top_p: f32, pub top_p: f32,
/// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama. /// Base URL of a stable-diffusion.cpp `sd-server` (A1111-compatible API),
pub image_model: String, /// e.g. `http://localhost:1234`. Cross-platform — no longer Ollama/macOS-only.
pub image_api_url: String,
/// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`). /// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`).
pub embed_model: String, pub embed_model: String,
} }
@@ -47,7 +48,7 @@ impl Default for LlmConfig {
temperature: 0.7, temperature: 0.7,
max_tokens: 512, max_tokens: 512,
top_p: 0.9, top_p: 0.9,
image_model: "x/flux2-klein:4b".to_string(), image_api_url: "http://localhost:1234".to_string(),
embed_model: "nomic-embed-text".to_string(), embed_model: "nomic-embed-text".to_string(),
} }
} }
+5 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "DM-Pal", "productName": "DM-Pal",
"version": "0.1.0", "version": "0.1.3",
"identifier": "com.dm-pal.app", "identifier": "com.dm-pal.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -29,6 +29,10 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"macOS": {
"signingIdentity": "-",
"hardenedRuntime": true
},
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+97 -54
View File
@@ -31,10 +31,13 @@ import { QuestDesigner } from "./components/QuestDesigner";
import { Soundboard } from "./components/Soundboard"; import { Soundboard } from "./components/Soundboard";
import { LorePanel } from "./components/LorePanel"; import { LorePanel } from "./components/LorePanel";
import { ImageGenerator } from "./components/ImageGenerator"; import { ImageGenerator } from "./components/ImageGenerator";
import { FirstRunWizard } from "./components/FirstRunWizard";
import { ToastContainer } from "./components/Toast"; import { ToastContainer } from "./components/Toast";
import { CommandPalette } from "./components/CommandPalette"; import { CommandPalette } from "./components/CommandPalette";
import { ShortcutHelp } from "./components/ShortcutHelp";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { ConnectionPill } from "./components/ConnectionPill"; import { ConnectionPill } from "./components/ConnectionPill";
import { GeneratingIndicator } from "./components/GeneratingIndicator";
import { HistoryView } from "./components/HistoryView"; import { HistoryView } from "./components/HistoryView";
import type { Generation, GenerationKind } from "./lib/generations"; import type { Generation, GenerationKind } from "./lib/generations";
@@ -56,17 +59,47 @@ export type View =
| "settings" | "settings"
| "history"; | "history";
// ponytail: tools that accept a prefill from history. Mirrors the kinds. // ponytail: ONE source of truth per tool — kind (for history prefill),
const PREFILLABLE: Partial<Record<View, GenerationKind>> = { // the prefill-aware component, and the preferred max-width. Adding a tool
npcs: "npc", // is a single entry here; the KIND_TO_VIEW inverse and renderView derive
encounter: "encounter", // from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS
world: "world", // triple that drifted whenever a tool was added.
items: "item", type PrefillComponent = React.ComponentType<{
quest: "quest", prefill?: Generation | null;
session: "session", onPrefillConsumed?: () => void;
image: "image", }>;
interface ToolMeta {
kind?: GenerationKind;
Comp?: PrefillComponent;
maxWidth?: string;
}
const TOOL_META: Partial<Record<View, ToolMeta>> = {
npcs: { kind: "npc", Comp: NpcGenerator, maxWidth: "max-w-4xl" },
encounter: { kind: "encounter", Comp: EncounterBuilder, maxWidth: "max-w-4xl" },
quest: { kind: "quest", Comp: QuestDesigner, maxWidth: "max-w-4xl" },
items: { kind: "item", Comp: ItemForge, maxWidth: "max-w-4xl" },
world: { kind: "world", Comp: WorldBuilder, maxWidth: "max-w-6xl" },
session: { kind: "session", Comp: SessionLogger, maxWidth: "max-w-6xl" },
image: { kind: "image", Comp: ImageGenerator, maxWidth: "max-w-6xl" },
lore: { maxWidth: "max-w-6xl" },
sound: { maxWidth: "max-w-6xl" },
dice: { maxWidth: "max-w-2xl" },
settings: { maxWidth: "max-w-2xl" },
initiative: { maxWidth: "max-w-2xl" },
tables: { maxWidth: "max-w-2xl" },
calendar: { maxWidth: "max-w-2xl" },
}; };
// ponytail: kind → view inverse, derived once so rehydrate() is a lookup.
const KIND_TO_VIEW: Partial<Record<GenerationKind, View>> = {};
for (const [v, m] of Object.entries(TOOL_META)) {
if (m.kind) KIND_TO_VIEW[m.kind] = v as View;
}
const DEFAULT_MAX_WIDTH = "max-w-2xl";
type NavGroup = "session" | "world"; type NavGroup = "session" | "world";
interface NavItem { interface NavItem {
@@ -100,19 +133,16 @@ function renderView(
onPrefillConsumed: () => void, onPrefillConsumed: () => void,
rehydrate: (kind: GenerationKind, data: Generation) => void, rehydrate: (kind: GenerationKind, data: Generation) => void,
): React.ReactNode { ): React.ReactNode {
// ponytail: history view renders its own list/detail panes, so it doesn't // ponytail: history renders its own list/detail panes — no prefill wrapper.
// use the prefill or wrapper pattern.
if (view === "history") { if (view === "history") {
return <HistoryView onRehydrate={rehydrate} />; return <HistoryView onRehydrate={rehydrate} />;
} }
// Tools that accept prefill. // ponytail: prefill-aware tools come straight from TOOL_META — one entry
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />; // per tool instead of a parallel if-chain that drifted with the maps.
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />; const meta = TOOL_META[view];
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />; if (meta?.Comp) {
if (view === "items") return <ItemForge prefill={prefill} onPrefillConsumed={onPrefillConsumed} />; return <meta.Comp prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
if (view === "quest") return <QuestDesigner prefill={prefill} onPrefillConsumed={onPrefillConsumed} />; }
if (view === "session") return <SessionLogger prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
if (view === "image") return <ImageGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
switch (view) { switch (view) {
case "dice": case "dice":
return <DiceRoller />; return <DiceRoller />;
@@ -133,54 +163,30 @@ function renderView(
} }
} }
// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail.
const viewMaxWidth: Partial<Record<View, string>> = {
dice: "max-w-2xl",
settings: "max-w-2xl",
npcs: "max-w-4xl",
encounter: "max-w-4xl",
quest: "max-w-4xl",
items: "max-w-4xl",
world: "max-w-6xl",
lore: "max-w-6xl",
image: "max-w-6xl",
sound: "max-w-6xl",
session: "max-w-6xl",
initiative: "max-w-2xl",
tables: "max-w-2xl",
calendar: "max-w-2xl",
};
// ponytail: tools that accept a prefill. The map mirrors `PREFILLABLE` above
// and exists so rehydrate() can find the right View for a given kind.
const PREFILLABLE_TOOLS = {
npcs: NpcGenerator,
encounter: EncounterBuilder,
world: WorldBuilder,
items: ItemForge,
quest: QuestDesigner,
session: SessionLogger,
image: ImageGenerator,
} as const;
export default function App() { export default function App() {
const [view, setView] = useState<View>("dashboard"); const [view, setView] = useState<View>("dashboard");
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
// ponytail: prefill is set when the user clicks "Open in tool" in History. // ponytail: prefill is set when the user clicks "Open in tool" in History.
// The targeted tool reads it via usePrefillEffect and calls onConsumed // The targeted tool reads it via usePrefillEffect and calls onConsumed
// to clear it. State lives here so the rehydrate→switch→consume dance // to clear it. State lives here so the rehydrate→switch→consume dance
// doesn't depend on a shared store. // doesn't depend on a shared store.
const [prefill, setPrefill] = useState<Generation | null>(null); const [prefill, setPrefill] = useState<Generation | null>(null);
const [showWizard, setShowWizard] = useState(false);
function rehydrate(kind: GenerationKind, data: Generation) { function rehydrate(kind: GenerationKind, data: Generation) {
const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find( const target = KIND_TO_VIEW[kind];
(v) => PREFILLABLE[v] === kind,
);
if (!target) return; if (!target) return;
setPrefill(data); setPrefill(data);
setView(target); setView(target);
} }
const completeWizard = () => {
localStorage.setItem('dm-pal-wizard-completed', 'true');
setShowWizard(false);
};
// ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette. // ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette.
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -198,6 +204,21 @@ export default function App() {
setPaletteOpen(false); setPaletteOpen(false);
return; return;
} }
if (e.key === "Escape" && helpOpen) {
setHelpOpen(false);
return;
}
// ponytail: `?` opens the shortcut cheatsheet — the rail is icon-only,
// so without this the digit shortcuts are undiscoverable. Not while
// typing in an input (Shift+/ on a US layout yields `?`).
if (
e.key === "?" &&
!(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
) {
e.preventDefault();
setHelpOpen((o) => !o);
return;
}
// Digit shortcuts jump to nav items, but not while typing in an input. // Digit shortcuts jump to nav items, but not while typing in an input.
if ( if (
!paletteOpen && !paletteOpen &&
@@ -221,7 +242,21 @@ export default function App() {
} }
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [paletteOpen]); }, [paletteOpen, helpOpen]);
// Check if we need to show the first-run wizard
useEffect(() => {
const completed = localStorage.getItem('dm-pal-wizard-completed');
if (!completed && view !== 'settings') {
setShowWizard(true);
}
}, [view]);
useEffect(() => {
if (view === 'settings') {
setShowWizard(false);
}
}, [view]);
const isOnDashboard = view === "dashboard"; const isOnDashboard = view === "dashboard";
const groupedNav = useMemo( const groupedNav = useMemo(
@@ -245,7 +280,7 @@ export default function App() {
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`} }`}
> >
<item.icon size={item.view === "tables" ? 18 : 18} /> <item.icon size={18} />
</button> </button>
); );
} }
@@ -356,18 +391,22 @@ export default function App() {
</kbd> </kbd>
</button> </button>
<div className="ml-auto" data-tauri-no-drag /> <div className="ml-auto" data-tauri-no-drag />
<GeneratingIndicator />
<ConnectionPill /> <ConnectionPill />
</header> </header>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 overflow-auto" id="main-content"> <main className="flex-1 overflow-auto" id="main-content">
{showWizard ? (
<FirstRunWizard onComplete={completeWizard} />
) : (
<ErrorBoundary> <ErrorBoundary>
{isOnDashboard ? ( {isOnDashboard ? (
<Dashboard onNavigate={setView} /> <Dashboard onNavigate={setView} />
) : view === "history" ? ( ) : view === "history" ? (
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} /> <HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
) : ( ) : (
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}> <div className={`${TOOL_META[view]?.maxWidth ?? DEFAULT_MAX_WIDTH} mx-auto p-6`}>
{view === "settings" ? ( {view === "settings" ? (
<div className="glass-card p-6"> <div className="glass-card p-6">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4"> <h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
@@ -383,6 +422,7 @@ export default function App() {
</div> </div>
)} )}
</ErrorBoundary> </ErrorBoundary>
)}
</main> </main>
</div> </div>
@@ -395,6 +435,9 @@ export default function App() {
}} }}
/> />
{/* ponytail: `?` shortcut cheatsheet — discoverability for the icon-only rail. */}
<ShortcutHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Toast notifications */} {/* Toast notifications */}
<ToastContainer /> <ToastContainer />
</div> </div>
+4 -37
View File
@@ -1,15 +1,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { usePersistentState } from "../lib/usePersistentState"; import { usePersistentState } from "../lib/usePersistentState";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
interface DieResult {
notation: string;
rolls: number[];
total: number;
modifier: number;
advantage?: "adv" | "dis" | null;
keptRoll?: number;
}
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"]; const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
{ label: "Damage", die: "d8" }, { label: "Damage", die: "d8" },
]; ];
type Mode = "normal" | "adv" | "dis";
function rollDie(sides: number): number { function rollDie(sides: number): number {
return Math.floor(Math.random() * sides) + 1; return Math.floor(Math.random() * sides) + 1;
} }
function parseNotation( // ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
notation: string, // with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
): { count: number; sides: number; modifier: number } | null { // types are imported from there too.
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
if (!match) return null;
return {
count: parseInt(match[1] || "1"),
sides: parseInt(match[2]),
modifier: parseInt(match[3] || "0"),
};
}
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
// meaningful for d20 rolls; for other dice we just roll normally.
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
return { rolls, kept: rolls[0] ?? 0 };
}
if (mode === "adv") {
const hi = Math.max(rolls[0], rolls[1]);
return { rolls, kept: hi };
}
const lo = Math.min(rolls[0], rolls[1]);
return { rolls, kept: lo };
}
export function DiceRoller() { export function DiceRoller() {
const [input, setInput] = usePersistentState<string>("dice.input", "1d20"); const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
+300
View File
@@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useToast } from "./Toast";
export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState("welcome");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
// ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote).
const apiUrl = "http://localhost:11434";
const apiKey = "";
const { addToast } = useToast();
useEffect(() => {
if (step === "check") {
checkOllama();
}
}, [step]);
async function checkOllama() {
setLoading(true);
setError("");
try {
// Test connection to the default Ollama URL
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
"test_connection",
{ config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } }
);
if (result.ok) {
setModels(result.models);
setStep("model");
} else {
setError(result.error || "Failed to connect to Ollama");
setStep("error");
}
} catch (e) {
setError(String(e));
setStep("error");
}
setLoading(false);
}
async function pullModel(model: string) {
setLoading(true);
setError("");
try {
// We don't have a direct pull command, but we can suggest the user to run `ollama pull` in terminal.
// For now, we'll just set the model in config and hope it exists.
// Alternatively, we could invoke a generate command to trigger a pull? Not sure.
// We'll just set the config and complete.
await invoke("set_llm_config", {
config: {
api_url: apiUrl,
api_key: apiKey,
model: model,
temperature: 0.7,
max_tokens: 512,
top_p: 0.9,
image_api_url: "http://localhost:1234",
embed_model: "nomic-embed-text",
}
});
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
setStep("done");
} catch (e) {
setError(String(e));
setStep("error");
}
setLoading(false);
}
function skip() {
// Skip wizard and go to settings
onComplete();
}
function back() {
if (step === "model") {
setStep("welcome");
} else if (step === "error") {
setStep("welcome");
} else if (step === "done") {
// Should not happen
}
}
async function handleSubmit() {
if (step === "welcome") {
setStep("check");
return;
}
if (step === "model") {
if (!selectedModel) {
setError("Please select a model");
return;
}
await pullModel(selectedModel);
return;
}
if (step === "done") {
onComplete();
return;
}
}
if (step === "welcome") {
return (
<div className="glass-card p-6 max-w-md w-full mx-auto">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Welcome to DM-Pal
</h2>
<p className="text-[var(--color-text-primary)] text-sm mb-4">
To generate content, DM-Pal needs a language model. Let's set up a local model using Ollama (recommended for privacy and offline use).
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => setStep("check")}
disabled={loading}
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"
>
{loading ? "Checking…" : "Check for Ollama"}
</button>
<button
onClick={skip}
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"
>
Skip for now (go to Settings)
</button>
</div>
{error && (
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
)}
</div>
);
}
if (step === "check") {
return (
<div className="glass-card p-6 max-w-md w-full mx-auto">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Checking for Ollama
</h2>
<p className="text-[var(--color-text-primary)] text-sm mb-4">
We're checking if Ollama is running at {apiUrl}...
</p>
{loading ? (
<div className="flex flex-col items-center gap-2">
<div className="w-4 h-4 border-2 border-[var(--color-gold-bright)] border-t-transparent rounded-full animate-spin" />
<p className="text-[var(--color-text-dim)] text-xs">Checking…</p>
</div>
) : (
<>
{error ? (
<p className="text-[var(--color-danger)] text-sm">{error}</p>
) : (
<p className="text-[var(--color-success)] text-sm">Ollama is running!</p>
)}
<div className="flex flex-col gap-3 mt-6">
<button
onClick={() => setStep("model")}
disabled={loading}
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"
>
Continue to model selection
</button>
<button
onClick={skip}
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"
>
Skip for now (go to Settings)
</button>
</div>
</>
)}
</div>
);
}
if (step === "model") {
return (
<div className="glass-card p-6 max-w-md w-full mx-auto">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Select a Model
</h2>
<p className="text-[var(--color-text-primary)] text-sm mb-4">
Ollama is running. Choose a model to use for text generation.
</p>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
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-xs cursor-pointer"
>
{models.map((m) => (
<option key={m} value={m}>
{m}
{m === "llama3.2" && " (recommended)"}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-3">
<button
onClick={handleSubmit}
disabled={loading || !selectedModel}
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"
>
{loading ? "Setting model…" : "Set Model"}
</button>
<button
onClick={back}
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"
>
Back
</button>
<button
onClick={skip}
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"
>
Skip for now (go to Settings)
</button>
</div>
</div>
{error && (
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
)}
<p className="text-[var(--color-text-dim)] text-sm mt-4">
If you don't see your model, you may need to download it first. In a terminal, run:{' '}
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code>
</p>
</div>
);
}
if (step === "done") {
return (
<div className="glass-card p-6 max-w-md w-full mx-auto">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Setup Complete
</h2>
<p className="text-[var(--color-text-primary)] text-sm mb-4">
DM-Pal is now configured to use {selectedModel} for text generation.
Image generation runs via a stable-diffusion.cpp `sd-server` at http://localhost:1234 (cross-platform).
Embedding model for lore search is set to nomic-embed-text.
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => onComplete()}
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"
>
Go to Dashboard
</button>
<button
onClick={() => {
// Go to settings to review
// We'll just complete and let the user navigate to settings if they want
onComplete();
}}
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"
>
Review Settings
</button>
</div>
</div>
);
}
if (step === "error") {
return (
<div className="glass-card p-6 max-w-md w-full mx-auto">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
Error
</h2>
<p className="text-[var(--color-text-primary)] text-sm mb-4">
{error}
</p>
<div className="flex flex-col gap-3">
<button
onClick={() => setStep("welcome")}
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"
>
Try Again
</button>
<button
onClick={skip}
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"
>
Skip for now (go to Settings)
</button>
</div>
</div>
);
}
return null;
}
+8 -27
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke, Channel } from "@tauri-apps/api/core"; import { invoke, Channel } from "@tauri-apps/api/core";
import { ImageOff } from "lucide-react";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
interface Props { interface Props {
@@ -20,22 +19,20 @@ type ImageEvent =
| { type: "error"; 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 a stable-diffusion.cpp
* Results are cached on disk by the backend, so re-renders are instant. * `sd-server` (A1111-compatible API). Results are cached on disk by the
* macOS-only: on other OSes the backend returns an error and we show a * backend, so re-renders are instant. Cross-platform.
* placeholder instead of a confusing timeout.
* *
* Uses the streaming command so the DM sees a real progress bar (driven from * 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 * `/sdapi/v1/progress`) during the multi-second wait. The image still loads
* loads even if no progress events arrive — Done carries the data URL. * 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 [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
// ponytail: progress for the in-flight generation. step/total may be null // ponytail: progress for the in-flight generation. step/total may be null
// if Ollama omits them; we show an indeterminate shimmer then. // if the server omits them; we show an indeterminate shimmer then.
const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null); const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null);
const { addToast } = useToast(); const { addToast } = useToast();
@@ -43,7 +40,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
if (!prompt) return; if (!prompt) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setUnsupported(false);
setProgress(null); setProgress(null);
const channel = new Channel<ImageEvent>(); const channel = new Channel<ImageEvent>();
@@ -56,9 +52,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
setLoading(false); setLoading(false);
setProgress(null); setProgress(null);
} else if (event.type === "error") { } else if (event.type === "error") {
const msg = String(event.data); addToast(`Image failed: ${event.data}`, "error");
if (msg.includes("macOS-only")) setUnsupported(true);
else addToast(`Image failed: ${msg}`, "error");
setLoading(false); setLoading(false);
setProgress(null); setProgress(null);
} }
@@ -66,9 +60,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => { invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => {
if (!cancelled) { if (!cancelled) {
const msg = String(e); addToast(`Image failed: ${e}`, "error");
if (msg.includes("macOS-only")) setUnsupported(true);
else addToast(`Image failed: ${msg}`, "error");
setLoading(false); setLoading(false);
setProgress(null); setProgress(null);
} }
@@ -83,17 +75,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`; const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`;
if (unsupported) {
return (
<div className={box}>
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
<ImageOff size={20} />
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
</div>
</div>
);
}
if (loading) { if (loading) {
// ponytail: determinate bar when step/total are known, indeterminate shimmer otherwise. // 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; const pct = progress && progress.step != null && progress.total ? Math.round((progress.step / progress.total) * 100) : null;
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { Sparkles } from "lucide-react";
interface GenBusyPayload {
busy: boolean;
}
// ponytail: a title-bar "something is generating" signal. The Rust generate
// commands emit `gen-busy` true at start / false at end; we keep a counter so
// concurrent generations (NPC + image at once) balance to zero only when all
// finish. Lets the DM navigate away from a 15s image gen and still see it's
// working — the only per-card feedback vanishes the moment they switch views.
export function GeneratingIndicator() {
const [busy, setBusy] = useState(false);
useEffect(() => {
let count = 0;
let unlisten: (() => void) | undefined;
let alive = true;
listen<GenBusyPayload>("gen-busy", (event) => {
if (!alive) return;
count += event.payload.busy ? 1 : -1;
if (count < 0) count = 0; // guard against a stray false with no matching true
setBusy(count > 0);
}).then((u) => {
unlisten = u;
});
return () => {
alive = false;
unlisten?.();
};
}, []);
if (!busy) return null;
return (
<span
data-tauri-no-drag
className="ml-2 flex items-center gap-1.5 text-[var(--color-gold-bright)] text-xs select-none"
title="A generation is in progress"
aria-label="Generation in progress"
>
<Sparkles size={12} className="thinking-dot" />
<span className="hidden sm:inline thinking-dot">Generating…</span>
</span>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
export function Greet() {
const [greeting, setGreeting] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/
setGreeting(await invoke("greet", { name }));
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<form
className="flex gap-2 w-full max-w-md"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-[var(--font-sans)]"
onChange={(e) => setName(e.target.value)}
placeholder="Enter a name…"
value={name}
/>
<button
type="submit"
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"
>
Greet
</button>
</form>
{greeting && (
<p className="text-[var(--color-text-secondary)] text-sm font-[var(--font-mono)]">
{greeting}
</p>
)}
</div>
);
}
+65 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from "react"; import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
History as HistoryIcon, History as HistoryIcon,
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
const [confirmClear, setConfirmClear] = useState(false); const [confirmClear, setConfirmClear] = useState(false);
const { addToast } = useToast(); const { addToast } = useToast();
// ponytail: gallery thumbnails — fetch each image generation's data URL on
// demand when the image filter is active. N queries against local SQLite
// is fine for a campaign's worth of images; add a list-with-data command
// if it ever gets slow. Ref is the source of truth; state mirrors it to
// trigger re-renders when a thumbnail lands.
const thumbsRef = useRef<Map<number, string>>(new Map());
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
useEffect(() => {
if (activeKind !== "image") return;
const missing = summaries
.filter((s) => s.kind === "image" && !thumbsRef.current.has(s.id))
.map((s) => s.id);
if (missing.length === 0) return;
let alive = true;
(async () => {
for (const id of missing) {
try {
const g = await getGeneration(id);
if (g && g.kind === "image" && g.data) thumbsRef.current.set(id, g.data);
} catch {}
}
if (alive) setThumbs(new Map(thumbsRef.current));
})();
return () => { alive = false; };
}, [activeKind, summaries]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
try { try {
await deleteGeneration(id); await deleteGeneration(id);
setSummaries((prev) => prev.filter((s) => s.id !== id)); setSummaries((prev) => prev.filter((s) => s.id !== id));
thumbsRef.current.delete(id);
setThumbs(new Map(thumbsRef.current));
if (selected?.id === id) setSelected(null); if (selected?.id === id) setSelected(null);
} catch (e) { } catch (e) {
addToast(`Delete failed: ${e}`, "error"); addToast(`Delete failed: ${e}`, "error");
@@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
const n = await clearAllGenerations(); const n = await clearAllGenerations();
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success"); addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
setSummaries([]); setSummaries([]);
thumbsRef.current.clear();
setThumbs(new Map(thumbsRef.current));
setSelected(null); setSelected(null);
setConfirmClear(false); setConfirmClear(false);
} catch (e) { } catch (e) {
@@ -232,6 +263,37 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
)} )}
</div> </div>
)} )}
{activeKind === "image" ? (
<div className="grid grid-cols-2 gap-2">
{filtered.map((s) => {
const src = thumbs.get(s.id);
return (
<button
key={s.id}
onClick={() => onSelect(s.id)}
className={`group relative rounded-lg overflow-hidden border bg-[var(--color-bg-deep)] aspect-square cursor-pointer transition-colors ${
selected?.id === s.id
? "border-[var(--color-gold-bright)]"
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
}`}
title={s.title}
>
{src ? (
<img src={src} alt={s.title} className="w-full h-full object-cover" loading="lazy" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Loader2 size={16} className="animate-spin text-[var(--color-text-dim)]" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1.5 py-1">
<span className="text-[10px] text-white/90 truncate block">{s.title}</span>
</div>
</button>
);
})}
</div>
) : (
<>
{filtered.map((s) => ( {filtered.map((s) => (
<button <button
key={s.id} key={s.id}
@@ -255,6 +317,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
</div> </div>
</button> </button>
))} ))}
</>
)}
</div> </div>
</div> </div>
+74 -28
View File
@@ -4,8 +4,7 @@ import { ImagePlus, Sparkles } from "lucide-react";
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 { ImageSetupGuide } from "./ImageSetupGuide";
const DEFAULT_MODEL = "x/flux2-klein:4b";
interface Props { interface Props {
prefill?: Generation | null; prefill?: Generation | null;
@@ -14,11 +13,11 @@ interface Props {
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [dataUrl, setDataUrl] = useState<string | null>(null); const [dataUrl, setDataUrl] = useState<string | null>(null);
const [variants, setVariants] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [variant, setVariant] = useState(0); const [variant, setVariant] = useState(0);
const [unsupported, setUnsupported] = useState(false); const [guideOpen, setGuideOpen] = useState(false);
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
@@ -31,22 +30,58 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
if (!prompt.trim()) return; if (!prompt.trim()) return;
setLoading(true); setLoading(true);
setDataUrl(null); setDataUrl(null);
setVariants([]);
try { try {
// ponytail: append a variant tag to bust the backend's prompt-hash cache // ponytail: append a variant tag to bust the backend's prompt-hash cache
// so "regenerate" actually produces a new image instead of the cached one. // so "regenerate" actually produces a new image instead of the cached one.
const v = variant > 0 ? `\n\n(variation ${variant})` : ""; const v = variant > 0 ? `\n\n(variation ${variant})` : "";
const url = await invoke<string>("generate_image", { const url = await invoke<string>("generate_image", {
req: { prompt: `${prompt}${v}`, model: model.trim() || null }, req: { prompt: `${prompt}${v}` },
}); });
setDataUrl(url); setDataUrl(url);
setUnsupported(false);
addToast("Image generated", "success"); addToast("Image generated", "success");
// ponytail: persist the PNG data URL so the DM can re-open past renders. // ponytail: persist the PNG data URL so the DM can re-open past renders.
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL }); void addGeneration({ kind: "image", title: prompt, data: url, source: "sd-server" });
} catch (e) { } catch (e) {
const msg = String(e); addToast(`Image generation failed: ${String(e)}`, "error");
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true); setGuideOpen(true); // ponytail: a failed generate usually means sd-server isn't running — pop the guide.
addToast(`Image generation failed: ${msg}`, "error"); }
setLoading(false);
}
// ponytail: batch = 4 concurrent generates with distinct variation tags so
// the backend cache yields 4 different images. sd-server processes them
// server-side anyway, so concurrency is just cleaner code than a loop with
// awaits. Each variant is persisted so the gallery gets all four.
async function generateBatch() {
if (!prompt.trim()) return;
setLoading(true);
setVariants([]);
setDataUrl(null);
const tags = [1, 2, 3, 4];
const results = await Promise.allSettled(
tags.map((t) =>
invoke<string>("generate_image", {
req: { prompt: `${prompt}\n\n(variation ${t})` },
}),
),
);
const ok: string[] = [];
for (const r of results) {
if (r.status === "fulfilled" && r.value) {
ok.push(r.value);
void addGeneration({ kind: "image", title: prompt, data: r.value, source: "sd-server" });
}
}
if (ok.length === 0) {
const firstErr = results.find((r) => r.status === "rejected");
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
setGuideOpen(true); // ponytail: likely sd-server not running — show the guide.
} else {
setVariants(ok);
setDataUrl(ok[0]);
addToast(`Generated ${ok.length} variants`, "success");
} }
setLoading(false); setLoading(false);
} }
@@ -59,6 +94,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
return ( return (
<div className="flex flex-col gap-3 text-sm"> <div className="flex flex-col gap-3 text-sm">
<ImageSetupGuide forcedOpen={guideOpen} />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label> <label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
<textarea <textarea
@@ -70,18 +106,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
/> />
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Model (optional — defaults to {DEFAULT_MODEL})
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={generate} onClick={generate}
@@ -90,6 +114,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
> >
{loading ? "✨ Generating…" : "✨ Generate Image"} {loading ? "✨ Generating…" : "✨ Generate Image"}
</button> </button>
<button
onClick={generateBatch}
disabled={loading || !prompt.trim()}
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
title="Generate 4 variants and pick the best"
>
{loading ? "…" : "4×"}
</button>
{dataUrl && !loading && ( {dataUrl && !loading && (
<button <button
onClick={regenerate} onClick={regenerate}
@@ -107,14 +139,28 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
</div> </div>
)} )}
{unsupported && ( {variants.length > 1 && !loading && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs"> <div className="flex flex-col gap-1">
Image generation is macOS-only via Ollama (for now). On other platforms the <span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
✨ buttons elsewhere fall back to a placeholder. <div className="grid grid-cols-2 gap-2 w-full max-w-md mx-auto">
{variants.map((v, i) => (
<button
key={i}
onClick={() => setDataUrl(v)}
className={`rounded-lg overflow-hidden border aspect-square cursor-pointer transition-colors ${
dataUrl === v
? "border-[var(--color-gold-bright)]"
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
}`}
title={`Variant ${i + 1}`}
>
<img src={v} alt={`Variant ${i + 1}`} className="w-full h-full object-cover" />
</button>
))}
</div>
</div> </div>
)} )}
{dataUrl && !loading && ( {dataUrl && !loading && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]"> <div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
@@ -145,7 +191,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
</div> </div>
)} )}
{!dataUrl && !loading && !unsupported && ( {!dataUrl && !loading && (
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs"> <div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
<span>No image yet.</span> <span>No image yet.</span>
<span className="mt-1">Describe a scene or portrait, then Generate.</span> <span className="mt-1">Describe a scene or portrait, then Generate.</span>
+185
View File
@@ -0,0 +1,185 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ChevronDown, ExternalLink, CheckCircle2, XCircle } from "lucide-react";
import { useToast } from "./Toast";
// ponytail: a non-technical DM won't read the README or build sd-server from
// source. This is the one place they'll look — the Image tab — so the full
// step-by-step lives here as a collapsible panel. The "Test" button mirrors
// Settings so they get a green check without leaving the tab.
interface Props {
/** Control from the parent so a failed generate can pop this open. */
forcedOpen?: boolean;
}
export function ImageSetupGuide({ forcedOpen = false }: Props) {
const [open, setOpen] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; error: string } | null>(null);
const { addToast } = useToast();
// ponytail: when the parent reports a connection failure, expand so the DM
// sees the fix immediately instead of staring at a toast.
const isOpen = open || forcedOpen;
async function test() {
setTesting(true);
setResult(null);
try {
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
setResult(r);
if (r.ok) addToast("sd-server reachable — image generation is ready", "success");
else addToast(`Not reachable: ${r.error}`, "error");
} catch (e) {
setResult({ ok: false, error: String(e) });
addToast(`Not reachable: ${e}`, "error");
}
setTesting(false);
}
return (
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)]">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer select-none"
>
<span>⚙️ First time? Set up image generation (5-minute guide)</span>
<ChevronDown size={14} className={`transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen && (
<div className="px-3 pb-3 pt-1 border-t border-[var(--color-border-subtle)] flex flex-col gap-3 text-xs leading-relaxed text-[var(--color-text-secondary)]">
<p>
DM-Pal makes images through a small free helper program called{" "}
<strong className="text-[var(--color-text-primary)]">sd-server</strong> (part of{" "}
<a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
stable-diffusion.cpp
</a>
). It runs on your computer alongside Ollama. Set it up once and you're done.
</p>
<Step n={1} title="Download sd-server">
Go to the{" "}
<a href="https://github.com/leejet/stable-diffusion.cpp/releases" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
releases page <ExternalLink size={10} className="inline" />
</a>{" "}
and download the latest build for your system:
<ul className="list-disc pl-5 mt-1 space-y-0.5">
<li><strong className="text-[var(--color-text-primary)]">Windows</strong> — the <code className="font-mono">sd-server-win</code> zip</li>
<li><strong className="text-[var(--color-text-primary)]">Mac (Apple Silicon)</strong> — the <code className="font-mono">sd-server-macos</code> zip</li>
<li><strong className="text-[var(--color-text-primary)]">Linux</strong> — the <code className="font-mono">sd-server-linux</code> build</li>
</ul>
Unzip it into a permanent folder, e.g. <code className="font-mono">sd-server</code> in your home folder.
</Step>
<Step n={2} title="Download a model (recommended: SDXL — great fantasy art, 2 files)">
Put these two files in your <code className="font-mono">sd-server</code> folder:
<ul className="list-disc pl-5 mt-1 space-y-0.5">
<li>
Model (~6.5 GB):{" "}
<a href="https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
sd_xl_base_1.0.safetensors <ExternalLink size={10} className="inline" />
</a>
</li>
<li>
VAE (~0.6 GB):{" "}
<a href="https://huggingface.co/madebyollin/sdxl-vae-fp16-fix/blob/main/sdxl_vae-fp16-fix.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
sdxl_vae-fp16-fix.safetensors <ExternalLink size={10} className="inline" />
</a>
</li>
</ul>
<p className="mt-1 text-[var(--color-text-dim)]">
On the HuggingFace page click the ↓ next to the file to download it.
</p>
</Step>
<Step n={3} title="Start the server (leave the window open)">
Open a terminal — <em>Mac:</em> Terminal (Applications → Utilities); <em>Windows:</em> Command Prompt.
Move into your folder, then run:
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
{`# Mac / Linux
./sd-server -m sd_xl_base_1.0.safetensors \\
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu
# Windows
sd-server.exe -m sd_xl_base_1.0.safetensors \\
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu`}</pre>
<p className="mt-1 text-[var(--color-text-dim)]">
<code className="font-mono">--offload-to-cpu</code> lets it run on machines without a strong GPU (slower). You'll see it listening on <code className="font-mono">http://127.0.0.1:1234</code>.
</p>
</Step>
<Step n={4} title="Point DM-Pal at it & test">
In <strong className="text-[var(--color-text-primary)]">Settings → Image Server</strong>, make sure the URL is{" "}
<code className="font-mono">http://localhost:1234</code>. Then test it right here:
<div className="flex items-center gap-2 mt-2">
<button
type="button"
onClick={test}
disabled={testing}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
>
{testing ? "Testing…" : "Test sd-server connection"}
</button>
{result && (
<span className={`flex items-center gap-1 text-xs ${result.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
{result.ok ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
{result.ok ? "Connected — ready to generate" : result.error}
</span>
)}
</div>
</Step>
<details className="rounded-lg border border-[var(--color-border-subtle)] px-3 py-2">
<summary className="text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)] select-none">
Slower laptop? Try the lightweight option (Z-Image-Turbo, ~4 GB VRAM, 3 files)
</summary>
<div className="mt-2 flex flex-col gap-1.5">
<p>Download these three files into your <code className="font-mono">sd-server</code> folder:</p>
<ul className="list-disc pl-5 space-y-0.5">
<li>Diffusion model:{" "}
<a href="https://huggingface.co/leejet/Z-Image-Turbo-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
z_image_turbo-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
</a>
</li>
<li>VAE:{" "}
<a href="https://huggingface.co/black-forest-labs/FLUX.1-schnell/blob/main/ae.sft" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
ae.sft <ExternalLink size={10} className="inline" />
</a>
</li>
<li>Text encoder:{" "}
<a href="https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
Qwen3-4B-Instruct-2507-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
</a>
</li>
</ul>
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
{`./sd-server --diffusion-model z_image_turbo-Q4_K_M.gguf \\
--vae ae.sft --llm Qwen3-4B-Instruct-2507-Q4_K_M.gguf \\
--cfg-scale 1.0 --diffusion-fa --offload-to-cpu`}</pre>
<p className="text-[var(--color-text-dim)]">
Z-Image-Turbo is built for speed (few steps), so it generates faster than SDXL on modest hardware.
</p>
</div>
</details>
</div>
)}
</div>
);
}
function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<p className="font-medium text-[var(--color-text-primary)]">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] text-[10px] font-bold mr-1.5 align-middle">
{n}
</span>
{title}
</p>
<div className="pl-[26px]">{children}</div>
</div>
);
}
+41 -1
View File
@@ -1,6 +1,7 @@
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, ChevronDown, ChevronRight, FileUp } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog";
import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { addToLore } from "../lib/lore"; import { addToLore } from "../lib/lore";
@@ -25,6 +26,7 @@ export function LorePanel() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sources, setSources] = useState<RagSource[]>([]); const [sources, setSources] = useState<RagSource[]>([]);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [addingDir, setAddingDir] = useState(false);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -117,6 +119,34 @@ export function LorePanel() {
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info"); if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
} }
// ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only).
// Doing the walk in Rust sidesteps the webview fs scope — an external
// world-bible folder needs no fs permission grant this way.
async function addDirectory() {
const selected = await open({ directory: true, multiple: false });
if (!selected || typeof selected !== "string") return;
setAddingDir(true);
try {
const report = await invoke<{ files: number; chunks: number; skipped: string[] }>(
"rag_add_directory",
{ req: { path: selected } },
);
await loadSources();
if (report.files === 0) {
addToast("No .md / .txt files found in that folder", "info");
} else {
addToast(
`Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`,
"success",
);
}
for (const s of report.skipped) console.warn("lore dir import skipped:", s);
} catch (e) {
addToast(`Folder import failed: ${e}`, "error");
}
setAddingDir(false);
}
async function search() { async function search() {
if (!query.trim()) return; if (!query.trim()) return;
setSearching(true); setSearching(true);
@@ -168,6 +198,16 @@ export function LorePanel() {
onChange={onFiles} onChange={onFiles}
/> />
</label> </label>
{/* ponytail: directory import — walks every .md/.txt recursively in
Rust (no webview fs-scope needed for an external folder). */}
<button
onClick={addDirectory}
disabled={addingDir}
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 disabled:opacity-50"
>
<FolderOpen size={14} />
{addingDir ? "Indexing…" : "Add folder…"}
</button>
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>} {msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
</div> </div>
+50 -7
View File
@@ -10,7 +10,7 @@ interface LlmConfig {
temperature: number; temperature: number;
max_tokens: number; max_tokens: number;
top_p: number; top_p: number;
image_model: string; image_api_url: string;
embed_model: string; embed_model: string;
} }
@@ -30,6 +30,9 @@ export function SettingsPanel() {
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 [dataDir, setDataDir] = useState<DataDirInfo | null>(null);
const [relocating, setRelocating] = useState(false); const [relocating, setRelocating] = useState(false);
// ponytail: probe the sd-server so a non-technical DM gets a green check, same pattern as the LLM test.
const [imgTesting, setImgTesting] = useState(false);
const [imgConn, setImgConn] = useState<{ ok: boolean; error: string } | null>(null);
// ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db). // ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db).
const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations"); const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations");
const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name"); const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name");
@@ -153,6 +156,24 @@ export function SettingsPanel() {
setTesting(false); setTesting(false);
} }
// ponytail: probe the configured sd-server. Save first so it uses the latest URL.
async function testImage() {
if (!config) return;
setImgTesting(true);
setImgConn(null);
try {
await invoke("set_llm_config", { config });
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
setImgConn(r);
if (r.ok) addToast("sd-server reachable", "success");
else addToast(`Image server not reachable: ${r.error}`, "error");
} catch (e) {
setImgConn({ ok: false, error: String(e) });
addToast(`Image server not reachable: ${e}`, "error");
}
setImgTesting(false);
}
// ponytail: provider presets fill in the API URL pattern + default model. // ponytail: provider presets fill in the API URL pattern + default model.
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [ const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" }, { label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
@@ -177,7 +198,7 @@ export function SettingsPanel() {
temperature: 0.7, temperature: 0.7,
max_tokens: 512, max_tokens: 512,
top_p: 0.9, top_p: 0.9,
image_model: "x/flux2-klein:4b", image_api_url: "http://localhost:1234",
embed_model: "nomic-embed-text", embed_model: "nomic-embed-text",
}; };
function resetDefaults() { function resetDefaults() {
@@ -386,17 +407,39 @@ export function SettingsPanel() {
</div> </div>
)} )}
<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">Image Model</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">Image Server</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
Image model sd-server URL
</label> </label>
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
Base URL of a <a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="underline">stable-diffusion.cpp</a> `sd-server` (A1111-compatible API). Start it with your chosen model, e.g.<br /><code className="font-mono">sd-server -m model.safetensors</code>
</p>
<input <input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]" className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={config.image_model} value={config.image_api_url}
onChange={(e) => setConfig({ ...config, image_model: e.target.value })} onChange={(e) => setConfig({ ...config, image_api_url: e.target.value })}
placeholder="x/flux2-klein:4b" placeholder="http://localhost:1234"
/> />
{/* ponytail: mirror the LLM test button so a non-technical DM gets a green check */}
<div className="flex items-center gap-2 mt-1">
<button
type="button"
onClick={testImage}
disabled={imgTesting}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
>
{imgTesting ? "Testing…" : "Test connection"}
</button>
{imgConn && (
<span className={`text-xs ${imgConn.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
{imgConn.ok ? "✓ sd-server reachable" : `✕ ${imgConn.error}`}
</span>
)}
</div>
<p className="text-[10px] text-[var(--color-text-dim)]">
New to this? Open the <strong>Image</strong> tab → "First time? Set up image generation" for a full step-by-step.
</p>
</div> </div>
<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">Embedding Model (Lore RAG)</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">Embedding Model (Lore RAG)</h3>
+88
View File
@@ -0,0 +1,88 @@
import { Keyboard } from "lucide-react";
import { navItems } from "../App";
interface ShortcutHelpProps {
open: boolean;
onClose: () => void;
}
// ponytail: a single source of truth for the global shortcuts, shown via the
// `?` key. Tool-local shortcuts (space-to-roll in Dice) live in their tool's
// own help text — this is only the app-shell set the DM can't otherwise
// discover because the rail is icon-only.
const GLOBAL: { keys: string; label: string }[] = [
{ keys: "⌘K", label: "Command palette — jump to any tool" },
{ keys: "⌘H", label: "History — re-open a past generation" },
{ keys: "⌘,", label: "Settings" },
{ keys: "Esc", label: "Close palette / overlay" },
{ keys: "?", label: "This help" },
];
export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) {
if (!open) return null;
// ponytail: digit shortcuts come from navItems (single source of truth), so
// this list can't drift from the rail. Only items with a shortcut show.
const digits = navItems.filter((n) => n.shortcut);
return (
<div
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
<div
className="relative w-full max-w-md glass-card p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-3">
<Keyboard size={16} className="text-[var(--color-gold-bright)]" />
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-wide">
Keyboard shortcuts
</h2>
</div>
{/* Global shortcuts */}
<ul className="flex flex-col gap-1.5 mb-3">
{GLOBAL.map((s) => (
<li key={s.keys} className="flex items-center justify-between gap-3">
<span className="text-xs text-[var(--color-text-secondary)]">{s.label}</span>
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
{s.keys}
</kbd>
</li>
))}
</ul>
{/* Digit shortcuts — derived from the rail so they can't drift. */}
{digits.length > 0 && (
<>
<div className="border-t border-[var(--color-border-subtle)] pt-2 mb-2">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
Jump to tool
</span>
</div>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
{digits.map((n) => (
<li key={n.view} className="flex items-center justify-between gap-3">
<span className="text-xs text-[var(--color-text-secondary)] truncate">{n.label}</span>
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
{n.shortcut}
</kbd>
</li>
))}
</ul>
</>
)}
<div className="flex items-center justify-between mt-3 pt-2 border-t border-[var(--color-border-subtle)] text-[10px] text-[var(--color-text-dim)]">
<span>In a tool? Each one lists its own shortcuts inline.</span>
<span>esc to close</span>
</div>
</div>
</div>
);
}
+110 -32
View File
@@ -27,10 +27,16 @@ const SFX_SOUNDS = [
{ id: "footsteps", label: "Steps", icon: "👣" }, { id: "footsteps", label: "Steps", icon: "👣" },
]; ];
type Scene = { name: string; ambients: string[] };
export function Soundboard() { export function Soundboard() {
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set()); const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5); const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false); const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
// ponytail: scenes store only the set of ambient ids + a name; per-scene
// volume is deferred (global volume is enough until a DM actually asks).
const [scenes, setScenes] = usePersistentState<Scene[]>("sound.scenes", []);
const [newSceneName, setNewSceneName] = useState("");
const audioCtxRef = useRef<AudioContext | null>(null); const audioCtxRef = useRef<AudioContext | null>(null);
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map()); const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
const { addToast } = useToast(); const { addToast } = useToast();
@@ -81,26 +87,10 @@ export function Soundboard() {
}); });
}, [addToast]); }, [addToast]);
const toggleAmbient = useCallback((id: string) => { // Shared start/stop so scenes and the toggle button route through one path.
setActiveAmbients((prev) => { const startAmbient = useCallback((id: string) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
// Stop the sound
const nodes = nodesRef.current.get(id);
if (nodes) {
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
setTimeout(() => {
nodes.source.stop();
nodesRef.current.delete(id);
}, 600);
}
} else {
next.add(id);
// Start the sound
const sound = AMBIENT_SOUNDS.find((s) => s.id === id); const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
if (!sound) return next; if (!sound) return;
const ctx = getAudioCtx(); const ctx = getAudioCtx();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
gainNode.gain.value = 0; // Start silent, fade in gainNode.gain.value = 0; // Start silent, fade in
@@ -111,7 +101,6 @@ export function Soundboard() {
filter.frequency.value = sound.filterFreq; filter.frequency.value = sound.filterFreq;
let source: OscillatorNode | AudioBufferSourceNode; let source: OscillatorNode | AudioBufferSourceNode;
if (sound.type === "brown") { if (sound.type === "brown") {
// Brown noise via buffer // Brown noise via buffer
const bufferSize = ctx.sampleRate * 2; const bufferSize = ctx.sampleRate * 2;
@@ -128,7 +117,6 @@ export function Soundboard() {
source.buffer = buffer; source.buffer = buffer;
source.loop = true; source.loop = true;
} else { } else {
// Oscillator
source = ctx.createOscillator(); source = ctx.createOscillator();
source.type = sound.type; source.type = sound.type;
source.frequency.value = sound.freq; source.frequency.value = sound.freq;
@@ -138,12 +126,50 @@ export function Soundboard() {
filter.connect(gainNode); filter.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
source.start(); source.start();
nodesRef.current.set(id, { source, gain: gainNode, filter }); nodesRef.current.set(id, { source, gain: gainNode, filter });
}, [volume, getAudioCtx]);
const stopAmbient = useCallback((id: string) => {
const nodes = nodesRef.current.get(id);
if (!nodes) return;
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
setTimeout(() => {
try { nodes.source.stop(); } catch {}
nodesRef.current.delete(id);
}, 600);
}, []);
const stopAll = useCallback(() => {
nodesRef.current.forEach((_, id) => stopAmbient(id));
nodesRef.current.clear();
setActiveAmbients(new Set());
}, [stopAmbient]);
const toggleAmbient = useCallback((id: string) => {
setActiveAmbients((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
stopAmbient(id);
} else {
next.add(id);
startAmbient(id);
} }
return next; return next;
}); });
}, [volume, getAudioCtx]); }, [startAmbient, stopAmbient]);
// Apply a saved scene: stop everything, then start the scene's ambients.
const applyScene = useCallback((scene: Scene) => {
stopAll();
// Defer starts one tick so the stop ramps clear first; ambients are
// independent ids so a small delay keeps the fade clean.
setTimeout(() => {
setActiveAmbients(new Set(scene.ambients));
scene.ambients.forEach((id) => startAmbient(id));
}, 50);
addToast(`Scene: ${scene.name}`, "info");
}, [stopAll, startAmbient, addToast]);
const playSfx = useCallback((id: string) => { const playSfx = useCallback((id: string) => {
if (muted) return; // ponytail: master mute gates one-shot SFX too. if (muted) return; // ponytail: master mute gates one-shot SFX too.
@@ -359,18 +385,70 @@ export function Soundboard() {
</div> </div>
</div> </div>
{/* Stop all — always visible so the DM never hunts for it. */} {/* Scenes — one-click named combinations of the ambients above. */}
<div>
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
🎭 Scenes
</h3>
<div className="flex flex-wrap items-center gap-1.5">
{scenes.map((scene, i) => (
<div
key={i}
className="group flex items-center gap-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] pl-2.5 pr-1 py-1 transition-colors"
>
<button
onClick={() => applyScene(scene)}
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
title={`Play: ${scene.ambients.map((id) => AMBIENT_SOUNDS.find((s) => s.id === id)?.label).filter(Boolean).join(", ")}`}
>
{scene.name}
</button>
<button
onClick={() => setScenes((prev) => prev.filter((_, j) => j !== i))}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-xs px-1 cursor-pointer"
aria-label={`Delete scene ${scene.name}`}
>
×
</button>
</div>
))}
{scenes.length === 0 && (
<span className="text-[10px] text-[var(--color-text-dim)]">No scenes yet — turn some ambients on and save the combo.</span>
)}
</div>
<div className="flex gap-1.5 mt-2">
<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={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
placeholder={activeAmbients.size ? "Scene name (e.g. Tavern)" : "Turn ambients on first"}
disabled={activeAmbients.size === 0}
onKeyDown={(e) => {
if (e.key === "Enter" && newSceneName.trim() && activeAmbients.size) {
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
setNewSceneName("");
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
}
}}
/>
<button <button
onClick={() => { onClick={() => {
nodesRef.current.forEach((nodes) => { if (!newSceneName.trim() || activeAmbients.size === 0) return;
try { setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5); setNewSceneName("");
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600); addToast(`Saved scene: ${newSceneName.trim()}`, "success");
} catch {}
});
nodesRef.current.clear();
setActiveAmbients(new Set());
}} }}
disabled={!newSceneName.trim() || activeAmbients.size === 0}
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)] disabled:opacity-30 disabled:cursor-not-allowed"
>
Save scene
</button>
</div>
</div>
{/* Stop all — always visible so the DM never hunts for it. */}
<button
onClick={stopAll}
disabled={activeAmbients.size === 0} disabled={activeAmbients.size === 0}
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
> >
+12
View File
@@ -7,6 +7,7 @@ import { addGeneration, extractTitle, 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 { WorldMap } from "./WorldMap"; import { WorldMap } from "./WorldMap";
import { GeneratedImage } from "./GeneratedImage";
interface WorldResponse { interface WorldResponse {
name: string; name: string;
@@ -31,6 +32,8 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null); const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [mapPrompt, setMapPrompt] = useState<string | null>(null);
const [mapNonce, setMapNonce] = useState(0);
const { addToast } = useToast(); const { addToast } = useToast();
// ponytail: record a dragged pin's position into the world so it persists. // ponytail: record a dragged pin's position into the world so it persists.
@@ -98,6 +101,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`World: ${parsed.name || theme}`, `World: ${parsed.name || theme}`,
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`, `${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
); );
// Generate map image prompt
const mapPrompt = `fantasy map of ${parsed.name}, ${parsed.description}, parchment texture, ink cartography, top-down`;
setMapPrompt(mapPrompt);
setMapNonce((n) => n + 1);
} else { } else {
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
@@ -157,6 +164,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
{world.description} {world.description}
</p> </p>
</div> </div>
{mapPrompt && (
<div className="mt-4">
<GeneratedImage prompt={mapPrompt} nonce={mapNonce} className="w-full" aspect="aspect-[3/2]" />
</div>
)}
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */} {/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
+101
View File
@@ -0,0 +1,101 @@
// ponytail: dice notation parsing + advantage/disadvantage, extracted from
// DiceRoller so the math is testable without the component. Pure functions,
// no React, no DOM.
export type Mode = "normal" | "adv" | "dis";
export interface ParsedNotation {
count: number;
sides: number;
modifier: number;
}
// ponytail: one rolled result, shaped for the history list + breakdown view.
// Re-exported by DiceRoller so the component owns the display but not the
// shape.
export interface DieResult {
notation: string;
rolls: number[];
total: number;
modifier: number;
advantage?: Mode | null;
keptRoll?: number;
}
/**
* Parse a dice notation string: `[count]d<sides>[+/-modifier]`.
* Case-insensitive, trims whitespace. Returns null on a bad expression.
* Examples: `d20`, `1d20`, `2d6+3`, `d100-2`, `4d8`.
*/
export function parseNotation(
notation: string,
): ParsedNotation | null {
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
if (!match) return null;
return {
count: parseInt(match[1] || "1"),
sides: parseInt(match[2]),
modifier: parseInt(match[3] || "0"),
};
}
/**
* Apply advantage/disadvantage to a 2-roll d20 set. For non-d20 or non-pair
* rolls, the first roll is kept unchanged. Returns the rolls to display plus
* the single value to add to the modifier.
*/
export function applyMode(
rolls: number[],
sides: number,
mode: Mode,
): { rolls: number[]; kept: number } {
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
return { rolls, kept: rolls[0] ?? 0 };
}
if (mode === "adv") {
return { rolls, kept: Math.max(rolls[0], rolls[1]) };
}
return { rolls, kept: Math.min(rolls[0], rolls[1]) };
}
// ─── Self-check (run: node scripts/check-dice.ts) ──────────────
// ponytail: the smallest thing that fails if the parser breaks. No framework
// — plain asserts. Covers the notations a DM types every session plus the
// adv/dis keep rule and the boundary cases that silently break (negative
// mods, bare `d20`, `d100`, rejects garbage).
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`dice self-check failed: ${msg}`);
}
export function demo(): void {
// Basic shapes.
assert(parseNotation("d20")!.count === 1, "bare d20 → count 1");
assert(parseNotation("1d20")!.sides === 20, "1d20 sides");
assert(parseNotation("2d6+3")!.count === 2, "2d6+3 count");
assert(parseNotation("2d6+3")!.sides === 6, "2d6+3 sides");
assert(parseNotation("2d6+3")!.modifier === 3, "2d6+3 mod +3");
assert(parseNotation("1d20-2")!.modifier === -2, "negative modifier");
assert(parseNotation("d100")!.sides === 100, "d100 sides");
// Case + whitespace tolerance.
assert(parseNotation(" 4D8 ")!.count === 4, "uppercase + whitespace");
assert(parseNotation("3d8-5")!.modifier === -5, "3d8-5 mod -5");
// Garbage rejected.
assert(parseNotation("hello") === null, "words rejected");
assert(parseNotation("2d") === null, "missing sides rejected");
assert(parseNotation("d20+abc") === null, "non-numeric mod rejected");
assert(parseNotation("") === null, "empty rejected");
// Advantage keeps the higher of two d20s; disadvantage the lower.
assert(applyMode([5, 17], 20, "adv").kept === 17, "adv keeps high");
assert(applyMode([5, 17], 20, "dis").kept === 5, "dis keeps low");
// Non-d20 rolls ignore mode entirely.
assert(applyMode([4, 6], 6, "adv").kept === 4, "non-d20 adv keeps first");
// Single roll (no comparison possible) keeps the first.
assert(applyMode([12], 20, "adv").kept === 12, "single roll keeps first");
// Normal mode keeps the first regardless of count.
assert(applyMode([8, 15], 20, "normal").kept === 8, "normal keeps first");
console.log("dice self-check passed ✓");
}
+6
View File
@@ -0,0 +1,6 @@
// ponytail: reuses the in-source demo() self-check so there's one set of
// assertions, not two. Run: node scripts/check-encounter-budget.ts (or any
// TS runner). No test framework, no @types/node dependency.
import { demo } from "./encounter-budget";
demo();
+42
View File
@@ -62,3 +62,45 @@ export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
Hard: "var(--color-gold-bright)", Hard: "var(--color-gold-bright)",
Deadly: "var(--color-danger)", Deadly: "var(--color-danger)",
}; };
// ─── Self-check (run: node scripts/check-encounter-budget.ts) ────
// ponytail: the smallest thing that fails if the budget math breaks. No
// framework — plain asserts. Verifies the per-level table scales, the
// party-size multiplier, level clamping, and the XP→difficulty bucketing
// (the boundary the DMG relies on: a budget-equal XP is the *next* tier).
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`encounter-budget self-check failed: ${msg}`);
}
export function demo(): void {
// Level 5, 4 PCs — DMG p.82 per-character: Easy 250 / Med 500 / Hard 750 / Deadly 1100.
const b = encounterBudget(5, 4);
assert(b.easy === 1000, "L5×4 easy = 250×4");
assert(b.medium === 2000, "L5×4 medium = 500×4");
assert(b.hard === 3000, "L5×4 hard = 750×4");
assert(b.deadly === 4400, "L5×4 deadly = 1100×4");
// Solo L1 character.
const solo = encounterBudget(1, 1);
assert(solo.easy === 25 && solo.deadly === 100, "L1×1 matches table");
// Level clamps to 1..20; size clamps to >=1.
const low = encounterBudget(0, 1);
assert(low.easy === 25, "level clamps up to 1");
const high = encounterBudget(99, 1);
assert(high.deadly === 12700, "level clamps down to 20");
const zero = encounterBudget(5, 0);
assert(zero.medium === 500, "size clamps up to 1");
// difficultyForXp: budget-equal XP lands in the *next* tier up (>=, not >),
// and anything below the easy floor is still Easy (never undefined).
const d = encounterBudget(5, 4);
assert(difficultyForXp(999, d) === "Easy", "below easy floor stays Easy");
assert(difficultyForXp(1000, d) === "Easy", "== easy budget is Easy");
assert(difficultyForXp(2000, d) === "Medium", "== medium budget is Medium");
assert(difficultyForXp(3000, d) === "Hard", "== hard budget is Hard");
assert(difficultyForXp(4400, d) === "Deadly", "== deadly budget is Deadly");
assert(difficultyForXp(9999, d) === "Deadly", "over deadly stays Deadly");
console.log("encounter-budget self-check passed ✓");
}