16 Commits

Author SHA1 Message Date
itsamejms d41737e1c9 docs: roadmap — Phase 1 complete
CI / frontend (push) Successful in 30s
CI / rust (push) Successful in 5m33s
2026-09-07 10:28:57 +01:00
itsamejms 160788d63b feat: condition durations — 'Hexed (3)' ticks down each turn, drops at 0
CI / frontend (push) Successful in 30s
CI / rust (push) Successful in 5m26s
Numbered conditions decrement when the combatant's turn starts and
expire at zero; plain conditions still toggle manually. The custom
condition input hints the syntax. Roadmap 1.8 — Phase 1 complete.
2026-09-07 10:28:30 +01:00
itsamejms 630a025e77 feat: dice macros — persisted named rolls (Grimjaw attack, fire damage…)
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 5m41s
Type a notation in the input, name it, '+ macro' saves it. Chips roll
with the template relabel path so history reads 'Attack (1d20+5) → 17';
× deletes. Roadmap 1.7.
2026-09-07 10:28:04 +01:00
itsamejms 00178e603d feat: first-run wizard pulls models with a real progress bar
CI / frontend (push) Successful in 33s
CI / rust (push) Successful in 5m48s
New pull_model command streams Ollama /api/pull NDJSON over a Channel
(status lines + completed/total bytes). The wizard's model step gains a
'type any tag to pull' input: uninstalled models download in-app with
a progress bar instead of the old 'run ollama pull yourself' dead end.
The NDJSON line-loop is factored out of the chat streaming path and
shared.
2026-09-07 10:27:41 +01:00
itsamejms 3c599553fc feat: session auto-capture + NPC→Initiative push + lore grounding everywhere
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 6m3s
- SessionLogger listens for a new LogEntry bus event; dice rolls,
  initiative turns/rounds, generated encounters and calendar day
  advances land in the active session as timestamped entries — the
  raw material the AI summary actually needs. Mute toggle in the
  toolbar (persisted, default on).
- NPC Generator: '⚔ to initiative' button pushes the generated stat
  block (HP) into the tracker via the existing AddCombatants bus event.
- ragQuery now set on Encounter, Quest and Session summary too
  (NPC/Item/World already had it) — every generator is world-grounded.
2026-09-07 10:25:38 +01:00
itsamejms bbd492fff1 feat: real token streaming from Ollama (NDJSON) + OpenAI-compatible (SSE)
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 5m51s
generate_stream previously did a non-streaming call and emitted the
whole response as one fake token. Now reqwest bytes_stream feeds a
line buffer; every token piece goes out as it arrives via the existing
Channel. SessionLogger's progressive display lights up unchanged.
Per-line parsers are pure fns with unit tests; unparseable lines are
skipped, server error lines bubble.
2026-09-06 23:33:54 +01:00
itsamejms 7bdfe5dbae docs: roadmap — Phase 0 complete
CI / frontend (push) Successful in 32s
CI / rust (push) Successful in 5m41s
2026-09-06 23:27:43 +01:00
itsamejms 1e7301647d feat: explicit provider field replaces URL sniffing
CI / frontend (push) Successful in 30s
CI / rust (push) Successful in 5m42s
LlmConfig.provider ("ollama" | "openai", empty = sniff URL so legacy
configs keep working). Settings presets set it — a custom-port Ollama
no longer falls into the OpenAI branch and fails confusingly.

Also: test_connection now accepts an optional config override — the
wizard was passing one that Rust silently ignored, so it tested the
saved config instead of the URL the user just typed.
2026-09-06 23:27:20 +01:00
itsamejms 624d82931b fix: set minimal CSP instead of null
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 5m39s
All LLM/image HTTP goes through Rust (reqwest), so the webview needs
almost nothing: self for assets, data: for generated-image URLs,
inline styles (Vite dev + style attrs), and ws to the Vite dev server
for HMR. Verify on next dev run.
2026-09-06 23:26:25 +01:00
itsamejms 00fb901aee docs: fix stale image-gen refs, Node prereq, version sync; drop dead icons.svg
CI / frontend (push) Successful in 30s
CI / rust (push) Successful in 5m38s
- README: image gen is sd-server (cross-platform), not macOS/Ollama;
  Node 23.6+ note for self-checks; prefs store row in the data table.
- plan.md: superseded banners on the Ollama image-gen sections.
- gitea-release.sh: syncs Cargo.toml too; bumped to 0.1.3 to match.
- public/icons.svg: unreferenced — favicon.svg is the icon.
2026-09-06 23:26:16 +01:00
itsamejms c5d4db4ae1 ci: gitea workflow (lint + self-checks + tsc/vite build + cargo test)
CI / frontend (push) Successful in 32s
CI / rust (push) Successful in 5m39s
npm run check aggregates the three node self-checks so CI (and humans)
run them with one command. Node 23.6+ required for bare .ts execution.
2026-09-06 23:25:46 +01:00
itsamejms a69d7caefb fix: RAG — UTF-8 chunk boundary, source dedupe, embed-model guard + reindex
- chunk(): byte splits that landed mid multibyte char silently dropped
  the whole chunk (accented/CJK lore). Back the boundary off to a char
  boundary; regression test included.
- add_document(): DELETE the source first — re-adding a file no longer
  doubles its chunks.
- chunks now record their embed_model (ALTER TABLE migration for old
  DBs); search skips chunks from a different model; rag_list reports it;
  new rag_reindex re-embeds everything, surfaced in the Lore panel as a
  mismatch banner with a one-click Reindex.
