Compare commits
14 Commits
3eb0a2da2e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d41737e1c9 | |||
| 160788d63b | |||
| 630a025e77 | |||
| 00178e603d | |||
| 3c599553fc | |||
| bbd492fff1 | |||
| 7bdfe5dbae | |||
| 1e7301647d | |||
| 624d82931b | |||
| 00fb901aee | |||
| c5d4db4ae1 | |||
| a69d7caefb | |||
| 913345a05e | |||
| 03a634fdd0 |
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 |
@@ -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,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 = ""
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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(¶);
|
||||||
|
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(¶2);
|
||||||
|
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];
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user