14 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
27 changed files with 884 additions and 137 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, …) |
+8 -1
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 |
+24 -20
View File
@@ -11,38 +11,42 @@ Source tags: **[R]** repo-review · **[P]** plan.md §17/§15 ·
---
## Phase 0 — Fix & harden → tag `v0.1.4` (~1.5 days)
## Phase 0 — Fix & harden → tag `v0.1.4` (~1.5 days) — ✅ DONE 2026-09-06
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 0.1 | Scrub PII from `scripts/apple-signing.env.example` | 5 m | [R] real email + Team ID still present |
| 0.2 | Persist `LlmConfig` to prefs store; load on startup | 1 h | [R] **P0** — restart currently wipes Settings. Add a `configVersion` field for future shape changes |
| 0.3 | RAG: UTF-8 boundary fix in `chunk()` + test with accented text | 1 h | [R] multibyte paragraphs silently drop chunks today |
| 0.4 | RAG: `DELETE WHERE source = ?` before re-insert in `add_document` | 15 m | [R] re-adding a file doubles its chunks |
| 0.5 | RAG: embed-model mismatch guard (store model per source; refuse/warn) + "Reindex all" | 2 h | [R] changing embed model currently poisons cosine scores |
| 0.6 | `npm run check` + `.gitea/workflows/ci.yml` (lint, tsc, checks, `cargo test`) | 2 h | [R][P] |
| 0.7 | Doc drift: README line 21 ("macOS, via Ollama"), Node ≥ 23.6 prerequisite, version sync (Cargo vs tauri.conf), banner on stale plan.md §4.4/§10, delete `public/icons.svg` | 1 h | [R] |
| 0.8 | Minimal CSP in `tauri.conf.json` (self + `data:` img; check dev HMR) | 30 m | [R] |
| 0.9 | Replace `is_ollama()` URL sniff with `provider` field in `LlmConfig` (wizard presets set it) | 1.5 h | [R] breaks on custom Ollama ports |
| [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.
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)
## Phase 1 — Core UX enablers → tag `v0.2.0` (~1 week) — ✅ DONE 2026-09-06
| # | Item | Effort | Notes |
|---|------|--------|-------|
| 1.1 | Real token streaming: Ollama `stream:true` NDJSON + OpenAI SSE via `reqwest::bytes_stream` (dep already present) | 3 h | [R][N] unlocks visible generation everywhere; SessionLogger already renders progressive tokens |
| 1.2 | Model pull in first-run wizard: `POST /api/pull` NDJSON → Channel progress bar (same shape as image-gen poller) | 3 h | [N] wizard currently "hopes it exists" |
| 1.3 | `ragQuery` on Encounter, Quest, Session summary | 30 m | [N] NPC/Item/World already pass it |
| 1.4 | Auto-log session events: bus emits for dice rolls, initiative round/turn changes, encounter start → SessionLogger appends | 3 h | [N] makes the AI summary summarize the actual fight |
| 1.5 | NPC → Initiative push ("⚔ Add to tracker" on NPC card via `AddCombatants` bus event; stats already generated) | 30 m | [N] |
| 1.6 | Calendar "Next day" → session-log entry incl. rolled weather | 1 h | [N] ties calendar into the timeline |
| 1.7 | Dice macros: persisted named rolls (`usePersistentState`), button row + edit sheet | 2 h | [U P3] |
| 1.8 | Initiative condition durations (auto-decrement per round, expire) | 2 h | [U-adjacent] concentration/Hex is table-stakes |
| [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)
+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

+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;