2026-09-06 23:25:22 +01:00
itsamejms 913345a05e test: unique temp dir per generations test — parallel runs raced on one SQLite file 2026-09-06 23:23:25 +01:00
itsamejms 03a634fdd0 fix: persist LLM config to prefs store so Settings survive restarts
set_llm_config only mutated an in-memory Mutex; lib.rs always started
from LlmConfig::default(). Config now round-trips through the same
dm-pal-prefs.json store as dataDir, with a defaults fallback so a
future shape change can't brick startup. Prefs consts now live in
lib.rs (pub) instead of being mirrored per-module.
2026-09-06 23:22:34 +01:00
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
29 changed files with 1199 additions and 121 deletions
+40
View File
@@ -0,0 +1,40 @@
# ponytail: two jobs, no matrix, no third-party actions beyond checkout +
# setup-node (both mirrored on gitea.com and github) so this runs on any
# act_runner config. Rust toolchain installs via rustup if missing.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
# 23.6+ for native TS type-stripping (scripts/check-*.ts run bare .ts)
node-version: 23
- run: npm ci
- run: npm run lint
- run: npm run check
- run: npm run build
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Tauri system deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \
librsvg2-dev pkg-config libssl-dev
- name: Install Rust
run: |
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- run: cargo test --manifest-path src-tauri/Cargo.toml
+4 -3
View File
@@ -18,7 +18,7 @@ into **Session** (live) and **World** (prep) tools:
- **NPC Generator** — portraits, personality, goals, stat blocks - **NPC Generator** — portraits, personality, goals, stat blocks
- **Quest Designer** — multi-step quests with twists and reward breakdown - **Quest Designer** — multi-step quests with twists and reward breakdown
- **Item Forge** — magic items with art and structured mechanics - **Item Forge** — magic items with art and structured mechanics
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama) - **Image Generator** — portraits, maps, scene art (cross-platform, via stable-diffusion.cpp)
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary - **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
- **Soundboard** — synthesized ambience/SFX with one-click scenes - **Soundboard** — synthesized ambience/SFX with one-click scenes
- **World Builder** — generated regions, landmarks, a draggable-pin map - **World Builder** — generated regions, landmarks, a draggable-pin map
@@ -34,7 +34,7 @@ persistent state round it out — reload loses nothing.
### Prerequisites ### Prerequisites
- **Rust** + **Cargo** — https://rustup.rs - **Rust** + **Cargo** — https://rustup.rs
- **Node.js** 20+ — https://nodejs.org - **Node.js** 20+ (23.6+ to run the `npm run check` self-checks) — https://nodejs.org
- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`) - **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
### Install & run ### Install & run
@@ -66,7 +66,8 @@ All campaign data stays on disk under your OS app-data dir (default
| File | Contents | | File | Contents |
|------|----------| |------|----------|
| `lore.db` | RAG chunks + embeddings (SQLite) | | `dm-pal-prefs.json` | App prefs — data-dir setting + LLM config incl. API key (stored locally, one level above `dm-toolkit/`) |
| `lore/lore.db` | RAG chunks + embeddings (SQLite) |
| `generations.db` | History of every generated NPC/encounter/item/quest/… | | `generations.db` | History of every generated NPC/encounter/item/quest/… |
| `images/` | Cached generated PNGs, keyed by prompt hash | | `images/` | Cached generated PNGs, keyed by prompt hash |
| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) | | `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
+9 -2
View File
@@ -351,6 +351,13 @@ pub async fn generate_image(
- **macOS-only today** — Ollama image models only run on macOS (Apple Silicon via MLX). Gate the image-gen UI behind an OS check on first run; on other platforms fall back to a placeholder/emoji or the optional remote API. (This is an Ollama limitation, not ours.) - **macOS-only today** — Ollama image models only run on macOS (Apple Silicon via MLX). Gate the image-gen UI behind an OS check on first run; on other platforms fall back to a placeholder/emoji or the optional remote API. (This is an Ollama limitation, not ours.)
- **Slow + heavy** — 4B is ~5.7GB, 9B is ~12GB, generation is multi-second. Always generate in the background with a progress bar (drive it from the NDJSON `step`/`total` lines), never block the UI thread. Cache results to disk by hash of the prompt. - **Slow + heavy** — 4B is ~5.7GB, 9B is ~12GB, generation is multi-second. Always generate in the background with a progress bar (drive it from the NDJSON `step`/`total` lines), never block the UI thread. Cache results to disk by hash of the prompt.
- **Model picker** — add `image_model` to `LlmConfig` (default `x/flux2-klein:4b`; `x/z-image-turbo` for fast/low-VRAM). Reuse the existing settings panel, don't build a second one. - **Model picker** — add `image_model` to `LlmConfig` (default `x/flux2-klein:4b`; `x/z-image-turbo` for fast/low-VRAM). Reuse the existing settings panel, don't build a second one.
> **Superseded (2026-09):** image generation now targets a
> [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp)
> `sd-server` via its AUTOMATIC1111-compatible API — cross-platform, no
> macOS gating, no per-request model field. See README and
> `src-tauri/src/commands/image_commands.rs`. Everything below (this section
> and §10's image-model rows) is kept as design history only.
- **Prompt engineering is the lever** — FLUX.2 handles readable text and hex colors, so item/NPC name labels can be rendered *into* the image where it helps. Default to 1024×1024. - **Prompt engineering is the lever** — FLUX.2 handles readable text and hex colors, so item/NPC name labels can be rendered *into* the image where it helps. Default to 1024×1024.
--- ---
@@ -759,7 +766,7 @@ Local LLMs are not "fire and forget." Plan for:
| **Loading state** | Model load can take 5–30 s on HDD/CPU. Show progress bar and cancel button | | **Loading state** | Model load can take 5–30 s on HDD/CPU. Show progress bar and cancel button |
| **GPU offloading** | Expose `n_gpu_layers` slider per model | | **GPU offloading** | Expose `n_gpu_layers` slider per model |
| **Context length** | 2k/4k/8k selector with memory warning | | **Context length** | 2k/4k/8k selector with memory warning |
| **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. | | **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. **Superseded 2026-09 — see the banner in §4.4.** |
| **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool | | **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool |
| **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models | | **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models |
@@ -925,7 +932,7 @@ checkable change — no redesigns.*
quickstart, prerequisites (Ollama), where data lives, license note. quickstart, prerequisites (Ollama), where data lives, license note.
Highest-ROI 20-minute task in the repo. Highest-ROI 20-minute task in the repo.
- [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a - [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a
real Apple ID email (`james.twose2711@gmail.com`) in a tracked file. real Apple ID email in a tracked file.
Replace with `you@example.com`. Replace with `you@example.com`.
- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet` - [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet`
Tauri command in `src-tauri/src/lib.rs`.** Leftover from Tauri command in `src-tauri/src/lib.rs`.** Leftover from
+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.
+123
View File
@@ -0,0 +1,123 @@
# 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) — ✅ DONE 2026-09-06
| # | Item | Effort | Notes |
|---|------|--------|-------|
| [x] 0.1 | Scrub PII from `scripts/apple-signing.env.example` | 5 m | [R] real email + Team ID still present |
| [x] 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 |
| [x] 0.3 | RAG: UTF-8 boundary fix in `chunk()` + test with accented text | 1 h | [R] multibyte paragraphs silently drop chunks today |
| [x] 0.4 | RAG: `DELETE WHERE source = ?` before re-insert in `add_document` | 15 m | [R] re-adding a file doubles its chunks |
| [x] 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 |
| [x] 0.6 | `npm run check` + `.gitea/workflows/ci.yml` (lint, tsc, checks, `cargo test`) | 2 h | [R][P] |
| [x] 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] |
| [x] 0.8 | Minimal CSP in `tauri.conf.json` (self + `data:` img; check dev HMR) | 30 m | [R] |
| [x] 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. — all shipped: config
round-trips through the prefs store, CI workflow pushed (verify first run
on Gitea), Reindex in the Lore panel, PII scrubbed (note: earlier values
remain in git history).
## Phase 1 — Core UX enablers → tag `v0.2.0` (~1 week) — ✅ DONE 2026-09-06
| # | Item | Effort | Notes |
|---|------|--------|-------|
| [x] 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 |
| [x] 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" |
| [x] 1.3 | `ragQuery` on Encounter, Quest, Session summary | 30 m | [N] NPC/Item/World already pass it |
| [x] 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 |
| [x] 1.5 | NPC → Initiative push ("⚔ Add to tracker" on NPC card via `AddCombatants` bus event; stats already generated) | 30 m | [N] |
| [x] 1.6 | Calendar "Next day" → session-log entry incl. rolled weather | 1 h | [N] ties calendar into the timeline |
| [x] 1.7 | Dice macros: persisted named rolls (`usePersistentState`), button row + edit sheet | 2 h | [U P3] |
| [x] 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.
— all shipped; verify the wizard pull once on a real Ollama instance.
## 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.
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"check": "node scripts/check-dice.ts && node scripts/check-encounter-budget.ts && node scripts/check-worldmap.ts",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+3 -3
View File
@@ -9,7 +9,7 @@
# 3. APPLE_PASSWORD: an app-specific password from https://account.apple.com # 3. APPLE_PASSWORD: an app-specific password from https://account.apple.com
# (Sign-In and Security -> App-Specific Passwords). NOT your normal password. # (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 # 4. APPLE_TEAM_ID: 10-char Team ID from https://developer.apple.com/account#MembershipDetailsCard
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (4759TW4SDC)" export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (XXXXXXXXXX)"
export APPLE_ID="james.twose2711@gmail.com" export APPLE_ID="you@example.com"
export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx" export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="4759TW4SDC" export APPLE_TEAM_ID="XXXXXXXXXX"
+6 -1
View File
@@ -43,9 +43,14 @@ node -e '
if(out===t)throw new Error("version not replaced in "+f); if(out===t)throw new Error("version not replaced in "+f);
fs.writeFileSync(f,out); fs.writeFileSync(f,out);
} }
// Cargo.toml: first top-of-file `version = "x.y.z"` (not the deps below it).
{const f="src-tauri/Cargo.toml",t=fs.readFileSync(f,"utf8");
const out=t.replace(/^version\s*=\s*"\d+\.\d+\.\d+"/m,`version = "${ver}"`);
if(out===t)throw new Error("version not replaced in "+f);
fs.writeFileSync(f,out);}
' "$VER" ' "$VER"
echo "release $TAG (version files synced)" echo "release $TAG (version files synced)"
git add package.json src-tauri/tauri.conf.json git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml
if ! git diff --cached --quiet; then if ! git diff --cached --quiet; then
git commit -m "chore: release $TAG" -q git commit -m "chore: release $TAG" -q
echo "committed version bump" echo "committed version bump"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dm-pal" name = "dm-pal"
version = "0.1.0" version = "0.1.3"
description = "AI-Powered Dungeon Master Toolkit" description = "AI-Powered Dungeon Master Toolkit"
authors = ["DM-Pal Team"] authors = ["DM-Pal Team"]
license = "" license = ""
+2 -5
View File
@@ -3,11 +3,8 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::Manager; use tauri::Manager;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use crate::{DATA_DIR_KEY, PREFS_STORE};
// ponytail: the data-dir pref store + key are defined in lib.rs; mirror them // PREFS_STORE / DATA_DIR_KEY live in lib.rs (pub) — single source of truth.
// here so this module is self-contained for reads/writes.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct DataDirInfo { pub struct DataDirInfo {
+302 -13
View File
@@ -1,8 +1,10 @@
use crate::commands::emit_busy; 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::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tauri::ipc::Channel; use tauri::ipc::Channel;
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
// ─── Simple (non-streaming) generate ───────────────────────── // ─── Simple (non-streaming) generate ─────────────────────────
@@ -36,7 +38,7 @@ async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest)
// consistent with the user's world bible. Shared path = every caller. // consistent with the user's world bible. Shared path = every caller.
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
if llm::is_ollama(&config.api_url) { if llm::is_ollama(&config.provider, &config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await call_ollama(&client, &config, &messages, temperature, max_tokens).await
} else { } else {
call_openai(&client, &config, &messages, temperature, max_tokens).await call_openai(&client, &config, &messages, temperature, max_tokens).await
@@ -62,17 +64,16 @@ pub async fn generate_stream(
emit_busy(&app, true); 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 // Real token streaming: Ollama NDJSON / OpenAI SSE, one LlmEvent::Token
// Real SSE streaming from Ollama/OpenAI can be added later // per piece as it arrives. Falls back to Done carrying the full text.
let result = if llm::is_ollama(&config.api_url) { let result = if llm::is_ollama(&config.provider, &config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await call_ollama_stream(&client, &config, &messages, temperature, max_tokens, &channel).await
} else { } else {
call_openai(&client, &config, &messages, temperature, max_tokens).await call_openai_stream(&client, &config, &messages, temperature, max_tokens, &channel).await
}; };
match result { match result {
Ok(text) => { Ok(text) => {
let _ = channel.send(LlmEvent::Token(text.clone()));
let _ = channel.send(LlmEvent::Done(text)); let _ = channel.send(LlmEvent::Done(text));
} }
Err(e) => { Err(e) => {
@@ -86,6 +87,91 @@ pub async fn generate_stream(
Ok(()) Ok(())
} }
// ─── Model pull (Ollama) ─────────────────────────────
/// Channel events for a streaming `ollama pull`.
#[derive(Clone, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
pub enum PullEvent {
/// Human-readable status line ("pulling manifest", "verifying sha256 digest").
Status(String),
/// Download progress in bytes.
Progress { completed: Option<u64>, total: Option<u64> },
Done,
Error(String),
}
#[derive(Debug, Deserialize)]
pub struct PullRequest {
pub model: String,
}
/// POST {api_url}/api/pull with stream:true, progress over the channel.
/// Ollama-only — other providers install models out of band, so the wizard
/// gates this. Progress fields vary per Ollama version: completed/total are
/// Option and the UI falls back to the status line.
#[tauri::command]
pub async fn pull_model(
state: tauri::State<'_, AppState>,
app: AppHandle,
req: PullRequest,
channel: Channel<PullEvent>,
) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
if !llm::is_ollama(&config.provider, &config.api_url) {
return Err("Model pull is only supported for Ollama endpoints".into());
}
emit_busy(&app, true);
let url = format!("{}/api/pull", config.api_url.trim_end_matches('/'));
let body = json!({ "model": req.model, "stream": true });
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
let res = client.post(&url).json(&body).send().await;
let result = match res {
Ok(r) if r.status().is_success() => {
for_each_ndjson_line(r, |line| {
let line = line.trim();
if line.is_empty() {
return Ok(false);
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
return Ok(false); // skip unparseable progress lines
};
if let Some(err) = v["error"].as_str() {
return Err(err.to_string());
}
let status = v["status"].as_str().unwrap_or_default().to_string();
let completed = v["completed"].as_u64();
let total = v["total"].as_u64();
if status == "success" {
return Ok(true);
}
if completed.is_some() || total.is_some() {
let _ = channel.send(PullEvent::Progress { completed, total });
} else if !status.is_empty() {
let _ = channel.send(PullEvent::Status(status));
}
Ok(false)
})
.await
}
Ok(r) => Err(format!("pull error {}", r.status())),
Err(e) => Err(format!("pull failed: {e}")),
};
match result {
Ok(()) => {
let _ = channel.send(PullEvent::Done);
}
Err(e) => {
let _ = channel.send(PullEvent::Error(e));
}
}
// ponytail: always balance the busy counter, even on error.
emit_busy(&app, false);
});
Ok(())
}
// ─── Get / Set LLM Config ──────────────────────────────────── // ─── Get / Set LLM Config ────────────────────────────────────
#[tauri::command] #[tauri::command]
@@ -95,9 +181,18 @@ pub fn get_llm_config(state: tauri::State<'_, AppState>) -> Result<crate::llm::L
} }
#[tauri::command] #[tauri::command]
pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::LlmConfig) -> Result<(), String> { pub fn set_llm_config(
let mut lock = state.config.lock().map_err(|e| e.to_string())?; app: AppHandle,
*lock = config; state: tauri::State<'_, AppState>,
config: crate::llm::LlmConfig,
) -> Result<(), String> {
let stored = serde_json::to_value(&config).map_err(|e| e.to_string())?;
*state.config.lock().map_err(|e| e.to_string())? = config;
// Persist so Settings survive restart. Same prefs store as dataDir;
// load_llm_config in lib.rs falls back to defaults if this ever unparses.
let store = app.store(crate::PREFS_STORE).map_err(|e| e.to_string())?;
store.set(crate::CONFIG_KEY, stored);
store.save().map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@@ -113,14 +208,22 @@ pub struct ConnectionTest {
} }
#[tauri::command] #[tauri::command]
pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result<ConnectionTest, String> { pub async fn test_connection(
let config = state.config.lock().map_err(|e| e.to_string())?.clone(); state: tauri::State<'_, AppState>,
config: Option<crate::llm::LlmConfig>,
) -> Result<ConnectionTest, String> {
// Optional override: the first-run wizard tests a config it hasn't saved
// yet. None = use the persisted state (Settings panel saves first anyway).
let config = match config {
Some(c) => c,
None => state.config.lock().map_err(|e| e.to_string())?.clone(),
};
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8)) .timeout(std::time::Duration::from_secs(8))
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if llm::is_ollama(&config.api_url) { if llm::is_ollama(&config.provider, &config.api_url) {
let url = format!("{}/api/tags", config.api_url.trim_end_matches('/')); let url = format!("{}/api/tags", config.api_url.trim_end_matches('/'));
let res = client.get(&url).send().await.map_err(|e| e.to_string())?; let res = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() { if !res.status().is_success() {
@@ -286,4 +389,190 @@ async fn call_openai(
.first() .first()
.map(|c| c.message.content.clone()) .map(|c| c.message.content.clone())
.ok_or_else(|| "No response from API".to_string()) .ok_or_else(|| "No response from API".to_string())
}
// ─── Real streaming (Ollama NDJSON / OpenAI SSE) ─────────────
// ponytail: shared line-buffer + per-line Value parsing. Typed structs would
// break on the shape variance across servers (final lines omit message,
// empty contents, metrics lines) — skipping unparseable lines is the
// boring-correct choice.
use futures_util::StreamExt;
/// What one parsed stream line yields.
struct StreamLine {
token: Option<String>,
done: bool,
}
/// Parse one Ollama NDJSON line: `{"message":{"content":"…"},"done":false}`.
fn parse_ollama_line(line: &str) -> Result<Option<StreamLine>, String> {
let line = line.trim();
if line.is_empty() {
return Ok(None);
}
let v: serde_json::Value = serde_json::from_str(line).map_err(|e| format!("bad NDJSON line: {e}"))?;
if let Some(err) = v["error"].as_str() {
return Err(err.to_string());
}
Ok(Some(StreamLine {
token: v["message"]["content"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string()),
done: v["done"].as_bool().unwrap_or(false),
}))
}
/// Parse one OpenAI SSE line: `data: {"choices":[{"delta":{"content":"…"}}]}`
/// or the terminal `data: [DONE]`.
fn parse_openai_line(line: &str) -> Result<Option<StreamLine>, String> {
let line = line.trim();
let Some(payload) = line.strip_prefix("data: ") else {
return Ok(None); // keep-alive comments / blank lines
};
if payload == "[DONE]" {
return Ok(Some(StreamLine { token: None, done: true }));
}
let v: serde_json::Value = serde_json::from_str(payload).map_err(|e| format!("bad SSE line: {e}"))?;
if let Some(err) = v["error"]["message"].as_str() {
return Err(err.to_string());
}
Ok(Some(StreamLine {
token: v["choices"][0]["delta"]["content"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string()),
done: false,
}))
}
/// POST with `stream: true`, emit Token per piece, return the full text
/// when the stream finishes. `body` is the request JSON minus the stream flag.
async fn stream_chat(
client: &reqwest::Client,
url: &str,
mut body: serde_json::Value,
channel: &Channel<LlmEvent>,
parse: fn(&str) -> Result<Option<StreamLine>, String>,
bearer: Option<&str>,
) -> Result<String, String> {
body["stream"] = serde_json::json!(true);
let mut req = client.post(url).json(&body);
if let Some(key) = bearer {
req = req.bearer_auth(key);
}
let res = req.send().await.map_err(|e| format!("stream request failed: {e}"))?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
return Err(format!("stream error {status}: {text}"));
}
stream_from_response(res, channel, parse).await
}
/// Consume a streaming HTTP response: buffer bytes into lines, hand each
/// complete line to `on_line`. `Ok(true)` from the callback stops the loop.
async fn for_each_ndjson_line(
res: reqwest::Response,
mut on_line: impl FnMut(&str) -> Result<bool, String>,
) -> Result<(), String> {
let mut buf = String::new();
let mut stream = res.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("stream read failed: {e}"))?;
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(nl) = buf.find('\n') {
let line: String = buf.drain(..=nl).collect();
if on_line(line.trim_end())? {
return Ok(());
}
}
}
Ok(())
}
/// Consume a streaming chat response: parse each line with `parse`, emit
/// Token per piece, and return the full text when the stream finishes.
async fn stream_from_response(
res: reqwest::Response,
channel: &Channel<LlmEvent>,
parse: fn(&str) -> Result<Option<StreamLine>, String>,
) -> Result<String, String> {
let mut full = String::new();
for_each_ndjson_line(res, |line| {
match parse(line)? {
Some(StreamLine { token: Some(t), done }) => {
full.push_str(&t);
let _ = channel.send(LlmEvent::Token(t));
Ok(done)
}
Some(StreamLine { token: None, done: true }) => Ok(true),
_ => Ok(false),
}
})
.await?;
// Stream closed without an explicit done — return what we got.
Ok(full)
}
async fn call_ollama_stream(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
channel: &Channel<LlmEvent>,
) -> Result<String, String> {
let url = format!("{}/api/chat", config.api_url.trim_end_matches('/'));
let body = json!({
"model": config.model,
"messages": messages,
"options": { "temperature": temperature, "num_predict": max_tokens },
});
stream_chat(client, &url, body, channel, parse_ollama_line, None).await
}
async fn call_openai_stream(
client: &reqwest::Client,
config: &crate::llm::LlmConfig,
messages: &[ChatMessage],
temperature: f32,
max_tokens: u32,
channel: &Channel<LlmEvent>,
) -> Result<String, String> {
let url = format!("{}/v1/chat/completions", config.api_url.trim_end_matches('/'));
let body = json!({
"model": config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
});
let bearer = (!config.api_key.is_empty()).then_some(config.api_key.as_str());
stream_chat(client, &url, body, channel, parse_openai_line, bearer).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ollama_line_parses_tokens_and_done() {
let tok = parse_ollama_line("{\"message\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"done\":false}").unwrap().unwrap();
assert_eq!(tok.token.as_deref(), Some("Hel"));
assert!(!tok.done);
// final line: no message content, done + metrics
let fin = parse_ollama_line("{\"done\":true,\"total_duration\":123}").unwrap().unwrap();
assert_eq!(fin.token, None);
assert!(fin.done);
// blank lines are skipped, errors bubble
assert!(parse_ollama_line("").unwrap().is_none());
assert!(parse_ollama_line("{\"error\":\"model not found\"}").is_err());
}
#[test]
fn openai_line_parses_sse() {
let tok = parse_openai_line("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}").unwrap().unwrap();
assert_eq!(tok.token.as_deref(), Some("lo"));
let fin = parse_openai_line("data: [DONE]").unwrap().unwrap();
assert!(fin.done);
// non-data lines (keep-alives) skipped; role-only deltas yield no token
assert!(parse_openai_line(": ping").unwrap().is_none());
let role = parse_openai_line("data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}").unwrap().unwrap();
assert_eq!(role.token, None);
}
} }
+20 -1
View File
@@ -18,6 +18,15 @@ pub struct RagHit {
pub struct RagSource { pub struct RagSource {
pub source: String, pub source: String,
pub chunks: i64, pub chunks: i64,
/// Embedding model the source was indexed with ("" = legacy/unknown).
/// Lets the UI flag sources that need a Reindex after a model change.
pub model: String,
}
#[derive(Debug, Serialize)]
pub struct RagReindexReport {
pub sources: usize,
pub chunks: usize,
} }
#[tauri::command] #[tauri::command]
@@ -48,10 +57,20 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, Str
.list_sources() .list_sources()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.into_iter() .into_iter()
.map(|(source, chunks)| Ok(RagSource { source, chunks })) .map(|(source, chunks, model)| Ok(RagSource { source, chunks, model }))
.collect() .collect()
} }
/// Re-embed every source with the currently configured embed model.
/// The fix-it button when the DM changes models (or for legacy chunks
/// indexed before per-source model stamping).
#[tauri::command]
pub async fn rag_reindex(state: tauri::State<'_, AppState>) -> Result<RagReindexReport, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let (sources, chunks) = state.rag.reindex(&config).await.map_err(|e| e.to_string())?;
Ok(RagReindexReport { sources, chunks })
}
#[tauri::command] #[tauri::command]
pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option<String>) -> Result<(), String> { pub fn rag_clear(state: tauri::State<'_, AppState>, source: Option<String>) -> Result<(), String> {
state.rag.clear(source.as_deref()).map_err(|e| e.to_string()) state.rag.clear(source.as_deref()).map_err(|e| e.to_string())
+6 -4
View File
@@ -159,15 +159,17 @@ impl GenerationStore {
mod tests { mod tests {
use super::*; use super::*;
fn tmp() -> std::path::PathBuf { // ponytail: unique dir per test — both tests previously shared one
let dir = std::env::temp_dir().join(format!("dm-pal-test-{}", std::process::id())); // pid-keyed dir and raced on the same SQLite file (flaky "disk I/O error").
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("dm-pal-test-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
dir dir
} }
#[test] #[test]
fn round_trip() { fn round_trip() {
let dir = tmp(); let dir = tmp("round-trip");
let store = GenerationStore::open(&dir).unwrap(); let store = GenerationStore::open(&dir).unwrap();
let id = store let id = store
.add("npc", "Thorin Stonefist", r#"{"name":"Thorin"}"#, Some("Dwarf Fighter")) .add("npc", "Thorin Stonefist", r#"{"name":"Thorin"}"#, Some("Dwarf Fighter"))
@@ -196,7 +198,7 @@ mod tests {
#[test] #[test]
fn clear_all() { fn clear_all() {
let dir = tmp(); let dir = tmp("clear-all");
let store = GenerationStore::open(&dir).unwrap(); let store = GenerationStore::open(&dir).unwrap();
store.add("a", "x", "{}", None).unwrap(); store.add("a", "x", "{}", None).unwrap();
store.add("b", "y", "{}", None).unwrap(); store.add("b", "y", "{}", None).unwrap();
+17 -3
View File
@@ -12,8 +12,11 @@ use tauri_plugin_store::StoreExt;
// ponytail: the data-dir preference lives in a tiny store at the OS default // ponytail: the data-dir preference lives in a tiny store at the OS default
// app_data_dir so it's always discoverable at startup, even before we know the // app_data_dir so it's always discoverable at startup, even before we know the
// custom location. Key: `dataDir` (absolute path). Empty/missing → default. // custom location. Key: `dataDir` (absolute path). Empty/missing → default.
const PREFS_STORE: &str = "dm-pal-prefs.json"; // The persisted LLM config lives here too (key `llmConfig`) so Settings
const DATA_DIR_KEY: &str = "dataDir"; // survive restarts. pub: commands modules read/write through crate::.
pub const PREFS_STORE: &str = "dm-pal-prefs.json";
pub const DATA_DIR_KEY: &str = "dataDir";
pub const CONFIG_KEY: &str = "llmConfig";
/// Resolve the data dir: the configured one if set and usable, else the /// Resolve the data dir: the configured one if set and usable, else the
/// default `$APPDATA/dm-toolkit`. /// default `$APPDATA/dm-toolkit`.
@@ -34,6 +37,15 @@ fn resolve_data_dir(app: &tauri::AppHandle) -> std::path::PathBuf {
default default
} }
/// Load the persisted LLM config (Settings). Falls back to defaults when
/// missing or unparsable — a future config-shape change never bricks startup;
/// the DM just re-enters Settings once.
fn load_llm_config(app: &tauri::AppHandle) -> llm::LlmConfig {
let Ok(store) = app.store(PREFS_STORE) else { return llm::LlmConfig::default() };
let Some(v) = store.get(CONFIG_KEY) else { return llm::LlmConfig::default() };
serde_json::from_value(v).unwrap_or_default()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -48,7 +60,7 @@ pub fn run() {
let gen = generations::GenerationStore::open(&data_dir) let gen = generations::GenerationStore::open(&data_dir)
.expect("open generations db"); .expect("open generations db");
app.manage(AppState { app.manage(AppState {
config: Mutex::new(llm::LlmConfig::default()), config: Mutex::new(load_llm_config(&app.handle())),
rag, rag,
gen, gen,
data_dir, data_dir,
@@ -61,6 +73,7 @@ pub fn run() {
commands::llm_commands::get_llm_config, commands::llm_commands::get_llm_config,
commands::llm_commands::set_llm_config, commands::llm_commands::set_llm_config,
commands::llm_commands::test_connection, commands::llm_commands::test_connection,
commands::llm_commands::pull_model,
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::image_commands::test_image_connection,
@@ -70,6 +83,7 @@ pub fn run() {
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::rag_commands::rag_add_directory,
commands::rag_commands::rag_reindex,
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,
+18 -2
View File
@@ -25,6 +25,12 @@ pub struct AppState {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig { pub struct LlmConfig {
/// API dialect: "ollama" (native /api/*) or "openai" (/v1/*).
/// Empty = sniff the URL (legacy configs + defaults). Provider presets
/// set it explicitly — a custom-port Ollama would otherwise be sniffed
/// as OpenAI and fail confusingly. serde default: old stored configs.
#[serde(default)]
pub provider: String,
pub api_url: String, pub api_url: String,
pub api_key: String, pub api_key: String,
pub model: String, pub model: String,
@@ -42,6 +48,7 @@ impl Default for LlmConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
// Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI // Default to Ollama local server; also works with LM Studio, llama.cpp server, or OpenAI
provider: String::new(),
api_url: "http://localhost:11434".to_string(), api_url: "http://localhost:11434".to_string(),
api_key: String::new(), api_key: String::new(),
model: "llama3.2".to_string(), model: "llama3.2".to_string(),
@@ -100,9 +107,18 @@ pub struct OllamaChatResponse {
pub done: bool, pub done: bool,
} }
// ─── Helper: detect if we're talking to Ollama ─────────────── // ─── Helper: are we talking Ollama? ───────────────────────
pub fn is_ollama(url: &str) -> bool { /// True when we should speak Ollama's native /api/* protocol. An explicit
/// provider on the config wins; empty falls back to URL sniffing so legacy
/// and default configs keep working.
pub fn is_ollama(provider: &str, url: &str) -> bool {
if provider.eq_ignore_ascii_case("ollama") {
return true;
}
if provider.eq_ignore_ascii_case("openai") {
return false;
}
url.contains("localhost:11434") || url.contains("127.0.0.1:11434") url.contains("localhost:11434") || url.contains("127.0.0.1:11434")
} }
+101 -21
View File
@@ -9,7 +9,13 @@ use std::sync::Mutex;
// that and scan time shows up in profiles. Avoids native extension loading. // that and scan time shows up in profiles. Avoids native extension loading.
/// One embedding vector, stored as little-endian f32 bytes. /// One embedding vector, stored as little-endian f32 bytes.
const MAX_CHUNK_CHARS: usize = 1000; /// Max chunk size in BYTES (chunk() splits on byte boundaries safely).
const MAX_CHUNK_BYTES: usize = 1000;
/// Legacy chunks indexed before the embed_model column existed. Unknown
/// model — assumed compatible so search keeps working; a one-click
/// Reindex stamps everything with the current model.
const EMBED_MODEL_UNKNOWN: &str = "";
pub struct RagStore { pub struct RagStore {
db: Mutex<Connection>, db: Mutex<Connection>,
@@ -27,13 +33,20 @@ impl RagStore {
let db = Connection::open(path)?; let db = Connection::open(path)?;
db.execute_batch( db.execute_batch(
"CREATE TABLE IF NOT EXISTS chunks ( "CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL, source TEXT NOT NULL,
text TEXT NOT NULL, text TEXT NOT NULL,
emb BLOB NOT NULL emb BLOB NOT NULL,
embed_model TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);", CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source);",
)?; )?;
// Migration for pre-embed_model DBs: add the column if it's missing.
// Duplicate-column error is expected on already-migrated DBs — ignore.
let _ = db.execute(
"ALTER TABLE chunks ADD COLUMN embed_model TEXT NOT NULL DEFAULT ''",
[],
);
Ok(Self { db: Mutex::new(db) }) Ok(Self { db: Mutex::new(db) })
} }
@@ -47,14 +60,20 @@ impl RagStore {
if para.is_empty() { if para.is_empty() {
continue; continue;
} }
if para.len() <= MAX_CHUNK_CHARS { if para.len() <= MAX_CHUNK_BYTES {
out.push(para.to_string()); out.push(para.to_string());
} else { } else {
// Hard-cap long paragraphs on char boundaries. // Hard-cap long paragraphs on char boundaries. A naive byte
for chunk in para.as_bytes().chunks(MAX_CHUNK_CHARS) { // split can land mid multibyte char — back the boundary off
if let Ok(s) = std::str::from_utf8(chunk) { // so accented/CJK text is never silently dropped.
out.push(s.trim().to_string()); let mut start = 0;
while start < para.len() {
let mut end = (start + MAX_CHUNK_BYTES).min(para.len());
while end < para.len() && !para.is_char_boundary(end) {
end -= 1;
} }
out.push(para[start..end].trim().to_string());
start = end;
} }
} }
} }
@@ -107,9 +126,13 @@ impl RagStore {
anyhow::bail!("embedding count mismatch"); anyhow::bail!("embedding count mismatch");
} }
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt = db.prepare("INSERT INTO chunks (source, text, emb) VALUES (?, ?, ?)")?; // Re-adding a source replaces it (no duplicate chunks skewing scores).
db.execute("DELETE FROM chunks WHERE source = ?", params![source])?;
let mut stmt = db.prepare(
"INSERT INTO chunks (source, text, emb, embed_model) VALUES (?, ?, ?, ?)",
)?;
for (text, emb) in chunks.iter().zip(embeddings.iter()) { for (text, emb) in chunks.iter().zip(embeddings.iter()) {
stmt.execute(params![source, text, Self::vec_to_blob(emb)])?; stmt.execute(params![source, text, Self::vec_to_blob(emb), config.embed_model])?;
} }
Ok(chunks.len()) Ok(chunks.len())
} }
@@ -128,14 +151,16 @@ impl RagStore {
.next() .next()
.ok_or_else(|| anyhow::anyhow!("no query embedding"))?; .ok_or_else(|| anyhow::anyhow!("no query embedding"))?;
let rows: Vec<(String, String, Vec<u8>)> = { let rows: Vec<(String, String, Vec<u8>, String)> = {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt = db.prepare("SELECT text, source, emb FROM chunks")?; let mut stmt =
db.prepare("SELECT text, source, emb, embed_model FROM chunks")?;
let rows = stmt.query_map([], |r| { let rows = stmt.query_map([], |r| {
Ok(( Ok((
r.get::<_, String>(0)?, r.get::<_, String>(0)?,
r.get::<_, String>(1)?, r.get::<_, String>(1)?,
r.get::<_, Vec<u8>>(2)?, r.get::<_, Vec<u8>>(2)?,
r.get::<_, String>(3)?,
)) ))
})?; })?;
rows.filter_map(|r| r.ok()).collect() rows.filter_map(|r| r.ok()).collect()
@@ -145,9 +170,16 @@ impl RagStore {
} }
// ponytail: naive O(n) scan. Fine to ~10k chunks; see module note. // ponytail: naive O(n) scan. Fine to ~10k chunks; see module note.
// Skip chunks embedded with a DIFFERENT model — their dot products
// against this query vector are garbage. '' rows are legacy/unknown
// (indexed before per-chunk stamping) and stay in play; Reindex
// re-stamps them.
let mut scored: Vec<(String, String, f32)> = rows let mut scored: Vec<(String, String, f32)> = rows
.into_iter() .into_iter()
.map(|(text, source, blob)| { .filter(|(_, _, _, model)| {
model.as_str() == EMBED_MODEL_UNKNOWN || model == &config.embed_model
})
.map(|(text, source, blob, _)| {
let v = Self::blob_to_vec(&blob); let v = Self::blob_to_vec(&blob);
let dot: f32 = v.iter().zip(q_emb.iter()).map(|(a, b)| a * b).sum(); let dot: f32 = v.iter().zip(q_emb.iter()).map(|(a, b)| a * b).sum();
(text, source, dot) (text, source, dot)
@@ -158,11 +190,15 @@ impl RagStore {
Ok(scored) Ok(scored)
} }
/// (source, chunk_count) for every distinct source. /// (source, chunk_count, embed_model) for every distinct source.
pub fn list_sources(&self) -> anyhow::Result<Vec<(String, i64)>> { pub fn list_sources(&self) -> anyhow::Result<Vec<(String, i64, String)>> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt = db.prepare("SELECT source, COUNT(*) FROM chunks GROUP BY source ORDER BY source")?; let mut stmt = db.prepare(
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?; "SELECT source, COUNT(*), MAX(embed_model) FROM chunks GROUP BY source ORDER BY source",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?))
})?;
let mut out = Vec::new(); let mut out = Vec::new();
for r in rows { for r in rows {
out.push(r?); out.push(r?);
@@ -170,6 +206,31 @@ impl RagStore {
Ok(out) Ok(out)
} }
/// Re-embed every source with the CURRENT embed model: for each source,
/// join its stored chunk texts and run them through add_document (which
/// replaces the old rows and stamps the model). Also the fix-it button
/// when the DM changes embedding models. Returns (sources, chunks).
pub async fn reindex(&self, config: &LlmConfig) -> anyhow::Result<(usize, usize)> {
let sources: Vec<String> = {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt = db.prepare("SELECT DISTINCT source FROM chunks ORDER BY source")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok()).collect()
};
let mut chunks = 0;
for s in &sources {
let texts: Vec<String> = {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
let mut stmt =
db.prepare("SELECT text FROM chunks WHERE source = ? ORDER BY id")?;
let rows = stmt.query_map(params![s], |r| r.get::<_, String>(0))?;
rows.filter_map(|r| r.ok()).collect()
};
chunks += self.add_document(config, &s, &texts.join("\n\n")).await?;
}
Ok((sources.len(), chunks))
}
/// Clear all chunks, or just one source if given. /// Clear all chunks, or just one source if given.
pub fn clear(&self, source: Option<&str>) -> anyhow::Result<()> { pub fn clear(&self, source: Option<&str>) -> anyhow::Result<()> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?; let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
@@ -211,7 +272,7 @@ mod tests {
#[test] #[test]
fn chunk_splits_paragraphs_and_caps_long_ones() { fn chunk_splits_paragraphs_and_caps_long_ones() {
let long = "a".repeat(MAX_CHUNK_CHARS * 2 + 50); let long = "a".repeat(MAX_CHUNK_BYTES * 2 + 50);
let text = format!("short para\n\n{long}\n\nanother"); let text = format!("short para\n\n{long}\n\nanother");
let chunks = RagStore::chunk(&text); let chunks = RagStore::chunk(&text);
// "short para", (2 or 3 long pieces), "another" // "short para", (2 or 3 long pieces), "another"
@@ -219,10 +280,29 @@ mod tests {
assert_eq!(chunks.first().unwrap(), "short para"); assert_eq!(chunks.first().unwrap(), "short para");
assert!(chunks.last().unwrap() == "another"); assert!(chunks.last().unwrap() == "another");
for c in &chunks { for c in &chunks {
assert!(c.len() <= MAX_CHUNK_CHARS); assert!(c.len() <= MAX_CHUNK_BYTES);
} }
} }
#[test]
fn chunk_never_drops_multibyte_text() {
// é is 2 bytes: a 1002-byte paragraph forces a split that used to land
// wherever the byte counter said — including mid-character, silently
// dropping the whole chunk. Every byte must survive now.
let para = format!("é{}", "a".repeat(MAX_CHUNK_BYTES));
let chunks = RagStore::chunk(&para);
let rejoined = chunks.concat();
assert_eq!(rejoined, para, "chunking must not drop multibyte text");
for c in &chunks {
assert!(c.len() <= MAX_CHUNK_BYTES);
}
// Force a mid-character boundary: 998 a's then 3-byte chars, so the
// 1000-byte cut lands inside the first 日.
let para2 = format!("{}{}", "a".repeat(MAX_CHUNK_BYTES - 2), "日日日");
let chunks2 = RagStore::chunk(&para2);
assert_eq!(chunks2.concat(), para2);
}
#[test] #[test]
fn blob_roundtrip() { fn blob_roundtrip() {
let v = vec![0.0, 1.5, -2.25, 3.33]; let v = vec![0.0, 1.5, -2.25, 3.33];
+1 -1
View File
@@ -23,7 +23,7 @@
} }
], ],
"security": { "security": {
"csp": null "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://localhost:5173"
} }
}, },
"bundle": { "bundle": {
+10 -6
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { usePersistentState } from "../lib/usePersistentState"; import { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
// ponytail: the calendar is user-configurable for homebrew campaigns. // ponytail: the calendar is user-configurable for homebrew campaigns.
// `months` carry their own day count so a custom calendar can have variable // `months` carry their own day count so a custom calendar can have variable
@@ -106,12 +107,15 @@ export function CalendarWidget() {
} }
function nextDay() { function nextDay() {
setToday((t) => { let { day, month, year } = today;
let { day, month, year } = t; day += 1;
day += 1; if (day > daysInMonth(config, month)) { day = 1; month += 1; }
if (day > daysInMonth(config, month)) { day = 1; month += 1; } if (month >= config.months.length) { month = 0; year += 1; }
if (month >= config.months.length) { month = 0; year += 1; } setToday({ day, month, year });
return { day, month, year }; // ponytail: the campaign clock ticks — the session log hears about it.
const label = config.months[month]?.name ?? String(month + 1);
bus.emit(Events.LogEntry, {
text: `🗓 Day advanced: ${day} ${label} ${config.yearLabel} ${year} — ${weatherFor(day, month, year)}`,
}); });
} }
+72
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { usePersistentState } from "../lib/usePersistentState"; import { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice"; import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
@@ -23,12 +24,22 @@ function rollDie(sides: number): number {
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult // with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
// types are imported from there too. // types are imported from there too.
interface Macro {
label: string;
notation: string;
}
export function DiceRoller() { export function DiceRoller() {
const [input, setInput] = usePersistentState<string>("dice.input", "1d20"); const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []); const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
const [lastTotal, setLastTotal] = useState<number | null>(null); const [lastTotal, setLastTotal] = useState<number | null>(null);
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null); const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal"); const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal");
// ponytail: macros = the DM's saved attack/damage rolls. Persisted; the
// notation is snapshot at save time ("Grimjaw attack" → "1d20+5"), not a
// live reference to the input box.
const [macros, setMacros] = usePersistentState<Macro[]>("dice.macros", []);
const [macroLabel, setMacroLabel] = useState("");
const { addToast } = useToast(); const { addToast } = useToast();
const roll = useCallback( const roll = useCallback(
@@ -58,6 +69,12 @@ export function DiceRoller() {
setResults((prev) => [result, ...prev].slice(0, 50)); setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum); setLastTotal(sum);
// ponytail: every roll also lands in the active session log —
// SessionLogger owns the mute toggle, we just report what happened.
bus.emit(Events.LogEntry, {
text: `🎲 ${result.notation} → ${sum} [${rolls.join(", ")}]`,
tag: "#misc",
});
setLastBreakdown(result); setLastBreakdown(result);
}, },
[input, mode], [input, mode],
@@ -113,6 +130,18 @@ export function DiceRoller() {
}); });
} }
function addMacro() {
const label = macroLabel.trim();
const notation = input.trim();
if (!label || !parseNotation(notation)) {
addToast("Type a name and a valid notation first", "info");
return;
}
setMacros((prev) => [...prev, { label, notation }]);
setMacroLabel("");
addToast(`Macro "${label}" saved (${notation})`, "success");
}
return ( return (
<div className="flex flex-col gap-3 h-full"> <div className="flex flex-col gap-3 h-full">
{/* Big result display */} {/* Big result display */}
@@ -202,6 +231,49 @@ export function DiceRoller() {
})} })}
</div> </div>
{/* Macros — the DM's named rolls. Same relabel path as templates. */}
{macros.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-[var(--color-text-dim)] text-[10px] uppercase tracking-wider">Macros</span>
{macros.map((m, i) => (
<span key={i} className="flex items-center rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-border-glass)] overflow-hidden">
<button
onClick={() => applyTemplate(m)}
className="px-2.5 py-1 text-xs text-[var(--color-gold-bright)] hover:bg-[var(--color-bg-card)] transition-colors cursor-pointer"
title={`${m.label} — ${m.notation}`}
>
{m.label}
</button>
<button
onClick={() => setMacros((prev) => prev.filter((_, j) => j !== i))}
className="px-1.5 py-1 text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
title="Delete macro"
aria-label={`Delete macro ${m.label}`}
>
×
</button>
</span>
))}
</div>
)}
<div className="flex gap-1.5">
<input
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-2.5 py-1 text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={macroLabel}
onChange={(e) => setMacroLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addMacro()}
placeholder={`save "${input}" as macro… (type a name)`}
aria-label="Name for a new dice macro"
/>
<button
onClick={addMacro}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2.5 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
title={`Save ${input} as a named roll`}
>
+ macro
</button>
</div>
{/* History */} {/* History */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
+6
View File
@@ -205,6 +205,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
system: "You are a D&D encounter designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", system: "You are a D&D encounter designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.9, temperature: 0.9,
max_tokens: 400, max_tokens: 400,
ragQuery: `${terrain} monsters encounter`,
}, },
}); });
const parsed = parseLlmJson<Encounter>(result); const parsed = parseLlmJson<Encounter>(result);
@@ -219,6 +220,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`Encounter: ${parsed.difficulty || "?"} in ${terrain}`, `Encounter: ${parsed.difficulty || "?"} in ${terrain}`,
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`, `Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
); );
// ponytail: the fight starts — tell the session log.
bus.emit(Events.LogEntry, {
text: `⚔ Encounter: ${title} (${flattenValue(parsed.difficulty) || "?"}) — ${monsters}`,
tag: "#combat",
});
} else { } else {
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
+102 -21
View File
@@ -1,13 +1,26 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke, Channel } from "@tauri-apps/api/core";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
// ponytail: mirrors the Rust PullEvent enum (adjacently tagged, camelCase).
type PullEvent =
| { type: "progress"; data: { completed: number | null; total: number | null } }
| { type: "status"; data: string }
| { type: "done" }
| { type: "error"; data: string };
export function FirstRunWizard({ onComplete }: { onComplete: () => void }) { export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState("welcome"); const [step, setStep] = useState("welcome");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [models, setModels] = useState<string[]>([]); const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
// ponytail: custom tag not in the installed list — pulled in-wizard with a
// progress bar instead of the old "run ollama pull yourself" dead end.
const [customModel, setCustomModel] = useState("");
const [pulling, setPulling] = useState(false);
const [pullPct, setPullPct] = useState<number | null>(null);
const [pullStatus, setPullStatus] = useState("");
// ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote). // ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote).
const apiUrl = "http://localhost:11434"; const apiUrl = "http://localhost:11434";
const apiKey = ""; const apiKey = "";
@@ -26,7 +39,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
// Test connection to the default Ollama URL // Test connection to the default Ollama URL
const result = await invoke<{ ok: boolean; models: string[]; error: string }>( const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
"test_connection", "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: "" } } { config: { provider: "ollama", 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) { if (result.ok) {
setModels(result.models); setModels(result.models);
@@ -42,19 +55,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
setLoading(false); setLoading(false);
} }
async function pullModel(model: string) { // ponytail: save the chosen model and finish — no pull needed when it's
// already installed.
async function saveModelAndFinish(model: string) {
setLoading(true); setLoading(true);
setError(""); setError("");
try { 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", { await invoke("set_llm_config", {
config: { config: {
provider: "ollama",
api_url: apiUrl, api_url: apiUrl,
api_key: apiKey, api_key: apiKey,
model: model, model,
temperature: 0.7, temperature: 0.7,
max_tokens: 512, max_tokens: 512,
top_p: 0.9, top_p: 0.9,
@@ -62,7 +74,6 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
embed_model: "nomic-embed-text", embed_model: "nomic-embed-text",
} }
}); });
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
setStep("done"); setStep("done");
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
@@ -71,6 +82,42 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
setLoading(false); setLoading(false);
} }
// Real `ollama pull` over the Channel — progress bar driven by the
// completed/total bytes Ollama streams; falls back to the status line.
async function pullAndFinish(model: string) {
setPulling(true);
setError("");
setPullPct(null);
setPullStatus("starting pull…");
const channel = new Channel<PullEvent>();
let failed = false;
channel.onmessage = (ev) => {
if (ev.type === "progress") {
setPullStatus("");
setPullPct(ev.data.completed != null && ev.data.total ? ev.data.completed / ev.data.total : null);
} else if (ev.type === "status") {
setPullStatus(ev.data);
setPullPct(null);
} else if (ev.type === "done") {
addToast(`Model ${model} pulled`, "success");
saveModelAndFinish(model);
setPulling(false);
} else if (ev.type === "error") {
failed = true;
setError(`Pull failed: ${ev.data}`);
setPulling(false);
}
};
try {
await invoke("pull_model", { req: { model }, channel });
} catch (e) {
if (!failed) {
setError(`Pull failed: ${e}`);
setPulling(false);
}
}
}
function skip() { function skip() {
// Skip wizard and go to settings // Skip wizard and go to settings
onComplete(); onComplete();
@@ -92,11 +139,17 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
return; return;
} }
if (step === "model") { if (step === "model") {
if (!selectedModel) { const model = customModel.trim() || selectedModel;
setError("Please select a model"); if (!model) {
setError("Choose a model or type a tag to pull");
return; return;
} }
await pullModel(selectedModel); // Installed → save directly; anything else gets pulled first.
if (models.includes(model)) {
await saveModelAndFinish(model);
} else {
await pullAndFinish(model);
}
return; return;
} }
if (step === "done") { if (step === "done") {
@@ -192,7 +245,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Model</label> <label className="text-[var(--color-text-secondary)] text-xs font-medium">Model</label>
<select <select
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => { setSelectedModel(e.target.value); setCustomModel(""); }}
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" 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) => ( {models.map((m) => (
@@ -203,13 +256,37 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
))} ))}
</select> </select>
</div> </div>
<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">Or type any Ollama tag to pull</label>
<input
value={customModel}
onChange={(e) => { setCustomModel(e.target.value); setSelectedModel(""); }}
placeholder="e.g. llama3.2"
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
/>
</div>
</div>
{pulling ? (
<div className="flex flex-col gap-2 mt-4">
<p className="text-[var(--color-text-secondary)] text-xs">Pulling {customModel.trim() || selectedModel} …</p>
<div className="w-full h-2 rounded-full bg-[var(--color-bg-surface)] overflow-hidden">
<div
className="h-full bg-[var(--color-gold-bright)] transition-all duration-300"
style={{ width: pullPct != null ? `${Math.round(pullPct * 100)}%` : "100%" }}
/>
</div>
<p className="text-[var(--color-text-dim)] text-xs">
{pullPct != null ? `${Math.round(pullPct * 100)}%` : pullStatus}
</p>
</div>
) : (
<div className="flex flex-col gap-3 mt-4">
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={loading || !selectedModel} disabled={loading || (!customModel.trim() && !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" 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"} {loading ? "Setting model…" : "Use Model"}
</button> </button>
<button <button
onClick={back} onClick={back}
@@ -224,14 +301,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
Skip for now (go to Settings) Skip for now (go to Settings)
</button> </button>
</div> </div>
</div> )}
{error && ( {error && (
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p> <p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
)} )}
<p className="text-[var(--color-text-dim)] text-sm mt-4"> {!pulling && (
If you don't see your model, you may need to download it first. In a terminal, run:{' '} <p className="text-[var(--color-text-dim)] text-xs mt-4">
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code> Models not in the list are pulled automatically — type any Ollama tag
</p> (e.g. <code className="font-mono text-[var(--color-gold-bright)]">llama3.2</code>, or
<code className="font-mono text-[var(--color-gold-bright)]">nomic-embed-text</code> for
lore search) and DM-Pal downloads it with a progress bar.
</p>
)}
</div> </div>
); );
} }
+32 -2
View File
@@ -157,21 +157,50 @@ export function InitiativeTracker() {
); );
}, []); }, []);
// ponytail: conditions written as "Name (N)" auto-expire: the number
// decrements each time that combatant's turn starts, and the condition
// drops at zero. Plain conditions run until toggled off manually.
function tickDurations(id: string) {
setCombatants((prev) =>
prev.map((c) => {
if (c.id !== id) return c;
const next: string[] = [];
for (const cn of c.conditions) {
const m = cn.match(/^(.*?)\s*\((\d+)\)$/);
if (!m) {
next.push(cn);
continue;
}
const n = parseInt(m[2], 10);
if (n > 1) next.push(`${m[1]} (${n - 1})`);
// n === 1 → expired, dropped
}
return { ...c, conditions: next };
}),
);
}
const nextTurn = useCallback(() => { const nextTurn = useCallback(() => {
if (combatants.length === 0) return; if (combatants.length === 0) return;
if (!activeId) { if (!activeId) {
setActiveId(combatants[0].id); setActiveId(combatants[0].id);
tickDurations(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Combat starts — ${combatants[0].name}`, tag: "#combat" });
return; return;
} }
const idx = combatants.findIndex((c) => c.id === activeId); const idx = combatants.findIndex((c) => c.id === activeId);
if (idx === combatants.length - 1) { if (idx === combatants.length - 1) {
setRound((r) => r + 1); setRound((r) => r + 1);
setActiveId(combatants[0].id); setActiveId(combatants[0].id);
tickDurations(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Round ${round + 1} — ${combatants[0].name}'s turn`, tag: "#combat" });
} else { } else {
setActiveId(combatants[idx + 1].id); setActiveId(combatants[idx + 1].id);
tickDurations(combatants[idx + 1].id);
bus.emit(Events.LogEntry, { text: `⚔ ${combatants[idx + 1].name}'s turn`, tag: "#combat" });
} }
setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer. setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer.
}, [combatants, activeId]); }, [combatants, activeId, round]);
// ponytail: tick the timer once per second while armed. Stopping at 0 lets // ponytail: tick the timer once per second while armed. Stopping at 0 lets
// the red zero sit until the DM advances (don't auto-advance — that would // the red zero sit until the DM advances (don't auto-advance — that would
@@ -510,7 +539,8 @@ export function InitiativeTracker() {
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
<input <input
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none" className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none"
placeholder="Custom condition…" placeholder="Custom condition… e.g. Hexed (3)"
title="A number in (rounds) makes the condition auto-expire: it ticks down each turn and drops at zero."
value={condInput[c.id] ?? ""} value={condInput[c.id] ?? ""}
onChange={(e) => setCondInput((p) => ({ ...p, [c.id]: e.target.value }))} onChange={(e) => setCondInput((p) => ({ ...p, [c.id]: e.target.value }))}
onKeyDown={(e) => { onKeyDown={(e) => {
+37
View File
@@ -8,6 +8,7 @@ import { addToLore } from "../lib/lore";
interface RagSource { interface RagSource {
source: string; source: string;
chunks: number; chunks: number;
model: string;
} }
interface RagHit { interface RagHit {
@@ -39,8 +40,15 @@ export function LorePanel() {
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
const [chunks, setChunks] = useState<RagChunk[]>([]); const [chunks, setChunks] = useState<RagChunk[]>([]);
const [loadingChunks, setLoadingChunks] = useState(false); const [loadingChunks, setLoadingChunks] = useState(false);
// ponytail: current embed model, to flag sources indexed with a different
// one (search skips those — garbage cosine scores). "" = still loading.
const [embedModel, setEmbedModel] = useState("");
const [reindexing, setReindexing] = useState(false);
const { addToast } = useToast(); const { addToast } = useToast();
const stale =
embedModel !== "" && sources.some((s) => s.model !== "" && s.model !== embedModel);
async function loadSources() { async function loadSources() {
try { try {
setSources(await invoke<RagSource[]>("rag_list")); setSources(await invoke<RagSource[]>("rag_list"));
@@ -51,6 +59,9 @@ export function LorePanel() {
useEffect(() => { useEffect(() => {
loadSources(); loadSources();
invoke<{ embed_model: string }>("get_llm_config")
.then((c) => setEmbedModel(c.embed_model))
.catch(() => {});
}, []); }, []);
async function loadChunks(s: string) { async function loadChunks(s: string) {
@@ -101,6 +112,18 @@ export function LorePanel() {
loadSources(); loadSources();
} }
async function reindex() {
setReindexing(true);
try {
const r = await invoke<{ sources: number; chunks: number }>("rag_reindex");
addToast(`Reindexed ${r.sources} sources · ${r.chunks} chunks`, "success");
await loadSources();
} catch (e) {
addToast(`Reindex failed: ${e}`, "error");
}
setReindexing(false);
}
// ponytail: file upload via native HTML input — no tauri-plugin-dialog // ponytail: file upload via native HTML input — no tauri-plugin-dialog
// needed. Reads .md/.txt contents in the webview and indexes each file as // needed. Reads .md/.txt contents in the webview and indexes each file as
// its own lore source. Multiple files supported. // its own lore source. Multiple files supported.
@@ -214,6 +237,20 @@ export function LorePanel() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3> <h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
{/* ponytail: embed-model mismatch banner — search silently skips
these chunks, so surface it with the one-click fix. */}
{stale && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-[var(--color-gold-bright)]">Indexed with an old embedding model</span>
<button
onClick={reindex}
disabled={reindexing}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-[10px] font-semibold cursor-pointer disabled:opacity-50"
>
{reindexing ? "Reindexing…" : "Reindex all"}
</button>
</div>
)}
{sources.length > 0 && ( {sources.length > 0 && (
confirmClear ? ( confirmClear ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
+20
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { GeneratedImage } from "./GeneratedImage"; import { GeneratedImage } from "./GeneratedImage";
import { addToLore } from "../lib/lore"; import { addToLore } from "../lib/lore";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill"; import { usePrefillEffect } from "../lib/usePrefill";
@@ -71,6 +72,18 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
if (!npc) return; if (!npc) return;
setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) }); setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) });
} }
// ponytail: one click from the NPC sheet to the combat tracker — the
// generated stat block already has HP; Initiative rolls on arrival.
function addToTracker() {
if (!npc) return;
const label = name || `${race} ${background}`;
bus.emit(Events.AddCombatants, {
combatants: [{ name: label, hp: npc.stats?.hp ?? 10 }],
source: "NPC Generator",
});
addToast(`${label} added to the Initiative Tracker`, "success");
}
function addGoal() { function addGoal() {
if (!npc || !newGoal.trim()) return; if (!npc || !newGoal.trim()) return;
setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] }); setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] });
@@ -204,6 +217,13 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
> >
✨ new portrait ✨ new portrait
</button> </button>
<button
onClick={addToTracker}
className="mt-0.5 w-full text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
title="Send this NPC to the Initiative Tracker"
>
⚔ to initiative
</button>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm"> <h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
+1
View File
@@ -76,6 +76,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85, temperature: 0.85,
max_tokens: 800, max_tokens: 800,
ragQuery: `${theme} quest ${level}`,
}, },
}); });
const parsed = parseLlmJson<QuestResponse>(result); const parsed = parseLlmJson<QuestResponse>(result);
+31
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { invoke, Channel } from "@tauri-apps/api/core"; import { invoke, Channel } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore"; import { addToLore } from "../lib/lore";
import { bus, Events, type LogEntryPayload } from "../lib/bus";
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";
@@ -45,6 +46,9 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
const [filterTag, setFilterTag] = useState<string | null>(null); const [filterTag, setFilterTag] = useState<string | null>(null);
const [previewSummary, setPreviewSummary] = useState(true); const [previewSummary, setPreviewSummary] = useState(true);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
// ponytail: auto-capture dice rolls / initiative turns / encounters into the
// active session — the raw material for a useful AI summary. Muteable.
const [autoLog, setAutoLog] = usePersistentState<boolean>("session.autolog", true);
const { addToast } = useToast(); const { addToast } = useToast();
// ponytail: ensure an active session exists (first run / migration). // ponytail: ensure an active session exists (first run / migration).
@@ -90,6 +94,21 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
updateActive((s) => ({ ...s, entries: s.entries.filter((_, j) => j !== i) })); updateActive((s) => ({ ...s, entries: s.entries.filter((_, j) => j !== i) }));
} }
// ponytail: listener re-registers when the active session or the mute
// toggle changes — no stale closures over activeId.
useEffect(() => {
return bus.on<LogEntryPayload>(Events.LogEntry, ({ text, tag }) => {
if (!autoLog) return;
setSessions((prev) =>
prev.map((s) =>
s.id === activeId
? { ...s, entries: [{ text, time: new Date().toLocaleString(), tag }, ...s.entries] }
: s,
),
);
});
}, [activeId, autoLog, setSessions]);
function addSession() { function addSession() {
const s = newSession(`Session ${sessions.length + 1}`); const s = newSession(`Session ${sessions.length + 1}`);
setSessions((prev) => [...prev, s]); setSessions((prev) => [...prev, s]);
@@ -152,6 +171,7 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
system: "You are a helpful D&D session summarizer. Be concise and creative.", system: "You are a helpful D&D session summarizer. Be concise and creative.",
temperature: 0.7, temperature: 0.7,
max_tokens: 512, max_tokens: 512,
ragQuery: active.title,
}, },
channel, channel,
}); });
@@ -251,6 +271,17 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
> >
Add Note Add Note
</button> </button>
<button
onClick={() => setAutoLog((v) => !v)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors cursor-pointer ${
autoLog
? "border-[var(--color-gold-bright)] text-[var(--color-gold-bright)] bg-[var(--color-gold-glow)]"
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
title="Auto-capture dice rolls, initiative turns, encounters and calendar days into this session"
>
{autoLog ? "● capturing" : "○ paused"}
</button>
<button <button
onClick={summarize} onClick={summarize}
disabled={loading || active.entries.length === 0} disabled={loading || active.entries.length === 0}
+12 -8
View File
@@ -4,6 +4,8 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
interface LlmConfig { interface LlmConfig {
/** "ollama" | "openai" — empty = let the backend sniff the URL. */
provider?: string;
api_url: string; api_url: string;
api_key: string; api_key: string;
model: string; model: string;
@@ -174,16 +176,17 @@ export function SettingsPanel() {
setImgTesting(false); 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 }[] = [ // + the API dialect (provider), so a custom-port Ollama isn't sniffed wrong.
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" }, const PRESETS: { label: string; url: string; model: string; provider: string; key?: boolean }[] = [
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" }, { label: "Ollama", url: "http://localhost:11434", model: "llama3.2", provider: "ollama" },
{ label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true }, { label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model", provider: "openai" },
{ label: "Custom", url: "", model: "" }, { label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", provider: "openai", key: true },
{ label: "Custom", url: "", model: "", provider: "" },
]; ];
function applyPreset(p: { url: string; model: string }) { function applyPreset(p: { url: string; model: string; provider: string }) {
if (!config) return; if (!config) return;
setConfig({ ...config, api_url: p.url, model: p.model }); setConfig({ ...config, api_url: p.url, model: p.model, provider: p.provider });
setConn(null); setConn(null);
} }
@@ -192,6 +195,7 @@ export function SettingsPanel() {
// backend re-default on save — but we can't send a partial, so mirror the // backend re-default on save — but we can't send a partial, so mirror the
// known defaults here and toast. // known defaults here and toast.
const DEFAULTS: LlmConfig = { const DEFAULTS: LlmConfig = {
provider: "",
api_url: "http://localhost:11434", api_url: "http://localhost:11434",
api_key: "", api_key: "",
model: "llama3.2", model: "llama3.2",
+10
View File
@@ -44,6 +44,7 @@ export const bus = new EventBus();
export const Events = { export const Events = {
AddCombatants: "add-combatants", AddCombatants: "add-combatants",
PlayAmbient: "play-ambient", PlayAmbient: "play-ambient",
LogEntry: "log-entry",
} as const; } as const;
export type AddCombatantsPayload = { export type AddCombatantsPayload = {
@@ -52,6 +53,15 @@ export type AddCombatantsPayload = {
source: string; source: string;
}; };
/** One auto-captured line for the active session log (dice rolls, initiative
* turns, encounters, calendar days). SessionLogger owns the mute toggle. */
export type LogEntryPayload = {
/** Pre-formatted line, e.g. "🎲 2d6+3 → 11 [4,5]". */
text: string;
/** One of SessionLogger's TAGS; omit for untagged. */
tag?: string;
};
export type PlayAmbientPayload = { export type PlayAmbientPayload = {
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */ /** Ambient sound id from the Soundboard (rain, wind, fire, …). */
id: string; id: string;