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 - **NPC Generator** — portraits, personality, goals, stat blocks
- **Quest Designer** — multi-step quests with twists and reward breakdown - **Quest Designer** — multi-step quests with twists and reward breakdown
- **Item Forge** — magic items with art and structured mechanics - **Item Forge** — magic items with art and structured mechanics
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama) - **Image Generator** — portraits, maps, scene art (cross-platform, via stable-diffusion.cpp)
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary - **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
- **Soundboard** — synthesized ambience/SFX with one-click scenes - **Soundboard** — synthesized ambience/SFX with one-click scenes
- **World Builder** — generated regions, landmarks, a draggable-pin map - **World Builder** — generated regions, landmarks, a draggable-pin map
@@ -34,7 +34,7 @@ persistent state round it out — reload loses nothing.
### Prerequisites ### Prerequisites
- **Rust** + **Cargo** — https://rustup.rs - **Rust** + **Cargo** — https://rustup.rs
- **Node.js** 20+ — https://nodejs.org - **Node.js** 20+ (23.6+ to run the `npm run check` self-checks) — https://nodejs.org
- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`) - **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
### Install & run ### Install & run
@@ -66,7 +66,8 @@ All campaign data stays on disk under your OS app-data dir (default
| File | Contents | | File | Contents |
|------|----------| |------|----------|
| `lore.db` | RAG chunks + embeddings (SQLite) | | `dm-pal-prefs.json` | App prefs — data-dir setting + LLM config incl. API key (stored locally, one level above `dm-toolkit/`) |
| `lore/lore.db` | RAG chunks + embeddings (SQLite) |
| `generations.db` | History of every generated NPC/encounter/item/quest/… | | `generations.db` | History of every generated NPC/encounter/item/quest/… |
| `images/` | Cached generated PNGs, keyed by prompt hash | | `images/` | Cached generated PNGs, keyed by prompt hash |
| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) | | `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
+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.) - **macOS-only today** — Ollama image models only run on macOS (Apple Silicon via MLX). Gate the image-gen UI behind an OS check on first run; on other platforms fall back to a placeholder/emoji or the optional remote API. (This is an Ollama limitation, not ours.)
- **Slow + heavy** — 4B is ~5.7GB, 9B is ~12GB, generation is multi-second. Always generate in the background with a progress bar (drive it from the NDJSON `step`/`total` lines), never block the UI thread. Cache results to disk by hash of the prompt. - **Slow + heavy** — 4B is ~5.7GB, 9B is ~12GB, generation is multi-second. Always generate in the background with a progress bar (drive it from the NDJSON `step`/`total` lines), never block the UI thread. Cache results to disk by hash of the prompt.
- **Model picker** — add `image_model` to `LlmConfig` (default `x/flux2-klein:4b`; `x/z-image-turbo` for fast/low-VRAM). Reuse the existing settings panel, don't build a second one. - **Model picker** — add `image_model` to `LlmConfig` (default `x/flux2-klein:4b`; `x/z-image-turbo` for fast/low-VRAM). Reuse the existing settings panel, don't build a second one.
> **Superseded (2026-09):** image generation now targets a
> [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp)
> `sd-server` via its AUTOMATIC1111-compatible API — cross-platform, no
> macOS gating, no per-request model field. See README and
> `src-tauri/src/commands/image_commands.rs`. Everything below (this section
> and §10's image-model rows) is kept as design history only.
- **Prompt engineering is the lever** — FLUX.2 handles readable text and hex colors, so item/NPC name labels can be rendered *into* the image where it helps. Default to 1024×1024. - **Prompt engineering is the lever** — FLUX.2 handles readable text and hex colors, so item/NPC name labels can be rendered *into* the image where it helps. Default to 1024×1024.
--- ---
@@ -759,7 +766,7 @@ Local LLMs are not "fire and forget." Plan for:
| **Loading state** | Model load can take 5–30 s on HDD/CPU. Show progress bar and cancel button | | **Loading state** | Model load can take 5–30 s on HDD/CPU. Show progress bar and cancel button |
| **GPU offloading** | Expose `n_gpu_layers` slider per model | | **GPU offloading** | Expose `n_gpu_layers` slider per model |
| **Context length** | 2k/4k/8k selector with memory warning | | **Context length** | 2k/4k/8k selector with memory warning |
| **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. | | **Image model** | Separate `image_model` field in `LlmConfig` (default `x/flux2-klein:4b`, `x/z-image-turbo` for speed). macOS-only — show a gated notice on Linux/Windows and disable the ✨ buttons. Drive the progress bar from NDJSON `step`/`total`. **Superseded 2026-09 — see the banner in §4.4.** |
| **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool | | **Generation controls** | Streaming toggle, temperature, top-p, repeat-penalty per tool |
| **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models | | **License acceptance** | First-run "model license + download" wizard; don't silently bundle 4 GB models |
+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 | | # | Item | Effort | Notes |
|---|------|--------|-------| |---|------|--------|-------|
| 0.1 | Scrub PII from `scripts/apple-signing.env.example` | 5 m | [R] real email + Team ID still present | | [x] 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 | | [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 |
| 0.3 | RAG: UTF-8 boundary fix in `chunk()` + test with accented text | 1 h | [R] multibyte paragraphs silently drop chunks today | | [x] 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 | | [x] 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 | | [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 |
| 0.6 | `npm run check` + `.gitea/workflows/ci.yml` (lint, tsc, checks, `cargo test`) | 2 h | [R][P] | | [x] 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] | | [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] |
| 0.8 | Minimal CSP in `tauri.conf.json` (self + `data:` img; check dev HMR) | 30 m | [R] | | [x] 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.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 **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 | | # | 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 | | [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 |
| 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.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 | | [x] 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 | | [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 |
| 1.5 | NPC → Initiative push ("⚔ Add to tracker" on NPC card via `AddCombatants` bus event; stats already generated) | 30 m | [N] | | [x] 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 | | [x] 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] | | [x] 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.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 **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. 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) ## Phase 2 — Flagship features → tag `v0.3.0` (~2 weeks)
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"check": "node scripts/check-dice.ts && node scripts/check-encounter-budget.ts && node scripts/check-worldmap.ts",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

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