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
- **Quest Designer** — multi-step quests with twists and reward breakdown
- **Item Forge** — magic items with art and structured mechanics
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama)
- **Image Generator** — portraits, maps, scene art (cross-platform, via stable-diffusion.cpp)
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
- **Soundboard** — synthesized ambience/SFX with one-click scenes
- **World Builder** — generated regions, landmarks, a draggable-pin map
@@ -34,7 +34,7 @@ persistent state round it out — reload loses nothing.
### Prerequisites
- **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`)
### Install & run
@@ -66,7 +66,8 @@ All campaign data stays on disk under your OS app-data dir (default
| 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/… |
| `images/` | Cached generated PNGs, keyed by prompt hash |
| `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.)
- **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.
> **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.
---
@@ -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 |
| **GPU offloading** | Expose `n_gpu_layers` slider per model |
| **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 |
| **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.
Highest-ROI 20-minute task in the repo.
- [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`.
- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet`
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",
"build": "tsc -b && vite build",
"lint": "oxlint",
"check": "node scripts/check-dice.ts && node scripts/check-encounter-budget.ts && node scripts/check-worldmap.ts",
"preview": "vite preview",
"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
# (Sign-In and Security -> App-Specific Passwords). NOT your normal password.
# 4. APPLE_TEAM_ID: 10-char Team ID from https://developer.apple.com/account#MembershipDetailsCard
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (4759TW4SDC)"
export APPLE_ID="james.twose2711@gmail.com"
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (XXXXXXXXXX)"
export APPLE_ID="you@example.com"
export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="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);
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"
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
git commit -m "chore: release $TAG" -q
echo "committed version bump"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dm-pal"
version = "0.1.0"
version = "0.1.3"
description = "AI-Powered Dungeon Master Toolkit"
authors = ["DM-Pal Team"]
license = ""
+2 -5
View File
@@ -3,11 +3,8 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_store::StoreExt;
// ponytail: the data-dir pref store + key are defined in lib.rs; mirror them
// here so this module is self-contained for reads/writes.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
use crate::{DATA_DIR_KEY, PREFS_STORE};
// PREFS_STORE / DATA_DIR_KEY live in lib.rs (pub) — single source of truth.
#[derive(Debug, Serialize)]
pub struct DataDirInfo {
+302 -13
View File
@@ -1,8 +1,10 @@
use crate::commands::emit_busy;
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::ipc::Channel;
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
// ─── 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.
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
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
@@ -62,17 +64,16 @@ pub async fn generate_stream(
emit_busy(&app, true);
tauri::async_runtime::spawn(async move {
// For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later
let result = if llm::is_ollama(&config.api_url) {
call_ollama(&client, &config, &messages, temperature, max_tokens).await
// Real token streaming: Ollama NDJSON / OpenAI SSE, one LlmEvent::Token
// per piece as it arrives. Falls back to Done carrying the full text.
let result = if llm::is_ollama(&config.provider, &config.api_url) {
call_ollama_stream(&client, &config, &messages, temperature, max_tokens, &channel).await
} else {
call_openai(&client, &config, &messages, temperature, max_tokens).await
call_openai_stream(&client, &config, &messages, temperature, max_tokens, &channel).await
};
match result {
Ok(text) => {
let _ = channel.send(LlmEvent::Token(text.clone()));
let _ = channel.send(LlmEvent::Done(text));
}
Err(e) => {
@@ -86,6 +87,91 @@ pub async fn generate_stream(
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 ────────────────────────────────────
#[tauri::command]
@@ -95,9 +181,18 @@ pub fn get_llm_config(state: tauri::State<'_, AppState>) -> Result<crate::llm::L
}
#[tauri::command]
pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::LlmConfig) -> Result<(), String> {
let mut lock = state.config.lock().map_err(|e| e.to_string())?;
*lock = config;
pub fn set_llm_config(
app: AppHandle,
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(())
}
@@ -113,14 +208,22 @@ pub struct ConnectionTest {
}
#[tauri::command]
pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result<ConnectionTest, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
pub async fn test_connection(
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()
.timeout(std::time::Duration::from_secs(8))
.build()
.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 res = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() {
@@ -287,3 +390,189 @@ async fn call_openai(
.map(|c| c.message.content.clone())
.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 source: String,
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]
@@ -48,10 +57,20 @@ pub fn rag_list(state: tauri::State<'_, AppState>) -> Result<Vec<RagSource>, Str
.list_sources()
.map_err(|e| e.to_string())?
.into_iter()
.map(|(source, chunks)| Ok(RagSource { source, chunks }))
.map(|(source, chunks, model)| Ok(RagSource { source, chunks, model }))
.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]
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())
+6 -4
View File
@@ -159,15 +159,17 @@ impl GenerationStore {
mod tests {
use super::*;
fn tmp() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("dm-pal-test-{}", std::process::id()));
// ponytail: unique dir per test — both tests previously shared one
// 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);
dir
}
#[test]
fn round_trip() {
let dir = tmp();
let dir = tmp("round-trip");
let store = GenerationStore::open(&dir).unwrap();
let id = store
.add("npc", "Thorin Stonefist", r#"{"name":"Thorin"}"#, Some("Dwarf Fighter"))
@@ -196,7 +198,7 @@ mod tests {
#[test]
fn clear_all() {
let dir = tmp();
let dir = tmp("clear-all");
let store = GenerationStore::open(&dir).unwrap();
store.add("a", "x", "{}", 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
// app_data_dir so it's always discoverable at startup, even before we know the
// custom location. Key: `dataDir` (absolute path). Empty/missing → default.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
// The persisted LLM config lives here too (key `llmConfig`) so Settings
// 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
/// default `$APPDATA/dm-toolkit`.
@@ -34,6 +37,15 @@ fn resolve_data_dir(app: &tauri::AppHandle) -> std::path::PathBuf {
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)]
pub fn run() {
tauri::Builder::default()
@@ -48,7 +60,7 @@ pub fn run() {
let gen = generations::GenerationStore::open(&data_dir)
.expect("open generations db");
app.manage(AppState {
config: Mutex::new(llm::LlmConfig::default()),
config: Mutex::new(load_llm_config(&app.handle())),
rag,
gen,
data_dir,
@@ -61,6 +73,7 @@ pub fn run() {
commands::llm_commands::get_llm_config,
commands::llm_commands::set_llm_config,
commands::llm_commands::test_connection,
commands::llm_commands::pull_model,
commands::image_commands::generate_image,
commands::image_commands::generate_image_stream,
commands::image_commands::test_image_connection,
@@ -70,6 +83,7 @@ pub fn run() {
commands::rag_commands::rag_clear,
commands::rag_commands::rag_chunks,
commands::rag_commands::rag_add_directory,
commands::rag_commands::rag_reindex,
commands::generation_commands::generation_add,
commands::generation_commands::generation_list,
commands::generation_commands::generation_get,
+18 -2
View File
@@ -25,6 +25,12 @@ pub struct AppState {
#[derive(Debug, Clone, Serialize, Deserialize)]
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_key: String,
pub model: String,
@@ -42,6 +48,7 @@ impl Default for LlmConfig {
fn default() -> Self {
Self {
// 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_key: String::new(),
model: "llama3.2".to_string(),
@@ -100,9 +107,18 @@ pub struct OllamaChatResponse {
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")
}
+98 -18
View File
@@ -9,7 +9,13 @@ use std::sync::Mutex;
// that and scan time shows up in profiles. Avoids native extension loading.
/// 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 {
db: Mutex<Connection>,
@@ -30,10 +36,17 @@ impl RagStore {
id INTEGER PRIMARY KEY AUTOINCREMENT,
source 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);",
)?;
// 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) })
}
@@ -47,14 +60,20 @@ impl RagStore {
if para.is_empty() {
continue;
}
if para.len() <= MAX_CHUNK_CHARS {
if para.len() <= MAX_CHUNK_BYTES {
out.push(para.to_string());
} else {
// Hard-cap long paragraphs on char boundaries.
for chunk in para.as_bytes().chunks(MAX_CHUNK_CHARS) {
if let Ok(s) = std::str::from_utf8(chunk) {
out.push(s.trim().to_string());
// Hard-cap long paragraphs on char boundaries. A naive byte
// split can land mid multibyte char — back the boundary off
// so accented/CJK text is never silently dropped.
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");
}
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()) {
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())
}
@@ -128,14 +151,16 @@ impl RagStore {
.next()
.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 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| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Vec<u8>>(2)?,
r.get::<_, String>(3)?,
))
})?;
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.
// 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
.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 dot: f32 = v.iter().zip(q_emb.iter()).map(|(a, b)| a * b).sum();
(text, source, dot)
@@ -158,11 +190,15 @@ impl RagStore {
Ok(scored)
}
/// (source, chunk_count) for every distinct source.
pub fn list_sources(&self) -> anyhow::Result<Vec<(String, i64)>> {
/// (source, chunk_count, embed_model) for every distinct source.
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 mut stmt = db.prepare("SELECT source, COUNT(*) FROM chunks GROUP BY source ORDER BY source")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
let mut stmt = db.prepare(
"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();
for r in rows {
out.push(r?);
@@ -170,6 +206,31 @@ impl RagStore {
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.
pub fn clear(&self, source: Option<&str>) -> anyhow::Result<()> {
let db = self.db.lock().map_err(|e| anyhow::anyhow!("db lock: {e}"))?;
@@ -211,7 +272,7 @@ mod tests {
#[test]
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 chunks = RagStore::chunk(&text);
// "short para", (2 or 3 long pieces), "another"
@@ -219,10 +280,29 @@ mod tests {
assert_eq!(chunks.first().unwrap(), "short para");
assert!(chunks.last().unwrap() == "another");
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]
fn blob_roundtrip() {
let v = vec![0.0, 1.5, -2.25, 3.33];
+1 -1
View File
@@ -23,7 +23,7 @@
}
],
"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": {
+7 -3
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from "react";
import { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
// ponytail: the calendar is user-configurable for homebrew campaigns.
// `months` carry their own day count so a custom calendar can have variable
@@ -106,12 +107,15 @@ export function CalendarWidget() {
}
function nextDay() {
setToday((t) => {
let { day, month, year } = t;
let { day, month, year } = today;
day += 1;
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
if (month >= config.months.length) { month = 0; year += 1; }
return { day, month, year };
setToday({ 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 { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast";
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
// types are imported from there too.
interface Macro {
label: string;
notation: string;
}
export function DiceRoller() {
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
const [lastTotal, setLastTotal] = useState<number | null>(null);
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
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 roll = useCallback(
@@ -58,6 +69,12 @@ export function DiceRoller() {
setResults((prev) => [result, ...prev].slice(0, 50));
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);
},
[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 (
<div className="flex flex-col gap-3 h-full">
{/* Big result display */}
@@ -202,6 +231,49 @@ export function DiceRoller() {
})}
</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 */}
<div className="flex-1 overflow-y-auto">
<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.",
temperature: 0.9,
max_tokens: 400,
ragQuery: `${terrain} monsters encounter`,
},
});
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 (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 {
addToast("LLM returned an unparseable response", "error");
}
+101 -20
View File
@@ -1,13 +1,26 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { invoke, Channel } from "@tauri-apps/api/core";
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 }) {
const [step, setStep] = useState("welcome");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [models, setModels] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
// ponytail: 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).
const apiUrl = "http://localhost:11434";
const apiKey = "";
@@ -26,7 +39,7 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
// Test connection to the default Ollama URL
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
"test_connection",
{ config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } }
{ 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) {
setModels(result.models);
@@ -42,19 +55,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
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);
setError("");
try {
// We don't have a direct pull command, but we can suggest the user to run `ollama pull` in terminal.
// For now, we'll just set the model in config and hope it exists.
// Alternatively, we could invoke a generate command to trigger a pull? Not sure.
// We'll just set the config and complete.
await invoke("set_llm_config", {
config: {
provider: "ollama",
api_url: apiUrl,
api_key: apiKey,
model: model,
model,
temperature: 0.7,
max_tokens: 512,
top_p: 0.9,
@@ -62,7 +74,6 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
embed_model: "nomic-embed-text",
}
});
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
setStep("done");
} catch (e) {
setError(String(e));
@@ -71,6 +82,42 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
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() {
// Skip wizard and go to settings
onComplete();
@@ -92,11 +139,17 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
return;
}
if (step === "model") {
if (!selectedModel) {
setError("Please select a model");
const model = customModel.trim() || selectedModel;
if (!model) {
setError("Choose a model or type a tag to pull");
return;
}
await pullModel(selectedModel);
// Installed → save directly; anything else gets pulled first.
if (models.includes(model)) {
await saveModelAndFinish(model);
} else {
await pullAndFinish(model);
}
return;
}
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>
<select
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"
>
{models.map((m) => (
@@ -203,13 +256,37 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
))}
</select>
</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
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"
>
{loading ? "Setting model…" : "Set Model"}
{loading ? "Setting model…" : "Use Model"}
</button>
<button
onClick={back}
@@ -224,14 +301,18 @@ export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
Skip for now (go to Settings)
</button>
</div>
</div>
)}
{error && (
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
)}
<p className="text-[var(--color-text-dim)] text-sm mt-4">
If you don't see your model, you may need to download it first. In a terminal, run:{' '}
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code>
{!pulling && (
<p className="text-[var(--color-text-dim)] text-xs mt-4">
Models not in the list are pulled automatically — type any Ollama tag
(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>
);
}
+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(() => {
if (combatants.length === 0) return;
if (!activeId) {
setActiveId(combatants[0].id);
tickDurations(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Combat starts — ${combatants[0].name}`, tag: "#combat" });
return;
}
const idx = combatants.findIndex((c) => c.id === activeId);
if (idx === combatants.length - 1) {
setRound((r) => r + 1);
setActiveId(combatants[0].id);
tickDurations(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Round ${round + 1} — ${combatants[0].name}'s turn`, tag: "#combat" });
} else {
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.
}, [combatants, activeId]);
}, [combatants, activeId, round]);
// 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
@@ -510,7 +539,8 @@ export function InitiativeTracker() {
<div className="flex gap-1 mt-1">
<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"
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] ?? ""}
onChange={(e) => setCondInput((p) => ({ ...p, [c.id]: e.target.value }))}
onKeyDown={(e) => {
+37
View File
@@ -8,6 +8,7 @@ import { addToLore } from "../lib/lore";
interface RagSource {
source: string;
chunks: number;
model: string;
}
interface RagHit {
@@ -39,8 +40,15 @@ export function LorePanel() {
const [expanded, setExpanded] = useState<string | null>(null);
const [chunks, setChunks] = useState<RagChunk[]>([]);
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 stale =
embedModel !== "" && sources.some((s) => s.model !== "" && s.model !== embedModel);
async function loadSources() {
try {
setSources(await invoke<RagSource[]>("rag_list"));
@@ -51,6 +59,9 @@ export function LorePanel() {
useEffect(() => {
loadSources();
invoke<{ embed_model: string }>("get_llm_config")
.then((c) => setEmbedModel(c.embed_model))
.catch(() => {});
}, []);
async function loadChunks(s: string) {
@@ -101,6 +112,18 @@ export function LorePanel() {
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
// needed. Reads .md/.txt contents in the webview and indexes each file as
// its own lore source. Multiple files supported.
@@ -214,6 +237,20 @@ export function LorePanel() {
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
{/* 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 && (
confirmClear ? (
<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 { GeneratedImage } from "./GeneratedImage";
import { addToLore } from "../lib/lore";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
@@ -71,6 +72,18 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
if (!npc) return;
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() {
if (!npc || !newGoal.trim()) return;
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
</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 className="flex-1 min-w-0">
<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.",
temperature: 0.85,
max_tokens: 800,
ragQuery: `${theme} quest ${level}`,
},
});
const parsed = parseLlmJson<QuestResponse>(result);
+31
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react";
import { invoke, Channel } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { bus, Events, type LogEntryPayload } from "../lib/bus";
import { useToast } from "./Toast";
import { addGeneration, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
@@ -45,6 +46,9 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
const [filterTag, setFilterTag] = useState<string | null>(null);
const [previewSummary, setPreviewSummary] = useState(true);
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();
// 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) }));
}
// 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() {
const s = newSession(`Session ${sessions.length + 1}`);
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.",
temperature: 0.7,
max_tokens: 512,
ragQuery: active.title,
},
channel,
});
@@ -251,6 +271,17 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
>
Add Note
</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
onClick={summarize}
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";
interface LlmConfig {
/** "ollama" | "openai" — empty = let the backend sniff the URL. */
provider?: string;
api_url: string;
api_key: string;
model: string;
@@ -174,16 +176,17 @@ export function SettingsPanel() {
setImgTesting(false);
}
// ponytail: provider presets fill in the API URL pattern + default model.
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" },
{ label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true },
{ label: "Custom", url: "", model: "" },
// ponytail: provider presets fill in the API URL pattern + default model
// + the API dialect (provider), so a custom-port Ollama isn't sniffed wrong.
const PRESETS: { label: string; url: string; model: string; provider: string; key?: boolean }[] = [
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2", provider: "ollama" },
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model", provider: "openai" },
{ 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;
setConfig({ ...config, api_url: p.url, model: p.model });
setConfig({ ...config, api_url: p.url, model: p.model, provider: p.provider });
setConn(null);
}
@@ -192,6 +195,7 @@ export function SettingsPanel() {
// backend re-default on save — but we can't send a partial, so mirror the
// known defaults here and toast.
const DEFAULTS: LlmConfig = {
provider: "",
api_url: "http://localhost:11434",
api_key: "",
model: "llama3.2",
+10
View File
@@ -44,6 +44,7 @@ export const bus = new EventBus();
export const Events = {
AddCombatants: "add-combatants",
PlayAmbient: "play-ambient",
LogEntry: "log-entry",
} as const;
export type AddCombatantsPayload = {
@@ -52,6 +53,15 @@ export type AddCombatantsPayload = {
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 = {
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
id: string;