Compare commits
8 Commits
v0.1.0
...
f3e06d2bd3
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e06d2bd3 | |||
| da443175c5 | |||
| 65aee44d6b | |||
| abf3668081 | |||
| 35ec227531 | |||
| d2eea494fe | |||
| 639d768bf1 | |||
| 8dcf6d2a28 |
@@ -15,6 +15,9 @@ dist-ssr
|
||||
# Rust / Tauri
|
||||
src-tauri/target/
|
||||
|
||||
# Apple signing credentials (never commit)
|
||||
scripts/apple-signing.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -25,3 +28,4 @@ src-tauri/target/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
secrets/
|
||||
|
||||
@@ -1,32 +1,130 @@
|
||||
# React + TypeScript + Vite
|
||||
# DM-Pal
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
An offline-first desktop toolkit for Dungeon Masters, powered by a local LLM.
|
||||
Built with **Tauri v2** + **Rust** on the back end and **React 19** + **TypeScript**
|
||||
on the front. Every generator is grounded in your own world bible via a local
|
||||
RAG index — no data leaves your machine unless you point it at a remote API.
|
||||
|
||||
Currently, two official plugins are available:
|
||||

|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
## What's in the box
|
||||
|
||||
## React Compiler
|
||||
DM-Pal packs every tool a DM reaches for at and between the table, grouped
|
||||
into **Session** (live) and **World** (prep) tools:
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
- **Initiative Tracker** — combatants, HP, conditions, death saves, turn timer
|
||||
- **Dice Roller** — notation parsing, advantage/disadvantage, roll templates
|
||||
- **Encounter Builder** — AI-generated encounters with a 5e XP budget
|
||||
- **NPC Generator** — portraits, personality, goals, stat blocks
|
||||
- **Quest Designer** — multi-step quests with twists and reward breakdown
|
||||
- **Item Forge** — magic items with art and structured mechanics
|
||||
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama)
|
||||
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
|
||||
- **Soundboard** — synthesized ambience/SFX with one-click scenes
|
||||
- **World Builder** — generated regions, landmarks, a draggable-pin map
|
||||
- **Lore (RAG)** — index your world bible, ground every generation in it
|
||||
- **Calendar** — custom fantasy calendars, weather, moon phases, events
|
||||
- **Random Tables** — built-in and custom tables, weighted rolls
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
A **⌘K command palette**, **History view** (re-open any past generation), and
|
||||
persistent state round it out — reload loses nothing.
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
## Quick start
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
### Prerequisites
|
||||
|
||||
- **Rust** + **Cargo** — https://rustup.rs
|
||||
- **Node.js** 20+ — https://nodejs.org
|
||||
- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
|
||||
|
||||
### Install & run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
On first launch open **Settings (⌘,)** and confirm the API URL, then pull a
|
||||
text model (e.g. `llama3.2`) and an embedding model (e.g. `nomic-embed-text`):
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
For image generation, DM-Pal talks to a [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) `sd-server` (cross-platform) via its AUTOMATIC1111-compatible API. Download a model (e.g. Z-Image-Turbo or FLUX.2-klein weights), start the server, then point DM-Pal at it under **Settings → Image Server** (default `http://localhost:1234`):
|
||||
|
||||
```bash
|
||||
# build sd-server, then run with your chosen model
|
||||
sd-server -m path/to/model.safetensors --vae path/to/vae.safetensors
|
||||
```
|
||||
|
||||
## Where your data lives
|
||||
|
||||
All campaign data stays on disk under your OS app-data dir (default
|
||||
`$APPDATA/dm-toolkit/`, configurable in Settings):
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `lore.db` | RAG chunks + embeddings (SQLite) |
|
||||
| `generations.db` | History of every generated NPC/encounter/item/quest/… |
|
||||
| `images/` | Cached generated PNGs, keyed by prompt hash |
|
||||
| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
|
||||
|
||||
Settings → **Data location** lets you relocate everything to an external drive
|
||||
and migrates existing data for you.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
React 19 + TypeScript ──invoke()/Channel──▶ Rust (Tauri v2)
|
||||
Zustand · framer-motion LLM (Ollama / OpenAI-compatible)
|
||||
react-konva (world map) SQLite (rusqlite, bundled)
|
||||
Tailwind v4 + glassmorphism RAG (brute-force cosine → sqlite-vec path)
|
||||
```
|
||||
|
||||
Each tool is a self-contained component mounted behind a single `renderView`
|
||||
switch; the dashboard is a launcher of at-a-glance tiles. The design system is
|
||||
dark-only (navy + Cinzel + gold), self-hosts its fonts for true offline use,
|
||||
and meets WCAG AA contrast.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (frontend only)
|
||||
npm run tauri dev # Full app, hot-reload
|
||||
npm run build # tsc -b && vite build
|
||||
npm run lint # oxlint
|
||||
node scripts/check-worldmap.ts # world-map layout self-check
|
||||
node scripts/check-encounter-budget.ts # XP-budget self-check
|
||||
node scripts/check-dice.ts # dice-notation parser self-check
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/ React front end
|
||||
components/ one file per tool
|
||||
lib/ pure logic + persistence hooks
|
||||
src-tauri/src/ Rust back end
|
||||
commands/ Tauri IPC commands (llm, image, rag, data, generation)
|
||||
llm/ Ollama/OpenAI client + config
|
||||
rag/ embedding + cosine search
|
||||
generations/ history store
|
||||
docs/ plan + UI/UX review
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [`docs/plan.md`](docs/plan.md) for the full plan and `docs/ui-ux-improvements.md`
|
||||
for the living UI/UX review with a prioritized checklist.
|
||||
|
||||
Planned / in progress: first-run model wizard, quest branching graph, world
|
||||
hierarchy tree, real ambience packs, GitHub Actions CI, code signing, and an
|
||||
auto-updater.
|
||||
|
||||
## License
|
||||
|
||||
TBD. Built-in rule references use only 5e SRD / OGL / CC-BY content. Model files
|
||||
ship under their own licenses (e.g. Meta's Llama license) — accept them in
|
||||
Ollama before pulling.
|
||||
+149
@@ -911,6 +911,155 @@ A consolidated checklist covering all phases:
|
||||
|
||||
---
|
||||
|
||||
## 17. Post-Review Action Items (Fresh Pass)
|
||||
|
||||
*Findings from a fresh codebase review (shell, components, backend, git
|
||||
history). Ordered by impact within each tier. Each item ships as a concrete,
|
||||
checkable change — no redesigns.*
|
||||
|
||||
### 17.1 Repo hygiene (do first — cheap)
|
||||
|
||||
- [x] **Rewrite `README.md`.** It is still the Vite template boilerplate
|
||||
("React + TypeScript + Vite") and describes nothing about DM-Pal.
|
||||
Replace with: one-paragraph pitch, screenshot, `npm run tauri dev`
|
||||
quickstart, prerequisites (Ollama), where data lives, license note.
|
||||
Highest-ROI 20-minute task in the repo.
|
||||
- [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a
|
||||
real Apple ID email (`james.twose2711@gmail.com`) in a tracked file.
|
||||
Replace with `you@example.com`.
|
||||
- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet`
|
||||
Tauri command in `src-tauri/src/lib.rs`.** Leftover from
|
||||
`npm create tauri-app`; nothing in the shell references Greet.
|
||||
Verify with `rg Greet` first.
|
||||
- [x] **Fix pointless ternary in `App.tsx` NavButton:**
|
||||
`<item.icon size={item.view === "tables" ? 18 : 18} />` — both
|
||||
branches are 18. Just `size={18}`.
|
||||
- [x] **Collapse the three nav→kind maps in `App.tsx`** (`PREFILLABLE`,
|
||||
`PREFILLABLE_TOOLS`, `viewMaxWidth`) into a single
|
||||
`Record<View, { kind?, Comp?, maxWidth }>` and derive the inverse
|
||||
map for `rehydrate`. Today adding a tool means editing five places;
|
||||
this makes it one.
|
||||
|
||||
### 17.2 Tests & CI (Milestone 7, currently unshipped)
|
||||
|
||||
- [x] **`src/lib/encounter-budget.ts` self-check.** Non-trivial combat
|
||||
math with no test. Add an `assert`-based `demo()` / one small
|
||||
`test_encounter_budget.ts` so a tweaked XP number fails loudly.
|
||||
- [x] **Dice-parser test.** `DiceRoller` parses `NdX±M`, advantage
|
||||
(`kh1`/`kl1`), templates — a parser bug = wrong rolls at the table.
|
||||
One `test_dice.ts` asserting `{count, sides, mod, keep}` for
|
||||
`4d6+3`, `2d20kh1`, `1d20-2` is the smallest thing that catches
|
||||
regressions. (Rust side has good `generations` + image-base64
|
||||
tests; frontend has almost none — only `worldMap.ts`.)
|
||||
- [ ] **Gitea Actions CI** (not GitHub — repo ships via `scripts/gitea-release.sh`).
|
||||
A `.gitea/workflows/ci.yml` running `oxlint`, `tsc -b`, the three
|
||||
`node scripts/check-*.ts` self-checks, and `cargo test` on each push.
|
||||
Even lint-only is better than none. Same YAML syntax as GitHub
|
||||
Actions, just a different directory.
|
||||
- [ ] **Code signing + notarization** for macOS and Windows.
|
||||
- [ ] **Auto-updater** via `tauri-plugin-updater` (+ delta updates for
|
||||
model packs), then package with `npm run tauri build`.
|
||||
|
||||
### 17.3 Features still missing (verified unchecked in §16)
|
||||
|
||||
- [ ] **Quest branching graph (`reactflow`).** Quests are a linear
|
||||
carousel today. Branching quests with conditional edges ("if they
|
||||
spare the bandit → step 3; if they kill him → step 5") are what
|
||||
make a quest *designer* vs a quest *outliner*. The single biggest
|
||||
remaining "wow" gap in the generators.
|
||||
- [ ] **World hierarchy tree** (continent → country → region → city).
|
||||
Today `WorldBuilder` emits a flat `regions[]` + `landmarks[]`
|
||||
pinned on a map, with no nesting or "drill into a region to
|
||||
generate its sub-regions." A collapsible tree on the left of the
|
||||
existing two-pane layout turns World Builder from a one-shot
|
||||
generator into a living campaign bible.
|
||||
- [x] **Lore directory picker.** Doc said "needs tauri-plugin-dialog" —
|
||||
but the plugin is **already installed and used** in
|
||||
`SettingsPanel`. This is unblocked: add an "Add directory…" button
|
||||
in `LorePanel` that calls `open({ directory: true })` and walks
|
||||
`*.md`/`*.txt`. Low effort, high value for DMs with an existing
|
||||
world-bible folder.
|
||||
- [ ] **Real ambience packs + custom sound import (Soundboard).** Ship
|
||||
2–3 CC0 loops (Freesound/Pixabay) in `public/sounds/`, keep
|
||||
synthesis as fallback, and add drag-an-MP3 onto a tile. The
|
||||
difference between "demo" and "session-ready."
|
||||
- [ ] **First-run model download / license wizard.** Today a new user
|
||||
must hand-configure API URL + model name with no guidance. A
|
||||
one-time wizard on first launch (detect Ollama → list
|
||||
`GET /api/tags` → pick text + image + embed model → save) collapses
|
||||
the "open Settings, stare at empty form, give up" funnel. Reuse
|
||||
the existing connection-test + model-list plumbing from
|
||||
`SettingsPanel`.
|
||||
- [ ] **Image-gen into World Builder (map) + Encounter Builder**
|
||||
(battle map + loot), per §16 item 10 — still only wired into NPC +
|
||||
Item Forge.
|
||||
- [ ] **Handout renderer** (Markdown → PDF), still pending from §15.
|
||||
|
||||
### 17.4 UX refinements (beyond the existing ui-ux-improvements.md)
|
||||
|
||||
- [ ] **Campaign concept.** Everything is one global store
|
||||
(`dm-pal-state.json`) + two global SQLite DBs; a DM running two
|
||||
campaigns can't separate them. A lightweight "active campaign"
|
||||
selector in the title bar that namespaces store keys + DB files
|
||||
(`<dataDir>/<campaign>/`) unlocks multi-campaign without a schema
|
||||
migration. The data-dir relocation machinery already exists —
|
||||
generalize it per-campaign.
|
||||
- [x] **`?` shortcut help overlay.** Checklist marks `?` as shipped but
|
||||
there's no visible cheatsheet — shortcuts are documented only
|
||||
here. A small `?`-triggered modal listing
|
||||
`⌘K / 1–9 / Space / ⌘, / ⌘S / Esc` is the difference between
|
||||
"shortcuts exist" and "shortcuts are discoverable." ~40 lines.
|
||||
- [x] **Global streaming indicator.** `ConnectionPill` shows
|
||||
LLM/image/lore status but there's no "an LLM call is in flight"
|
||||
signal. During a 15s image gen the only feedback is a skeleton in
|
||||
one card. A subtle gold pulse on the pill (or a thin top-of-window
|
||||
progress bar) tells the DM something is working even after they've
|
||||
navigated away from the generating tool.
|
||||
- [x] **Consistent image-gen macOS gating.** `ImageGenerator` shows a
|
||||
clear "macOS only" message, but the `GeneratedImage` ✨ buttons
|
||||
inside NPC/Item silently fall back. Show the same inline
|
||||
"macOS only" notice there so a Windows DM isn't left wondering why
|
||||
nothing happens.
|
||||
- [ ] **Finish the empty-state set for Initiative** (and verify the
|
||||
others) — one `<p>` + CTA each, already done elsewhere.
|
||||
- [ ] **i18n** — extract strings to a `t()` helper (P3, only if shipping
|
||||
beyond EN).
|
||||
- [ ] **Draggable bento layout** (`react-grid-layout`) with persisted
|
||||
layout per campaign (P3).
|
||||
- [ ] **Console mode** for the dashboard — embed 2–3 chosen tools as
|
||||
live-session cards (P3).
|
||||
- [ ] **Player view** — a web app showing the DM's screen (initiative,
|
||||
dice) to phones on the local network (P3).
|
||||
- [ ] **Compendium integration** — pull monster stat blocks from a
|
||||
local SRD JSON (P3).
|
||||
- [ ] **Voice-to-text** for session notes (Whisper) (P3).
|
||||
- [ ] **Macros** — named dice-roll buttons (P3).
|
||||
- [ ] **Session replay** — record rolls/initiative/soundboard state
|
||||
and replay (P3).
|
||||
|
||||
### 17.5 Suggested order of attack
|
||||
|
||||
| # | Item | Effort | Impact |
|
||||
|---|------|--------|--------|
|
||||
| 1 | Rewrite README | 20 min | High |
|
||||
| 2 | Delete Greet + `greet` cmd, fix `18:18` ternary | 10 min | Low |
|
||||
| 3 | Collapse the 3 nav→kind maps into one | 30 min | Medium |
|
||||
| 4 | `encounter-budget` + dice-parser self-tests | 1 hr | Medium |
|
||||
| 5 | Lore directory picker (dialog already installed) | 1 hr | High |
|
||||
| 6 | First-run model wizard (reuse connection test) | 3 hrs | High |
|
||||
| 7 | Quest branching graph (`reactflow`) | 4 hrs | High |
|
||||
| 8 | World hierarchy tree | 3 hrs | High |
|
||||
| 9 | `?` help overlay + global streaming indicator | 1 hr | Medium |
|
||||
| 10 | Real ambience packs + sound import | 2 hrs | Medium |
|
||||
| 11 | GitHub Actions CI | 1 hr | Medium |
|
||||
| 12 | Campaign namespace selector | 4 hrs | High |
|
||||
|
||||
Items 1–5 form a single low-risk PR: README, dead-code cleanup, map
|
||||
consolidation, two self-tests, lore directory picker — all independently
|
||||
shippable in one session.
|
||||
|
||||
---
|
||||
|
||||
**You're now ready to spin up your own AI-powered Dungeon Master toolkit!**
|
||||
|
||||
Happy crafting — may your dice always land favorably! 🎲
|
||||
@@ -7,12 +7,12 @@ recommendation.*
|
||||
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
|
||||
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
|
||||
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
|
||||
> reward fields shipped; remaining P2 items are larger-effort or backend-
|
||||
> gated: image-gen advanced params (Ollama API doesn't support them),
|
||||
> soundboard scenes + real ambience packs, world hierarchy tree, lore
|
||||
> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory /
|
||||
> quest roster (History view covers re-opening). The checklist below is
|
||||
> updated as items land.
|
||||
> reward fields shipped; soundboard scenes + image gallery/batch shipped;
|
||||
> remaining P2 items are larger-effort or backend-gated: image-gen advanced
|
||||
> params (Ollama API doesn't support them), soundboard real ambience packs +
|
||||
> custom sound import, world hierarchy tree, lore directory picker (needs
|
||||
> tauri-plugin-dialog), NPC roster / item inventory / quest roster (History
|
||||
> view covers re-opening). The checklist below is updated as items land.
|
||||
|
||||
---
|
||||
|
||||
@@ -1095,8 +1095,8 @@ These are blockers or bugs that make the app feel broken.
|
||||
- [ ] Quest roster (History view covers re-opening past quests)
|
||||
- [ ] **Image Generator**:
|
||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||
- [ ] Batch mode (2×2 variants)
|
||||
- [ ] Gallery view with thumbnails
|
||||
- [x] Batch mode (2×2 variants)
|
||||
- [x] Gallery view with thumbnails
|
||||
- [x] Persist to campaign-scoped folder
|
||||
- [x] Copy to clipboard
|
||||
- [x] **Session Logger**:
|
||||
@@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken.
|
||||
- [ ] **Soundboard**:
|
||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||
- [ ] Custom sound import (drag-drop)
|
||||
- [ ] Scenes (one-click combinations)
|
||||
- [x] Scenes (one-click combinations)
|
||||
- [x] Master mute + per-sound volume
|
||||
- [x] Always-visible Stop All
|
||||
- [x] Save/load board state
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dm-pal",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copy to scripts/apple-signing.env and fill in. This file is gitignored.
|
||||
# Required for a signed + notarized DMG that Gatekeeper accepts on download.
|
||||
#
|
||||
# 1. APPLE_SIGNING_IDENTITY: the exact string from `security find-identity -v -p codesigning`
|
||||
# that starts with "Developer ID Application: ...". You must create that certificate
|
||||
# first at https://developer.apple.com/account/resources/certificates/list
|
||||
# (Cert type = "Developer ID Application", NOT "Apple Development").
|
||||
# 2. APPLE_ID: your Apple ID email.
|
||||
# 3. APPLE_PASSWORD: an app-specific password from https://account.apple.com
|
||||
# (Sign-In and Security -> App-Specific Passwords). NOT your normal password.
|
||||
# 4. APPLE_TEAM_ID: 10-char Team ID from https://developer.apple.com/account#MembershipDetailsCard
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (4759TW4SDC)"
|
||||
export APPLE_ID="james.twose2711@gmail.com"
|
||||
export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx"
|
||||
export APPLE_TEAM_ID="4759TW4SDC"
|
||||
@@ -0,0 +1,5 @@
|
||||
// ponytail: runnable self-check for the dice-notation parser. Not part of
|
||||
// the app build (scripts/ is outside tsconfig.app's include). Run with:
|
||||
// node scripts/check-dice.ts
|
||||
import { demo } from "../src/lib/dice.ts";
|
||||
demo();
|
||||
@@ -0,0 +1,5 @@
|
||||
// ponytail: runnable self-check for the encounter XP-budget math. Not part
|
||||
// of the app build (scripts/ is outside tsconfig.app's include). Run with:
|
||||
// node scripts/check-encounter-budget.ts
|
||||
import { demo } from "../src/lib/encounter-budget.ts";
|
||||
demo();
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload the built macOS artifacts to a Gitea release. No runners, no CI.
|
||||
# Auth comes from the macOS keychain (same creds `git` uses to push).
|
||||
# Usage: ./scripts/gitea-release.sh [v0.2.0]
|
||||
# No arg = auto patch-bump (0.1.0 -> 0.1.1). Explicit arg syncs version files.
|
||||
# Requires: jq, curl, npm run tauri build deps. If scripts/apple-signing.env is
|
||||
# present, builds a signed + notarized DMG automatically; else ad-hoc (unsigned).
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="https://gitea.jms.rocks"
|
||||
OWNER_REPO="jameshtwose/dm-pal"
|
||||
API="$GITEA_URL/api/v1/repos/$OWNER_REPO"
|
||||
HOST="${GITEA_URL#https://}"
|
||||
|
||||
# Auth: explicit token wins, else pull from the git credential helper (keychain).
|
||||
auth_header() {
|
||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||
printf 'Authorization: token %s' "$GITEA_TOKEN"; return
|
||||
fi
|
||||
creds=$(git credential fill <<< "protocol=https
|
||||
host=$HOST" 2>/dev/null || true)
|
||||
user=$(printf '%s\n' "$creds" | sed -n 's/^username=//p')
|
||||
pass=$(printf '%s\n' "$creds" | sed -n 's/^password=//p')
|
||||
[ -n "$pass" ] || { echo "no creds in keychain and GITEA_TOKEN unset"; exit 1; }
|
||||
printf 'Authorization: Basic %s' "$(printf '%s:%s' "$user" "$pass" | base64)"
|
||||
}
|
||||
|
||||
# Resolve the release tag. No arg => auto-bump patch from the current version.
|
||||
# An explicit arg (v0.2.0) wins and also syncs both version files to it, so the
|
||||
# built DMG's embedded version always matches the release tag.
|
||||
# ponytail: patch-only auto-bump; pass v0.2.0 / v1.0.0 for minor/major.
|
||||
if [ -n "${1:-}" ]; then
|
||||
VER="${1#v}"
|
||||
else
|
||||
VER=$(node -e 'const v=require("./package.json").version;const [a,b,c]=v.split(".").map(Number);process.stdout.write(`${a}.${b}.${c+1}`)')
|
||||
fi
|
||||
TAG="v$VER"
|
||||
node -e '
|
||||
const fs=require("fs"),ver=process.argv[1];
|
||||
for(const f of ["package.json","src-tauri/tauri.conf.json"]){
|
||||
const t=fs.readFileSync(f,"utf8");
|
||||
const out=t.replace(/("version"\s*:\s*")\d+\.\d+\.\d+(")/,`$1${ver}$2`);
|
||||
if(out===t)throw new Error("version not replaced in "+f);
|
||||
fs.writeFileSync(f,out);
|
||||
}
|
||||
' "$VER"
|
||||
echo "release $TAG (version files synced)"
|
||||
git add package.json src-tauri/tauri.conf.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: release $TAG" -q
|
||||
echo "committed version bump"
|
||||
fi
|
||||
|
||||
# If Apple signing creds are present, do a signed + notarized build now so the
|
||||
# uploaded DMG passes Gatekeeper on a browser download. Without these the DMG
|
||||
# is only ad-hoc signed and macOS shows it as "damaged" when downloaded.
|
||||
ENV_FILE="$(dirname "$0")/apple-signing.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
echo "Building signed + notarized DMG ..."
|
||||
npm run tauri build -- --bundles app,dmg
|
||||
else
|
||||
echo "warn: scripts/apple-signing.env missing — building unsigned (ad-hoc) DMG."
|
||||
echo " Browser downloads will be blocked by Gatekeeper. See apple-signing.env.example."
|
||||
npm run tauri build -- --bundles app,dmg
|
||||
fi
|
||||
|
||||
DMG=$(ls -t src-tauri/target/release/bundle/dmg/*.dmg | head -1)
|
||||
APP_BUNDLE=$(ls -d src-tauri/target/release/bundle/macos/*.app | head -1)
|
||||
[ -f "$DMG" ] || { echo "no dmg found — build failed"; exit 1; }
|
||||
[ -d "$APP_BUNDLE" ] || { echo "no .app found"; exit 1; }
|
||||
|
||||
# ponytail: ship the dmg as the installer; also zip the .app for unmanaged
|
||||
# installs / inspection. Skip if zip already present.
|
||||
ZIP="${APP_BUNDLE%.app}.zip"
|
||||
[ -f "$ZIP" ] || ditto -c -k --keepParent "$APP_BUNDLE" "$ZIP"
|
||||
|
||||
AUTH=$(auth_header)
|
||||
|
||||
echo "Creating release $TAG ..."
|
||||
REL=$(curl -sf -X POST "$API/releases" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg t "$TAG" --arg n "$TAG" \
|
||||
'{tag_name:$t, name:$n, body:"DM-Pal macOS build.", draft:false, prerelease:false}')")
|
||||
RID=$(echo "$REL" | jq -r .id)
|
||||
[ "$RID" != "null" ] || { echo "release create failed: $REL"; exit 1; }
|
||||
echo "release id=$RID"
|
||||
|
||||
for f in "$DMG" "$ZIP"; do
|
||||
echo "attaching $(basename "$f") ..."
|
||||
curl -sf -X POST "$API/releases/$RID/assets?name=$(basename "$f")" \
|
||||
-H "$AUTH" -F "attachment=@$f" -o /dev/null
|
||||
done
|
||||
|
||||
# ponytail: guard against shipping an unsigned bundle by mistake — if creds
|
||||
# were configured, confirm the app is actually Developer-ID signed.
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
codesign -dv --verbose=2 "$APP_BUNDLE" 2>&1 | grep -q "Authority=Developer ID" \
|
||||
|| { echo "error: APPLE creds set but app is not Developer-ID signed — notarization failed or skipped"; exit 1; }
|
||||
echo "verified: Developer ID signature present"
|
||||
fi
|
||||
|
||||
echo "done -> $GITEA_URL/$OWNER_REPO/releases/tag/$TAG"
|
||||
@@ -1,64 +1,32 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::AppState;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ponytail: we talk to a stable-diffusion.cpp `sd-server` over HTTP using its
|
||||
// AUTOMATIC1111-compatible API (/sdapi/v1/txt2img + /sdapi/v1/progress). This
|
||||
// reuses the existing HTTP+channel plumbing and drops the old Ollama/macOS-only
|
||||
// limitation — sd-server runs on macOS, Linux, and Windows. The model is chosen
|
||||
// at server startup, so there's no per-request model field anymore.
|
||||
|
||||
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImageRequest {
|
||||
pub prompt: String,
|
||||
/// Override the configured image model for this call.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// One line of Ollama's NDJSON image-generation response.
|
||||
/// `step`/`total` carry progress; the final `done: true` line carries the
|
||||
/// singular `image` base64 field. All fields optional per-line.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct OllamaImageLine {
|
||||
#[serde(default)]
|
||||
done: bool,
|
||||
#[serde(default)]
|
||||
step: Option<u32>,
|
||||
#[serde(default)]
|
||||
total: Option<u32>,
|
||||
#[serde(default)]
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed view of one NDJSON line used by both the streaming and the
|
||||
/// buffered paths. Extracted so the parsing logic is unit-testable without
|
||||
/// touching the network.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct ImageProgress {
|
||||
done: bool,
|
||||
step: Option<u32>,
|
||||
total: Option<u32>,
|
||||
image: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_image_line(line: &str) -> Option<ImageProgress> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed: OllamaImageLine = serde_json::from_str(line).ok()?;
|
||||
Some(ImageProgress {
|
||||
done: parsed.done,
|
||||
step: parsed.step,
|
||||
total: parsed.total,
|
||||
image: parsed.image,
|
||||
})
|
||||
}
|
||||
|
||||
/// Channel events for streaming image generation.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "camelCase")]
|
||||
pub enum ImageEvent {
|
||||
/// Progress update: (step, total). Either may be None if Ollama omits it.
|
||||
/// Progress update: (step, total). Either may be None if the server omits it.
|
||||
Progress { step: Option<u32>, total: Option<u32> },
|
||||
/// Final result: a `data:image/png;base64,...` URL ready for `<img src>`.
|
||||
Done(String),
|
||||
@@ -66,54 +34,66 @@ pub enum ImageEvent {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Generate (or fetch from disk cache) an image for `prompt` via the configured
|
||||
/// Ollama image model. Returns a `data:image/png;base64,...` URL ready for `<img src>`.
|
||||
///
|
||||
/// Ollama image models are macOS-only today; on other platforms we return an error
|
||||
/// so the front-end can fall back to a placeholder instead of a confusing timeout.
|
||||
/// A1111 `/sdapi/v1/txt2img` response. Only the base64 PNG list matters to us.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Txt2ImgResponse {
|
||||
#[serde(default)]
|
||||
images: Vec<String>,
|
||||
}
|
||||
|
||||
/// A1111 `/sdapi/v1/progress` response. `progress` is 0.0–1.0 of the current job.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProgressResponse {
|
||||
#[serde(default)]
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
/// Generate (or fetch from disk cache) an image for `prompt` via a local
|
||||
/// stable-diffusion.cpp `sd-server`. Returns a `data:image/png;base64,...` URL
|
||||
/// ready for `<img src>`.
|
||||
#[tauri::command]
|
||||
pub async fn generate_image(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
) -> Result<String, String> {
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
return Err("image generation is macOS-only via Ollama (for now)".into());
|
||||
}
|
||||
// ponytail: emit a global busy signal so the shell shows a working
|
||||
// indicator even if the DM navigates away. Paired with the false below.
|
||||
emit_busy(&app, true);
|
||||
let result = generate_image_inner(state, req).await;
|
||||
emit_busy(&app, false);
|
||||
result
|
||||
}
|
||||
|
||||
async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result<String, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let model = req.model.unwrap_or(config.image_model.clone());
|
||||
let api_url = config.image_api_url.clone();
|
||||
|
||||
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?;
|
||||
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?;
|
||||
if cache_hit {
|
||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||
return Ok(data_url(&bytes));
|
||||
}
|
||||
|
||||
let png_bytes = request_image_bytes(&config, &model, &req.prompt, None).await?;
|
||||
let png_bytes = txt2img(&api_url, &req.prompt).await?;
|
||||
std::fs::write(&cache_path, &png_bytes).map_err(|e| e.to_string())?;
|
||||
Ok(data_url(&png_bytes))
|
||||
}
|
||||
|
||||
/// Streaming variant: emits `ImageEvent::Progress` as Ollama reports `step`/`total`,
|
||||
/// then `ImageEvent::Done` with the data URL (or `Error`). Reuses the same disk cache
|
||||
/// as `generate_image`. The DM gets a real progress bar for the multi-second wait.
|
||||
/// Streaming variant: emits `ImageEvent::Progress` (from `/sdapi/v1/progress`)
|
||||
/// while the txt2img POST is in flight, then `ImageEvent::Done` with the data
|
||||
/// URL (or `Error`). Reuses the same disk cache as `generate_image`.
|
||||
#[tauri::command]
|
||||
pub async fn generate_image_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
channel: Channel<ImageEvent>,
|
||||
) -> Result<(), String> {
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
let _ = channel.send(ImageEvent::Error(
|
||||
"image generation is macOS-only via Ollama (for now)".into(),
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let model = req.model.unwrap_or(config.image_model.clone());
|
||||
let api_url = config.image_api_url.clone();
|
||||
|
||||
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &model, &req.prompt)?;
|
||||
let (cache_path, cache_hit) = prepare_cache(&state.data_dir, &api_url, &req.prompt)?;
|
||||
if cache_hit {
|
||||
let bytes = std::fs::read(&cache_path).map_err(|e| e.to_string())?;
|
||||
let _ = channel.send(ImageEvent::Done(data_url(&bytes)));
|
||||
@@ -122,60 +102,104 @@ pub async fn generate_image_stream(
|
||||
|
||||
// Drive the request on a background task so the command returns immediately
|
||||
// and progress flows through the channel. Errors become ImageEvent::Error.
|
||||
let channel = std::sync::Arc::new(channel);
|
||||
let channel = Arc::new(channel);
|
||||
let ch = channel.clone();
|
||||
// ponytail: only emit busy around the actual generation (not cache hits),
|
||||
// and always balance it in the spawn — even on error.
|
||||
emit_busy(&app, true);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
|
||||
// Poll /sdapi/v1/progress while the txt2img POST runs so the DM sees a
|
||||
// real progress bar. Best-effort: poll errors are silent (bar falls
|
||||
// back to indeterminate). 200ms is smooth without spamming the server.
|
||||
let poll_client = reqwest::Client::new();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let done_p = done.clone();
|
||||
let api_p = api_url.clone();
|
||||
let ch_p = ch.clone();
|
||||
let poller = tauri::async_runtime::spawn(async move {
|
||||
while !done_p.load(Ordering::SeqCst) {
|
||||
if let Ok(p) = poll_progress(&poll_client, &api_p).await {
|
||||
let step = (p.clamp(0.0, 1.0) * 100.0) as u32;
|
||||
let _ = ch_p.send(ImageEvent::Progress { step: Some(step), total: Some(100) });
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let result = txt2img(&api_url, &req.prompt).await;
|
||||
done.store(true, Ordering::SeqCst);
|
||||
let _ = poller.await; // let the poller flush its last iteration
|
||||
|
||||
match result {
|
||||
Ok(png_bytes) => {
|
||||
let _ = std::fs::write(&cache_path, &png_bytes);
|
||||
let _ = channel.send(ImageEvent::Done(data_url(&png_bytes)));
|
||||
let _ = ch.send(ImageEvent::Done(data_url(&png_bytes)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = channel.send(ImageEvent::Error(e));
|
||||
let _ = ch.send(ImageEvent::Error(e));
|
||||
}
|
||||
}
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the on-disk cache path for (model, prompt) and report a cache hit.
|
||||
/// Resolve the on-disk cache path for (api_url, prompt) and report a cache hit.
|
||||
/// `base` is the configured data dir (AppState.data_dir).
|
||||
fn prepare_cache(
|
||||
base: &std::path::Path,
|
||||
model: &str,
|
||||
api_url: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(std::path::PathBuf, bool), String> {
|
||||
let cache_dir = base.join("images");
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
model.hash(&mut hasher);
|
||||
api_url.hash(&mut hasher);
|
||||
prompt.hash(&mut hasher);
|
||||
let cache_path = cache_dir.join(format!("{:016x}.png", hasher.finish()));
|
||||
let hit = cache_path.exists();
|
||||
Ok((cache_path, hit))
|
||||
}
|
||||
|
||||
/// POST the image request to Ollama and collect the PNG bytes. When a channel
|
||||
/// is given, parse the NDJSON body incrementally and emit progress; otherwise
|
||||
/// read the whole body at once (legacy buffered path).
|
||||
async fn request_image_bytes(
|
||||
config: &crate::llm::LlmConfig,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
channel: Option<std::sync::Arc<Channel<ImageEvent>>>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/api/generate", config.api_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({ "model": model, "prompt": prompt, "stream": false });
|
||||
/// POST `/sdapi/v1/txt2img` to the sd-server and return the raw PNG bytes of
|
||||
/// the first generated image. A1111 returns base64 PNGs *without* a `data:`
|
||||
/// prefix, so we decode to bytes for the disk cache.
|
||||
async fn txt2img(api_url: &str, prompt: &str) -> Result<Vec<u8>, String> {
|
||||
// ponytail: width/height/steps/cfg are fixed — diffusion can take tens of
|
||||
// seconds; add config knobs only if the DM wants to tune them.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/sdapi/v1/txt2img", api_url.trim_end_matches('/'));
|
||||
let body = serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.0,
|
||||
"seed": -1,
|
||||
"batch_size": 1,
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("image request failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
// ponytail: non-technical DMs see a connect error as a mystery —
|
||||
// name the fix (start sd-server) and point at the in-app guide.
|
||||
if e.is_connect() || e.is_timeout() {
|
||||
format!(
|
||||
"Can't reach the image server at {api_url}. Is sd-server running? Open the Image tab → 'First time setup' for step-by-step instructions. ({e})"
|
||||
)
|
||||
} else {
|
||||
format!("image request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
@@ -183,56 +207,71 @@ async fn request_image_bytes(
|
||||
return Err(format!("image error {status}: {text}"));
|
||||
}
|
||||
|
||||
// ponytail: Ollama image models emit NDJSON even with stream:false —
|
||||
// progress lines (step/total) then a final done:true carrying the image.
|
||||
// The buffered path reads it all at once; the streaming path splits lines
|
||||
// as they arrive so the bar animates.
|
||||
let mut png_b64: Option<String> = None;
|
||||
let mut buffer = String::new();
|
||||
let parsed: Txt2ImgResponse = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("image parse error: {e}"))?;
|
||||
let b64 = parsed
|
||||
.images
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "no image in sd-server response".to_string())?;
|
||||
base64_decode(&b64)
|
||||
}
|
||||
|
||||
let apply_line = |line: &str, png: &mut Option<String>, ch: Option<&Channel<ImageEvent>>| {
|
||||
if let Some(p) = parse_image_line(line) {
|
||||
if let Some(c) = ch {
|
||||
let _ = c.send(ImageEvent::Progress { step: p.step, total: p.total });
|
||||
}
|
||||
if let Some(b64) = p.image {
|
||||
*png = Some(b64);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ch) = channel {
|
||||
let mut stream = res.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("image read error: {e}"))?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
// Process complete lines; keep the trailing partial line in buffer.
|
||||
while let Some(idx) = buffer.find('\n') {
|
||||
let line = buffer.split_off(idx + 1);
|
||||
let complete = std::mem::replace(&mut buffer, line);
|
||||
apply_line(&complete, &mut png_b64, Some(&ch));
|
||||
}
|
||||
}
|
||||
if !buffer.trim().is_empty() {
|
||||
apply_line(&buffer, &mut png_b64, Some(&ch));
|
||||
}
|
||||
} else {
|
||||
let body_text = res.text().await.map_err(|e| format!("image read error: {e}"))?;
|
||||
for line in body_text.lines() {
|
||||
apply_line(line, &mut png_b64, None);
|
||||
// ponytail: stop after the done line in the buffered path — the
|
||||
// final image is the one we want.
|
||||
if let Some(p) = parse_image_line(line) {
|
||||
if p.done && p.image.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// GET `/sdapi/v1/progress` → fraction (0.0–1.0) of the current job. Best-effort.
|
||||
async fn poll_progress(client: &reqwest::Client, api_url: &str) -> Result<f32, String> {
|
||||
let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/'));
|
||||
let res = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("progress HTTP {}", res.status()));
|
||||
}
|
||||
let p: ProgressResponse = res.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(p.progress)
|
||||
}
|
||||
|
||||
let b64 = png_b64.ok_or_else(|| "no image data in Ollama response".to_string())?;
|
||||
let png_bytes = base64_decode(&b64)?;
|
||||
Ok(png_bytes)
|
||||
/// GET `/sdapi/v1/progress` as a liveness probe for the configured sd-server.
|
||||
/// Returns ok=true when the server responds (even idle — progress is 0.0).
|
||||
/// Mirrors the LLM `test_connection` so Settings can show a green check.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ImageConnectionTest {
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_image_connection(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ImageConnectionTest, String> {
|
||||
let api_url = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.image_api_url
|
||||
.clone();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = format!("{}/sdapi/v1/progress", api_url.trim_end_matches('/'));
|
||||
match client.get(&url).send().await {
|
||||
Ok(res) if res.status().is_success() => Ok(ImageConnectionTest {
|
||||
ok: true,
|
||||
error: String::new(),
|
||||
}),
|
||||
Ok(res) => Ok(ImageConnectionTest {
|
||||
ok: false,
|
||||
error: format!("HTTP {} — is this an sd-server?", res.status()),
|
||||
}),
|
||||
Err(e) => Ok(ImageConnectionTest {
|
||||
ok: false,
|
||||
error: format!("Can't reach {api_url} — is sd-server running? ({e})"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn data_url(png: &[u8]) -> String {
|
||||
@@ -319,55 +358,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_key_is_stable() {
|
||||
// sanity: same inputs → same filename shape (16 hex digits)
|
||||
// sanity: same inputs → same 16-hex-digit filename shape
|
||||
let mut h = DefaultHasher::new();
|
||||
"x/flux2-klein:4b".hash(&mut h);
|
||||
"http://localhost:1234".hash(&mut h);
|
||||
"prompt".hash(&mut h);
|
||||
let s = format!("{:016x}", h.finish());
|
||||
assert_eq!(s.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_progress_and_image_lines() {
|
||||
// intermediate progress line, no image
|
||||
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":false,"total":4,"step":1}"#).unwrap();
|
||||
assert_eq!(p, ImageProgress { done: false, step: Some(1), total: Some(4), image: None });
|
||||
|
||||
// final done line carries the image
|
||||
let p = parse_image_line(r#"{"model":"x/flux2-klein:4b","done":true,"image":"iVBORw0KGgoAAAANSUhEUgAA"}"#).unwrap();
|
||||
assert!(p.done);
|
||||
assert_eq!(p.image.as_deref(), Some("iVBORw0KGgoAAAANSUhEUgAA"));
|
||||
assert!(p.total.is_none() && p.step.is_none());
|
||||
|
||||
// blank/garbage lines are ignored, not errors
|
||||
assert!(parse_image_line("").is_none());
|
||||
assert!(parse_image_line("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_ndjson_buffer_keeps_trailing_partial() {
|
||||
// Simulate two chunks arriving separately where the split falls mid-line.
|
||||
let chunk1 = "{\"done\":false,\"step\":1,\"total\":4}\n{\"done\":tru";
|
||||
let chunk2 = "e,\"image\":\"abc\"}\n";
|
||||
let mut buffer = String::new();
|
||||
let mut png: Option<String> = None;
|
||||
let whole = format!("{chunk1}{chunk2}");
|
||||
// emulate the streaming loop over the concatenated body
|
||||
buffer.push_str(&whole);
|
||||
let mut lines = Vec::new();
|
||||
while let Some(idx) = buffer.find('\n') {
|
||||
let line = buffer.split_off(idx + 1);
|
||||
let complete = std::mem::replace(&mut buffer, line);
|
||||
lines.push(complete);
|
||||
}
|
||||
if !buffer.trim().is_empty() {
|
||||
lines.push(std::mem::take(&mut buffer));
|
||||
}
|
||||
for line in &lines {
|
||||
if let Some(p) = parse_image_line(line) {
|
||||
if let Some(b) = p.image { png = Some(b); }
|
||||
}
|
||||
}
|
||||
assert_eq!(png.as_deref(), Some("abc"));
|
||||
fn progress_fraction_maps_to_step() {
|
||||
// The streaming poller emits step = clamp(progress,0,1)*100.
|
||||
assert_eq!((0.0f32 * 100.0) as u32, 0);
|
||||
assert_eq!((0.5f32 * 100.0) as u32, 50);
|
||||
assert_eq!((1.5f32.clamp(0.0, 1.0) * 100.0) as u32, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,27 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
|
||||
use serde_json::json;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ─── Simple (non-streaming) generate ─────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
|
||||
pub async fn generate(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: GenerateRequest,
|
||||
) -> Result<String, String> {
|
||||
// ponytail: emit a global busy signal so the shell can show a working
|
||||
// indicator even after the DM navigates away. Paired with the false
|
||||
// emit below — every return path balances the counter.
|
||||
emit_busy(&app, true);
|
||||
let result = generate_inner(state, req).await;
|
||||
emit_busy(&app, false);
|
||||
result
|
||||
}
|
||||
|
||||
async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
|
||||
let config = {
|
||||
let guard = state.config.lock().map_err(|e| e.to_string())?;
|
||||
guard.clone()
|
||||
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
||||
#[tauri::command]
|
||||
pub async fn generate_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: GenerateRequest,
|
||||
channel: Channel<LlmEvent>,
|
||||
) -> Result<(), String> {
|
||||
@@ -43,6 +60,7 @@ pub async fn generate_stream(
|
||||
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
||||
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
|
||||
|
||||
emit_busy(&app, true);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// For now, we do a non-streaming call and emit the full response as one token
|
||||
// Real SSE streaming from Ollama/OpenAI can be added later
|
||||
@@ -61,6 +79,8 @@ pub async fn generate_stream(
|
||||
let _ = channel.send(LlmEvent::Error(e));
|
||||
}
|
||||
}
|
||||
// ponytail: always balance the busy counter, even on error.
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -3,3 +3,20 @@ pub mod image_commands;
|
||||
pub mod rag_commands;
|
||||
pub mod generation_commands;
|
||||
pub mod data_commands;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
// ponytail: a global "generation in flight" signal. Every generate command
|
||||
// emits busy:true at start and busy:false at end so the shell can show a
|
||||
// working indicator even after the DM navigates away from the generating
|
||||
// tool. The frontend maintains a counter (true = +1, false = -1) so
|
||||
// concurrent generations balance out.
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct GenBusy {
|
||||
pub busy: bool,
|
||||
}
|
||||
|
||||
pub fn emit_busy(app: &AppHandle, busy: bool) {
|
||||
let _ = app.emit("gen-busy", GenBusy { busy });
|
||||
}
|
||||
@@ -75,3 +75,93 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<V
|
||||
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RagAddDirRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RagAddDirReport {
|
||||
pub files: usize,
|
||||
pub chunks: usize,
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Walk a directory (recursive, skipping hidden entries) and index every
|
||||
/// `.md`/`.markdown`/`.txt` file as its own lore source, named by its path
|
||||
/// relative to the picked root. Done in Rust rather than the webview `fs`
|
||||
/// plugin so a DM can pick an external world-bible folder without needing an
|
||||
/// fs scope permission for that path. ponytail: one embed batch per file is
|
||||
/// fine here — directory import is a one-off prep action, not a hot path.
|
||||
#[tauri::command]
|
||||
pub async fn rag_add_directory(
|
||||
state: tauri::State<'_, AppState>,
|
||||
req: RagAddDirRequest,
|
||||
) -> Result<RagAddDirReport, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||
let root = std::path::PathBuf::from(&req.path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("not a directory: {}", req.path));
|
||||
}
|
||||
let mut files = 0usize;
|
||||
let mut chunks = 0usize;
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut stack = vec![root.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{}: {e}", dir.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let is_text = matches!(
|
||||
p.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("md") | Some("markdown") | Some("txt")
|
||||
);
|
||||
if !is_text {
|
||||
continue;
|
||||
}
|
||||
let text = match std::fs::read_to_string(&p) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{}: {e}", p.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// ponytail: source = path relative to the picked root, extension
|
||||
// stripped, so two files with the same name in different folders
|
||||
// don't silently merge into one lore source.
|
||||
let rel = p.strip_prefix(&root).unwrap_or(&p);
|
||||
let source = rel.with_extension("").to_string_lossy().to_string();
|
||||
let source = if source.is_empty() {
|
||||
p.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
source
|
||||
};
|
||||
match state.rag.add_document(&config, &source, &text).await {
|
||||
Ok(n) => {
|
||||
files += 1;
|
||||
chunks += n;
|
||||
}
|
||||
Err(e) => skipped.push(format!("{}: {e}", p.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RagAddDirReport { files, chunks, skipped })
|
||||
}
|
||||
@@ -56,7 +56,6 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
commands::llm_commands::generate,
|
||||
commands::llm_commands::generate_stream,
|
||||
commands::llm_commands::get_llm_config,
|
||||
@@ -64,11 +63,13 @@ pub fn run() {
|
||||
commands::llm_commands::test_connection,
|
||||
commands::image_commands::generate_image,
|
||||
commands::image_commands::generate_image_stream,
|
||||
commands::image_commands::test_image_connection,
|
||||
commands::rag_commands::rag_add,
|
||||
commands::rag_commands::rag_search,
|
||||
commands::rag_commands::rag_list,
|
||||
commands::rag_commands::rag_clear,
|
||||
commands::rag_commands::rag_chunks,
|
||||
commands::rag_commands::rag_add_directory,
|
||||
commands::generation_commands::generation_add,
|
||||
commands::generation_commands::generation_list,
|
||||
commands::generation_commands::generation_get,
|
||||
@@ -83,7 +84,6 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to DM-Pal ⚔", name)
|
||||
}
|
||||
// ponytail: removed the scaffold `greet` command + its frontend component —
|
||||
// nothing in the shell referenced it. Kept here as a marker so the
|
||||
// invoke_handler list above stays in sync.
|
||||
@@ -31,8 +31,9 @@ pub struct LlmConfig {
|
||||
pub temperature: f32,
|
||||
pub max_tokens: u32,
|
||||
pub top_p: f32,
|
||||
/// Ollama image-generation model (e.g. `x/flux2-klein:4b`). macOS-only via Ollama.
|
||||
pub image_model: String,
|
||||
/// Base URL of a stable-diffusion.cpp `sd-server` (A1111-compatible API),
|
||||
/// e.g. `http://localhost:1234`. Cross-platform — no longer Ollama/macOS-only.
|
||||
pub image_api_url: String,
|
||||
/// Ollama embedding model for lore RAG (e.g. `nomic-embed-text`).
|
||||
pub embed_model: String,
|
||||
}
|
||||
@@ -47,7 +48,7 @@ impl Default for LlmConfig {
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
top_p: 0.9,
|
||||
image_model: "x/flux2-klein:4b".to_string(),
|
||||
image_api_url: "http://localhost:1234".to_string(),
|
||||
embed_model: "nomic-embed-text".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "DM-Pal",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"identifier": "com.dm-pal.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -29,6 +29,10 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+118
-75
@@ -31,10 +31,13 @@ import { QuestDesigner } from "./components/QuestDesigner";
|
||||
import { Soundboard } from "./components/Soundboard";
|
||||
import { LorePanel } from "./components/LorePanel";
|
||||
import { ImageGenerator } from "./components/ImageGenerator";
|
||||
import { FirstRunWizard } from "./components/FirstRunWizard";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import { ShortcutHelp } from "./components/ShortcutHelp";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { ConnectionPill } from "./components/ConnectionPill";
|
||||
import { GeneratingIndicator } from "./components/GeneratingIndicator";
|
||||
import { HistoryView } from "./components/HistoryView";
|
||||
import type { Generation, GenerationKind } from "./lib/generations";
|
||||
|
||||
@@ -56,17 +59,47 @@ export type View =
|
||||
| "settings"
|
||||
| "history";
|
||||
|
||||
// ponytail: tools that accept a prefill from history. Mirrors the kinds.
|
||||
const PREFILLABLE: Partial<Record<View, GenerationKind>> = {
|
||||
npcs: "npc",
|
||||
encounter: "encounter",
|
||||
world: "world",
|
||||
items: "item",
|
||||
quest: "quest",
|
||||
session: "session",
|
||||
image: "image",
|
||||
// ponytail: ONE source of truth per tool — kind (for history prefill),
|
||||
// the prefill-aware component, and the preferred max-width. Adding a tool
|
||||
// is a single entry here; the KIND_TO_VIEW inverse and renderView derive
|
||||
// from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS
|
||||
// triple that drifted whenever a tool was added.
|
||||
type PrefillComponent = React.ComponentType<{
|
||||
prefill?: Generation | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}>;
|
||||
|
||||
interface ToolMeta {
|
||||
kind?: GenerationKind;
|
||||
Comp?: PrefillComponent;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
const TOOL_META: Partial<Record<View, ToolMeta>> = {
|
||||
npcs: { kind: "npc", Comp: NpcGenerator, maxWidth: "max-w-4xl" },
|
||||
encounter: { kind: "encounter", Comp: EncounterBuilder, maxWidth: "max-w-4xl" },
|
||||
quest: { kind: "quest", Comp: QuestDesigner, maxWidth: "max-w-4xl" },
|
||||
items: { kind: "item", Comp: ItemForge, maxWidth: "max-w-4xl" },
|
||||
world: { kind: "world", Comp: WorldBuilder, maxWidth: "max-w-6xl" },
|
||||
session: { kind: "session", Comp: SessionLogger, maxWidth: "max-w-6xl" },
|
||||
image: { kind: "image", Comp: ImageGenerator, maxWidth: "max-w-6xl" },
|
||||
lore: { maxWidth: "max-w-6xl" },
|
||||
sound: { maxWidth: "max-w-6xl" },
|
||||
dice: { maxWidth: "max-w-2xl" },
|
||||
settings: { maxWidth: "max-w-2xl" },
|
||||
initiative: { maxWidth: "max-w-2xl" },
|
||||
tables: { maxWidth: "max-w-2xl" },
|
||||
calendar: { maxWidth: "max-w-2xl" },
|
||||
};
|
||||
|
||||
// ponytail: kind → view inverse, derived once so rehydrate() is a lookup.
|
||||
const KIND_TO_VIEW: Partial<Record<GenerationKind, View>> = {};
|
||||
for (const [v, m] of Object.entries(TOOL_META)) {
|
||||
if (m.kind) KIND_TO_VIEW[m.kind] = v as View;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_WIDTH = "max-w-2xl";
|
||||
|
||||
type NavGroup = "session" | "world";
|
||||
|
||||
interface NavItem {
|
||||
@@ -100,19 +133,16 @@ function renderView(
|
||||
onPrefillConsumed: () => void,
|
||||
rehydrate: (kind: GenerationKind, data: Generation) => void,
|
||||
): React.ReactNode {
|
||||
// ponytail: history view renders its own list/detail panes, so it doesn't
|
||||
// use the prefill or wrapper pattern.
|
||||
// ponytail: history renders its own list/detail panes — no prefill wrapper.
|
||||
if (view === "history") {
|
||||
return <HistoryView onRehydrate={rehydrate} />;
|
||||
}
|
||||
// Tools that accept prefill.
|
||||
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "items") return <ItemForge prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "quest") return <QuestDesigner prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "session") return <SessionLogger prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "image") return <ImageGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
// ponytail: prefill-aware tools come straight from TOOL_META — one entry
|
||||
// per tool instead of a parallel if-chain that drifted with the maps.
|
||||
const meta = TOOL_META[view];
|
||||
if (meta?.Comp) {
|
||||
return <meta.Comp prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
}
|
||||
switch (view) {
|
||||
case "dice":
|
||||
return <DiceRoller />;
|
||||
@@ -133,54 +163,30 @@ function renderView(
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail.
|
||||
const viewMaxWidth: Partial<Record<View, string>> = {
|
||||
dice: "max-w-2xl",
|
||||
settings: "max-w-2xl",
|
||||
npcs: "max-w-4xl",
|
||||
encounter: "max-w-4xl",
|
||||
quest: "max-w-4xl",
|
||||
items: "max-w-4xl",
|
||||
world: "max-w-6xl",
|
||||
lore: "max-w-6xl",
|
||||
image: "max-w-6xl",
|
||||
sound: "max-w-6xl",
|
||||
session: "max-w-6xl",
|
||||
initiative: "max-w-2xl",
|
||||
tables: "max-w-2xl",
|
||||
calendar: "max-w-2xl",
|
||||
};
|
||||
|
||||
// ponytail: tools that accept a prefill. The map mirrors `PREFILLABLE` above
|
||||
// and exists so rehydrate() can find the right View for a given kind.
|
||||
const PREFILLABLE_TOOLS = {
|
||||
npcs: NpcGenerator,
|
||||
encounter: EncounterBuilder,
|
||||
world: WorldBuilder,
|
||||
items: ItemForge,
|
||||
quest: QuestDesigner,
|
||||
session: SessionLogger,
|
||||
image: ImageGenerator,
|
||||
} as const;
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>("dashboard");
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
// ponytail: prefill is set when the user clicks "Open in tool" in History.
|
||||
// The targeted tool reads it via usePrefillEffect and calls onConsumed
|
||||
// to clear it. State lives here so the rehydrate→switch→consume dance
|
||||
// doesn't depend on a shared store.
|
||||
const [prefill, setPrefill] = useState<Generation | null>(null);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
function rehydrate(kind: GenerationKind, data: Generation) {
|
||||
const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find(
|
||||
(v) => PREFILLABLE[v] === kind,
|
||||
);
|
||||
const target = KIND_TO_VIEW[kind];
|
||||
if (!target) return;
|
||||
setPrefill(data);
|
||||
setView(target);
|
||||
}
|
||||
|
||||
const completeWizard = () => {
|
||||
localStorage.setItem('dm-pal-wizard-completed', 'true');
|
||||
setShowWizard(false);
|
||||
};
|
||||
|
||||
// ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -198,6 +204,21 @@ export default function App() {
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && helpOpen) {
|
||||
setHelpOpen(false);
|
||||
return;
|
||||
}
|
||||
// ponytail: `?` opens the shortcut cheatsheet — the rail is icon-only,
|
||||
// so without this the digit shortcuts are undiscoverable. Not while
|
||||
// typing in an input (Shift+/ on a US layout yields `?`).
|
||||
if (
|
||||
e.key === "?" &&
|
||||
!(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
setHelpOpen((o) => !o);
|
||||
return;
|
||||
}
|
||||
// Digit shortcuts jump to nav items, but not while typing in an input.
|
||||
if (
|
||||
!paletteOpen &&
|
||||
@@ -221,7 +242,21 @@ export default function App() {
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [paletteOpen]);
|
||||
}, [paletteOpen, helpOpen]);
|
||||
|
||||
// Check if we need to show the first-run wizard
|
||||
useEffect(() => {
|
||||
const completed = localStorage.getItem('dm-pal-wizard-completed');
|
||||
if (!completed && view !== 'settings') {
|
||||
setShowWizard(true);
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'settings') {
|
||||
setShowWizard(false);
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
const isOnDashboard = view === "dashboard";
|
||||
const groupedNav = useMemo(
|
||||
@@ -245,7 +280,7 @@ export default function App() {
|
||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<item.icon size={item.view === "tables" ? 18 : 18} />
|
||||
<item.icon size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -356,33 +391,38 @@ export default function App() {
|
||||
</kbd>
|
||||
</button>
|
||||
<div className="ml-auto" data-tauri-no-drag />
|
||||
<GeneratingIndicator />
|
||||
<ConnectionPill />
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto" id="main-content">
|
||||
<ErrorBoundary>
|
||||
{isOnDashboard ? (
|
||||
<Dashboard onNavigate={setView} />
|
||||
) : view === "history" ? (
|
||||
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
||||
) : (
|
||||
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}>
|
||||
{view === "settings" ? (
|
||||
<div className="glass-card p-6">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Settings
|
||||
</h2>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card p-6">
|
||||
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
{showWizard ? (
|
||||
<FirstRunWizard onComplete={completeWizard} />
|
||||
) : (
|
||||
<ErrorBoundary>
|
||||
{isOnDashboard ? (
|
||||
<Dashboard onNavigate={setView} />
|
||||
) : view === "history" ? (
|
||||
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
||||
) : (
|
||||
<div className={`${TOOL_META[view]?.maxWidth ?? DEFAULT_MAX_WIDTH} mx-auto p-6`}>
|
||||
{view === "settings" ? (
|
||||
<div className="glass-card p-6">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Settings
|
||||
</h2>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card p-6">
|
||||
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -395,6 +435,9 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ponytail: `?` shortcut cheatsheet — discoverability for the icon-only rail. */}
|
||||
<ShortcutHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface DieResult {
|
||||
notation: string;
|
||||
rolls: number[];
|
||||
total: number;
|
||||
modifier: number;
|
||||
advantage?: "adv" | "dis" | null;
|
||||
keptRoll?: number;
|
||||
}
|
||||
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
||||
|
||||
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
||||
|
||||
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
|
||||
{ label: "Damage", die: "d8" },
|
||||
];
|
||||
|
||||
type Mode = "normal" | "adv" | "dis";
|
||||
|
||||
function rollDie(sides: number): number {
|
||||
return Math.floor(Math.random() * sides) + 1;
|
||||
}
|
||||
|
||||
function parseNotation(
|
||||
notation: string,
|
||||
): { count: number; sides: number; modifier: number } | null {
|
||||
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
count: parseInt(match[1] || "1"),
|
||||
sides: parseInt(match[2]),
|
||||
modifier: parseInt(match[3] || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
|
||||
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
|
||||
// meaningful for d20 rolls; for other dice we just roll normally.
|
||||
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
|
||||
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
|
||||
return { rolls, kept: rolls[0] ?? 0 };
|
||||
}
|
||||
if (mode === "adv") {
|
||||
const hi = Math.max(rolls[0], rolls[1]);
|
||||
return { rolls, kept: hi };
|
||||
}
|
||||
const lo = Math.min(rolls[0], rolls[1]);
|
||||
return { rolls, kept: lo };
|
||||
}
|
||||
// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
|
||||
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
||||
// types are imported from there too.
|
||||
|
||||
export function DiceRoller() {
|
||||
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
export function FirstRunWizard({ onComplete }: { onComplete: () => void }) {
|
||||
const [step, setStep] = useState("welcome");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// ponytail: wizard defaults to local Ollama; no API-key UI here (Settings covers remote).
|
||||
const apiUrl = "http://localhost:11434";
|
||||
const apiKey = "";
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "check") {
|
||||
checkOllama();
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
async function checkOllama() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
// Test connection to the default Ollama URL
|
||||
const result = await invoke<{ ok: boolean; models: string[]; error: string }>(
|
||||
"test_connection",
|
||||
{ config: { api_url: apiUrl, api_key: apiKey, model: "", temperature: 0.7, max_tokens: 512, top_p: 0.9, image_api_url: "", embed_model: "" } }
|
||||
);
|
||||
if (result.ok) {
|
||||
setModels(result.models);
|
||||
setStep("model");
|
||||
} else {
|
||||
setError(result.error || "Failed to connect to Ollama");
|
||||
setStep("error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setStep("error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function pullModel(model: string) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
// We don't have a direct pull command, but we can suggest the user to run `ollama pull` in terminal.
|
||||
// For now, we'll just set the model in config and hope it exists.
|
||||
// Alternatively, we could invoke a generate command to trigger a pull? Not sure.
|
||||
// We'll just set the config and complete.
|
||||
await invoke("set_llm_config", {
|
||||
config: {
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model: model,
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
top_p: 0.9,
|
||||
image_api_url: "http://localhost:1234",
|
||||
embed_model: "nomic-embed-text",
|
||||
}
|
||||
});
|
||||
addToast(`Model ${model} selected. You may need to run 'ollama pull ${model}' if not already downloaded.`, "success");
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setStep("error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function skip() {
|
||||
// Skip wizard and go to settings
|
||||
onComplete();
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (step === "model") {
|
||||
setStep("welcome");
|
||||
} else if (step === "error") {
|
||||
setStep("welcome");
|
||||
} else if (step === "done") {
|
||||
// Should not happen
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (step === "welcome") {
|
||||
setStep("check");
|
||||
return;
|
||||
}
|
||||
if (step === "model") {
|
||||
if (!selectedModel) {
|
||||
setError("Please select a model");
|
||||
return;
|
||||
}
|
||||
await pullModel(selectedModel);
|
||||
return;
|
||||
}
|
||||
if (step === "done") {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (step === "welcome") {
|
||||
return (
|
||||
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Welcome to DM-Pal
|
||||
</h2>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||
To generate content, DM-Pal needs a language model. Let's set up a local model using Ollama (recommended for privacy and offline use).
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => setStep("check")}
|
||||
disabled={loading}
|
||||
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 ? "Checking…" : "Check for Ollama"}
|
||||
</button>
|
||||
<button
|
||||
onClick={skip}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now (go to Settings)
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "check") {
|
||||
return (
|
||||
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Checking for Ollama
|
||||
</h2>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||
We're checking if Ollama is running at {apiUrl}...
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-[var(--color-gold-bright)] border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-[var(--color-text-dim)] text-xs">Checking…</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error ? (
|
||||
<p className="text-[var(--color-danger)] text-sm">{error}</p>
|
||||
) : (
|
||||
<p className="text-[var(--color-success)] text-sm">Ollama is running!</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setStep("model")}
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
Continue to model selection
|
||||
</button>
|
||||
<button
|
||||
onClick={skip}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now (go to Settings)
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "model") {
|
||||
return (
|
||||
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Select a Model
|
||||
</h2>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||
Ollama is running. Choose a model to use for text generation.
|
||||
</p>
|
||||
<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">Model</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
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) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
{m === "llama3.2" && " (recommended)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !selectedModel}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Setting model…" : "Set Model"}
|
||||
</button>
|
||||
<button
|
||||
onClick={back}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={skip}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now (go to Settings)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-[var(--color-danger)] text-sm mt-4">{error}</p>
|
||||
)}
|
||||
<p className="text-[var(--color-text-dim)] text-sm mt-4">
|
||||
If you don't see your model, you may need to download it first. In a terminal, run:{' '}
|
||||
<code className="font-mono text-[var(--color-gold-bright)]">ollama pull llama3.2</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "done") {
|
||||
return (
|
||||
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Setup Complete
|
||||
</h2>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||
DM-Pal is now configured to use {selectedModel} for text generation.
|
||||
Image generation runs via a stable-diffusion.cpp `sd-server` at http://localhost:1234 (cross-platform).
|
||||
Embedding model for lore search is set to nomic-embed-text.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => onComplete()}
|
||||
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"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Go to settings to review
|
||||
// We'll just complete and let the user navigate to settings if they want
|
||||
onComplete();
|
||||
}}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Review Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "error") {
|
||||
return (
|
||||
<div className="glass-card p-6 max-w-md w-full mx-auto">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Error
|
||||
</h2>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mb-4">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={() => setStep("welcome")}
|
||||
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"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={skip}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now (go to Settings)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface Props {
|
||||
@@ -20,22 +19,20 @@ type ImageEvent =
|
||||
| { type: "error"; data: string };
|
||||
|
||||
/**
|
||||
* Generates and caches an image for `prompt` via the Ollama image model.
|
||||
* Results are cached on disk by the backend, so re-renders are instant.
|
||||
* macOS-only: on other OSes the backend returns an error and we show a
|
||||
* placeholder instead of a confusing timeout.
|
||||
* Generates and caches an image for `prompt` via a stable-diffusion.cpp
|
||||
* `sd-server` (A1111-compatible API). Results are cached on disk by the
|
||||
* backend, so re-renders are instant. Cross-platform.
|
||||
*
|
||||
* Uses the streaming command so the DM sees a real progress bar (driven from
|
||||
* Ollama's NDJSON step/total) during the multi-second wait. The image still
|
||||
* loads even if no progress events arrive — Done carries the data URL.
|
||||
* `/sdapi/v1/progress`) during the multi-second wait. The image still loads
|
||||
* even if no progress events arrive — Done carries the data URL.
|
||||
*/
|
||||
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// ponytail: progress for the in-flight generation. step/total may be null
|
||||
// if Ollama omits them; we show an indeterminate shimmer then.
|
||||
// if the server omits them; we show an indeterminate shimmer then.
|
||||
const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -43,7 +40,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
if (!prompt) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setUnsupported(false);
|
||||
setProgress(null);
|
||||
|
||||
const channel = new Channel<ImageEvent>();
|
||||
@@ -56,9 +52,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
} else if (event.type === "error") {
|
||||
const msg = String(event.data);
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else addToast(`Image failed: ${msg}`, "error");
|
||||
addToast(`Image failed: ${event.data}`, "error");
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
@@ -66,9 +60,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
|
||||
invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => {
|
||||
if (!cancelled) {
|
||||
const msg = String(e);
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else addToast(`Image failed: ${msg}`, "error");
|
||||
addToast(`Image failed: ${e}`, "error");
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
@@ -83,17 +75,6 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
|
||||
const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`;
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<div className={box}>
|
||||
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
|
||||
<ImageOff size={20} />
|
||||
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// ponytail: determinate bar when step/total are known, indeterminate shimmer otherwise.
|
||||
const pct = progress && progress.step != null && progress.total ? Math.round((progress.step / progress.total) * 100) : null;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface GenBusyPayload {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
// ponytail: a title-bar "something is generating" signal. The Rust generate
|
||||
// commands emit `gen-busy` true at start / false at end; we keep a counter so
|
||||
// concurrent generations (NPC + image at once) balance to zero only when all
|
||||
// finish. Lets the DM navigate away from a 15s image gen and still see it's
|
||||
// working — the only per-card feedback vanishes the moment they switch views.
|
||||
export function GeneratingIndicator() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let count = 0;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let alive = true;
|
||||
|
||||
listen<GenBusyPayload>("gen-busy", (event) => {
|
||||
if (!alive) return;
|
||||
count += event.payload.busy ? 1 : -1;
|
||||
if (count < 0) count = 0; // guard against a stray false with no matching true
|
||||
setBusy(count > 0);
|
||||
}).then((u) => {
|
||||
unlisten = u;
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!busy) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tauri-no-drag
|
||||
className="ml-2 flex items-center gap-1.5 text-[var(--color-gold-bright)] text-xs select-none"
|
||||
title="A generation is in progress"
|
||||
aria-label="Generation in progress"
|
||||
>
|
||||
<Sparkles size={12} className="thinking-dot" />
|
||||
<span className="hidden sm:inline thinking-dot">Generating…</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export function Greet() {
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/
|
||||
setGreeting(await invoke("greet", { name }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<form
|
||||
className="flex gap-2 w-full max-w-md"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="flex-1 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-sm font-[var(--font-sans)]"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter a name…"
|
||||
value={name}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Greet
|
||||
</button>
|
||||
</form>
|
||||
{greeting && (
|
||||
<p className="text-[var(--color-text-secondary)] text-sm font-[var(--font-mono)]">
|
||||
{greeting}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
History as HistoryIcon,
|
||||
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: gallery thumbnails — fetch each image generation's data URL on
|
||||
// demand when the image filter is active. N queries against local SQLite
|
||||
// is fine for a campaign's worth of images; add a list-with-data command
|
||||
// if it ever gets slow. Ref is the source of truth; state mirrors it to
|
||||
// trigger re-renders when a thumbnail lands.
|
||||
const thumbsRef = useRef<Map<number, string>>(new Map());
|
||||
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (activeKind !== "image") return;
|
||||
const missing = summaries
|
||||
.filter((s) => s.kind === "image" && !thumbsRef.current.has(s.id))
|
||||
.map((s) => s.id);
|
||||
if (missing.length === 0) return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
for (const id of missing) {
|
||||
try {
|
||||
const g = await getGeneration(id);
|
||||
if (g && g.kind === "image" && g.data) thumbsRef.current.set(id, g.data);
|
||||
} catch {}
|
||||
}
|
||||
if (alive) setThumbs(new Map(thumbsRef.current));
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [activeKind, summaries]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
try {
|
||||
await deleteGeneration(id);
|
||||
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||
thumbsRef.current.delete(id);
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
} catch (e) {
|
||||
addToast(`Delete failed: ${e}`, "error");
|
||||
@@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const n = await clearAllGenerations();
|
||||
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||
setSummaries([]);
|
||||
thumbsRef.current.clear();
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
setSelected(null);
|
||||
setConfirmClear(false);
|
||||
} catch (e) {
|
||||
@@ -232,29 +263,62 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||
{KIND_LABELS[s.kind]}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||
{relativeTime(s.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||
{s.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{activeKind === "image" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filtered.map((s) => {
|
||||
const src = thumbs.get(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`group relative rounded-lg overflow-hidden border bg-[var(--color-bg-deep)] aspect-square cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "border-[var(--color-gold-bright)]"
|
||||
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title={s.title}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={s.title} className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-text-dim)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1.5 py-1">
|
||||
<span className="text-[10px] text-white/90 truncate block">{s.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||
{KIND_LABELS[s.kind]}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||
{relativeTime(s.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||
{s.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import { ImagePlus, Sparkles } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
|
||||
const DEFAULT_MODEL = "x/flux2-klein:4b";
|
||||
import { ImageSetupGuide } from "./ImageSetupGuide";
|
||||
|
||||
interface Props {
|
||||
prefill?: Generation | null;
|
||||
@@ -14,11 +13,11 @@ interface Props {
|
||||
|
||||
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [variants, setVariants] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
||||
@@ -31,22 +30,58 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setDataUrl(null);
|
||||
setVariants([]);
|
||||
try {
|
||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||
// so "regenerate" actually produces a new image instead of the cached one.
|
||||
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
|
||||
const url = await invoke<string>("generate_image", {
|
||||
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
||||
req: { prompt: `${prompt}${v}` },
|
||||
});
|
||||
setDataUrl(url);
|
||||
setUnsupported(false);
|
||||
addToast("Image generated", "success");
|
||||
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
||||
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
||||
void addGeneration({ kind: "image", title: prompt, data: url, source: "sd-server" });
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
||||
addToast(`Image generation failed: ${msg}`, "error");
|
||||
addToast(`Image generation failed: ${String(e)}`, "error");
|
||||
setGuideOpen(true); // ponytail: a failed generate usually means sd-server isn't running — pop the guide.
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// ponytail: batch = 4 concurrent generates with distinct variation tags so
|
||||
// the backend cache yields 4 different images. sd-server processes them
|
||||
// server-side anyway, so concurrency is just cleaner code than a loop with
|
||||
// awaits. Each variant is persisted so the gallery gets all four.
|
||||
async function generateBatch() {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setVariants([]);
|
||||
setDataUrl(null);
|
||||
const tags = [1, 2, 3, 4];
|
||||
const results = await Promise.allSettled(
|
||||
tags.map((t) =>
|
||||
invoke<string>("generate_image", {
|
||||
req: { prompt: `${prompt}\n\n(variation ${t})` },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ok: string[] = [];
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled" && r.value) {
|
||||
ok.push(r.value);
|
||||
void addGeneration({ kind: "image", title: prompt, data: r.value, source: "sd-server" });
|
||||
}
|
||||
}
|
||||
if (ok.length === 0) {
|
||||
const firstErr = results.find((r) => r.status === "rejected");
|
||||
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
|
||||
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
|
||||
setGuideOpen(true); // ponytail: likely sd-server not running — show the guide.
|
||||
} else {
|
||||
setVariants(ok);
|
||||
setDataUrl(ok[0]);
|
||||
addToast(`Generated ${ok.length} variants`, "success");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -59,6 +94,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<ImageSetupGuide forcedOpen={guideOpen} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
|
||||
<textarea
|
||||
@@ -70,18 +106,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
Model (optional — defaults to {DEFAULT_MODEL})
|
||||
</label>
|
||||
<input
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generate}
|
||||
@@ -90,6 +114,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
>
|
||||
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||
</button>
|
||||
<button
|
||||
onClick={generateBatch}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Generate 4 variants and pick the best"
|
||||
>
|
||||
{loading ? "…" : "4×"}
|
||||
</button>
|
||||
{dataUrl && !loading && (
|
||||
<button
|
||||
onClick={regenerate}
|
||||
@@ -107,14 +139,28 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unsupported && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
||||
Image generation is macOS-only via Ollama (for now). On other platforms the
|
||||
✨ buttons elsewhere fall back to a placeholder.
|
||||
{variants.length > 1 && !loading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
|
||||
<div className="grid grid-cols-2 gap-2 w-full max-w-md mx-auto">
|
||||
{variants.map((v, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setDataUrl(v)}
|
||||
className={`rounded-lg overflow-hidden border aspect-square cursor-pointer transition-colors ${
|
||||
dataUrl === v
|
||||
? "border-[var(--color-gold-bright)]"
|
||||
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title={`Variant ${i + 1}`}
|
||||
>
|
||||
<img src={v} alt={`Variant ${i + 1}`} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{dataUrl && !loading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||
@@ -145,7 +191,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dataUrl && !loading && !unsupported && (
|
||||
{!dataUrl && !loading && (
|
||||
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
|
||||
<span>No image yet.</span>
|
||||
<span className="mt-1">Describe a scene or portrait, then Generate.</span>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ChevronDown, ExternalLink, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
// ponytail: a non-technical DM won't read the README or build sd-server from
|
||||
// source. This is the one place they'll look — the Image tab — so the full
|
||||
// step-by-step lives here as a collapsible panel. The "Test" button mirrors
|
||||
// Settings so they get a green check without leaving the tab.
|
||||
|
||||
interface Props {
|
||||
/** Control from the parent so a failed generate can pop this open. */
|
||||
forcedOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ImageSetupGuide({ forcedOpen = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: boolean; error: string } | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: when the parent reports a connection failure, expand so the DM
|
||||
// sees the fix immediately instead of staring at a toast.
|
||||
const isOpen = open || forcedOpen;
|
||||
|
||||
async function test() {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
|
||||
setResult(r);
|
||||
if (r.ok) addToast("sd-server reachable — image generation is ready", "success");
|
||||
else addToast(`Not reachable: ${r.error}`, "error");
|
||||
} catch (e) {
|
||||
setResult({ ok: false, error: String(e) });
|
||||
addToast(`Not reachable: ${e}`, "error");
|
||||
}
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer select-none"
|
||||
>
|
||||
<span>⚙️ First time? Set up image generation (5-minute guide)</span>
|
||||
<ChevronDown size={14} className={`transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-[var(--color-border-subtle)] flex flex-col gap-3 text-xs leading-relaxed text-[var(--color-text-secondary)]">
|
||||
<p>
|
||||
DM-Pal makes images through a small free helper program called{" "}
|
||||
<strong className="text-[var(--color-text-primary)]">sd-server</strong> (part of{" "}
|
||||
<a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
stable-diffusion.cpp
|
||||
</a>
|
||||
). It runs on your computer alongside Ollama. Set it up once and you're done.
|
||||
</p>
|
||||
|
||||
<Step n={1} title="Download sd-server">
|
||||
Go to the{" "}
|
||||
<a href="https://github.com/leejet/stable-diffusion.cpp/releases" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
releases page <ExternalLink size={10} className="inline" />
|
||||
</a>{" "}
|
||||
and download the latest build for your system:
|
||||
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||
<li><strong className="text-[var(--color-text-primary)]">Windows</strong> — the <code className="font-mono">sd-server-win</code> zip</li>
|
||||
<li><strong className="text-[var(--color-text-primary)]">Mac (Apple Silicon)</strong> — the <code className="font-mono">sd-server-macos</code> zip</li>
|
||||
<li><strong className="text-[var(--color-text-primary)]">Linux</strong> — the <code className="font-mono">sd-server-linux</code> build</li>
|
||||
</ul>
|
||||
Unzip it into a permanent folder, e.g. <code className="font-mono">sd-server</code> in your home folder.
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Download a model (recommended: SDXL — great fantasy art, 2 files)">
|
||||
Put these two files in your <code className="font-mono">sd-server</code> folder:
|
||||
<ul className="list-disc pl-5 mt-1 space-y-0.5">
|
||||
<li>
|
||||
Model (~6.5 GB):{" "}
|
||||
<a href="https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/blob/main/sd_xl_base_1.0.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
sd_xl_base_1.0.safetensors <ExternalLink size={10} className="inline" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
VAE (~0.6 GB):{" "}
|
||||
<a href="https://huggingface.co/madebyollin/sdxl-vae-fp16-fix/blob/main/sdxl_vae-fp16-fix.safetensors" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
sdxl_vae-fp16-fix.safetensors <ExternalLink size={10} className="inline" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-1 text-[var(--color-text-dim)]">
|
||||
On the HuggingFace page click the ↓ next to the file to download it.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Start the server (leave the window open)">
|
||||
Open a terminal — <em>Mac:</em> Terminal (Applications → Utilities); <em>Windows:</em> Command Prompt.
|
||||
Move into your folder, then run:
|
||||
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
|
||||
{`# Mac / Linux
|
||||
./sd-server -m sd_xl_base_1.0.safetensors \\
|
||||
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu
|
||||
|
||||
# Windows
|
||||
sd-server.exe -m sd_xl_base_1.0.safetensors \\
|
||||
--vae sdxl_vae-fp16-fix.safetensors --offload-to-cpu`}</pre>
|
||||
<p className="mt-1 text-[var(--color-text-dim)]">
|
||||
<code className="font-mono">--offload-to-cpu</code> lets it run on machines without a strong GPU (slower). You'll see it listening on <code className="font-mono">http://127.0.0.1:1234</code>.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={4} title="Point DM-Pal at it & test">
|
||||
In <strong className="text-[var(--color-text-primary)]">Settings → Image Server</strong>, make sure the URL is{" "}
|
||||
<code className="font-mono">http://localhost:1234</code>. Then test it right here:
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={test}
|
||||
disabled={testing}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing…" : "Test sd-server connection"}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`flex items-center gap-1 text-xs ${result.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
|
||||
{result.ok ? <CheckCircle2 size={14} /> : <XCircle size={14} />}
|
||||
{result.ok ? "Connected — ready to generate" : result.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<details className="rounded-lg border border-[var(--color-border-subtle)] px-3 py-2">
|
||||
<summary className="text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)] select-none">
|
||||
Slower laptop? Try the lightweight option (Z-Image-Turbo, ~4 GB VRAM, 3 files)
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
<p>Download these three files into your <code className="font-mono">sd-server</code> folder:</p>
|
||||
<ul className="list-disc pl-5 space-y-0.5">
|
||||
<li>Diffusion model:{" "}
|
||||
<a href="https://huggingface.co/leejet/Z-Image-Turbo-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
z_image_turbo-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
|
||||
</a>
|
||||
</li>
|
||||
<li>VAE:{" "}
|
||||
<a href="https://huggingface.co/black-forest-labs/FLUX.1-schnell/blob/main/ae.sft" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
ae.sft <ExternalLink size={10} className="inline" />
|
||||
</a>
|
||||
</li>
|
||||
<li>Text encoder:{" "}
|
||||
<a href="https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF" target="_blank" rel="noreferrer" className="text-[var(--color-gold-bright)] underline">
|
||||
Qwen3-4B-Instruct-2507-Q4_K_M.gguf <ExternalLink size={10} className="inline" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<pre className="mt-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[10px] font-mono overflow-x-auto whitespace-pre">
|
||||
{`./sd-server --diffusion-model z_image_turbo-Q4_K_M.gguf \\
|
||||
--vae ae.sft --llm Qwen3-4B-Instruct-2507-Q4_K_M.gguf \\
|
||||
--cfg-scale 1.0 --diffusion-fa --offload-to-cpu`}</pre>
|
||||
<p className="text-[var(--color-text-dim)]">
|
||||
Z-Image-Turbo is built for speed (few steps), so it generates faster than SDXL on modest hardware.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-[var(--color-text-primary)]">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] text-[10px] font-bold mr-1.5 align-middle">
|
||||
{n}
|
||||
</span>
|
||||
{title}
|
||||
</p>
|
||||
<div className="pl-[26px]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import { addToLore } from "../lib/lore";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function LorePanel() {
|
||||
const [text, setText] = useState("");
|
||||
const [sources, setSources] = useState<RagSource[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addingDir, setAddingDir] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -117,6 +119,34 @@ export function LorePanel() {
|
||||
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
|
||||
}
|
||||
|
||||
// ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only).
|
||||
// Doing the walk in Rust sidesteps the webview fs scope — an external
|
||||
// world-bible folder needs no fs permission grant this way.
|
||||
async function addDirectory() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
setAddingDir(true);
|
||||
try {
|
||||
const report = await invoke<{ files: number; chunks: number; skipped: string[] }>(
|
||||
"rag_add_directory",
|
||||
{ req: { path: selected } },
|
||||
);
|
||||
await loadSources();
|
||||
if (report.files === 0) {
|
||||
addToast("No .md / .txt files found in that folder", "info");
|
||||
} else {
|
||||
addToast(
|
||||
`Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
for (const s of report.skipped) console.warn("lore dir import skipped:", s);
|
||||
} catch (e) {
|
||||
addToast(`Folder import failed: ${e}`, "error");
|
||||
}
|
||||
setAddingDir(false);
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
@@ -168,6 +198,16 @@ export function LorePanel() {
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
{/* ponytail: directory import — walks every .md/.txt recursively in
|
||||
Rust (no webview fs-scope needed for an external folder). */}
|
||||
<button
|
||||
onClick={addDirectory}
|
||||
disabled={addingDir}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-sm text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{addingDir ? "Indexing…" : "Add folder…"}
|
||||
</button>
|
||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ interface LlmConfig {
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
top_p: number;
|
||||
image_model: string;
|
||||
image_api_url: string;
|
||||
embed_model: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export function SettingsPanel() {
|
||||
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
||||
const [dataDir, setDataDir] = useState<DataDirInfo | null>(null);
|
||||
const [relocating, setRelocating] = useState(false);
|
||||
// ponytail: probe the sd-server so a non-technical DM gets a green check, same pattern as the LLM test.
|
||||
const [imgTesting, setImgTesting] = useState(false);
|
||||
const [imgConn, setImgConn] = useState<{ ok: boolean; error: string } | null>(null);
|
||||
// ponytail: advanced SQLite viewer — read-only, two DBs (lore.db / generations.db).
|
||||
const [sqlDb, setSqlDb] = useState<"lore" | "generations">("generations");
|
||||
const [sqlInput, setSqlInput] = useState("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name");
|
||||
@@ -153,6 +156,24 @@ export function SettingsPanel() {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
// ponytail: probe the configured sd-server. Save first so it uses the latest URL.
|
||||
async function testImage() {
|
||||
if (!config) return;
|
||||
setImgTesting(true);
|
||||
setImgConn(null);
|
||||
try {
|
||||
await invoke("set_llm_config", { config });
|
||||
const r = await invoke<{ ok: boolean; error: string }>("test_image_connection");
|
||||
setImgConn(r);
|
||||
if (r.ok) addToast("sd-server reachable", "success");
|
||||
else addToast(`Image server not reachable: ${r.error}`, "error");
|
||||
} catch (e) {
|
||||
setImgConn({ ok: false, error: String(e) });
|
||||
addToast(`Image server not reachable: ${e}`, "error");
|
||||
}
|
||||
setImgTesting(false);
|
||||
}
|
||||
|
||||
// ponytail: provider presets fill in the API URL pattern + default model.
|
||||
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
|
||||
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
|
||||
@@ -177,7 +198,7 @@ export function SettingsPanel() {
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
top_p: 0.9,
|
||||
image_model: "x/flux2-klein:4b",
|
||||
image_api_url: "http://localhost:1234",
|
||||
embed_model: "nomic-embed-text",
|
||||
};
|
||||
function resetDefaults() {
|
||||
@@ -386,17 +407,39 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Model</h3>
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Server</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
Image model
|
||||
sd-server URL
|
||||
</label>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||
Base URL of a <a href="https://github.com/leejet/stable-diffusion.cpp" target="_blank" rel="noreferrer" className="underline">stable-diffusion.cpp</a> `sd-server` (A1111-compatible API). Start it with your chosen model, e.g.<br /><code className="font-mono">sd-server -m model.safetensors</code>
|
||||
</p>
|
||||
<input
|
||||
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)]"
|
||||
value={config.image_model}
|
||||
onChange={(e) => setConfig({ ...config, image_model: e.target.value })}
|
||||
placeholder="x/flux2-klein:4b"
|
||||
value={config.image_api_url}
|
||||
onChange={(e) => setConfig({ ...config, image_api_url: e.target.value })}
|
||||
placeholder="http://localhost:1234"
|
||||
/>
|
||||
{/* ponytail: mirror the LLM test button so a non-technical DM gets a green check */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={testImage}
|
||||
disabled={imgTesting}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{imgTesting ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
{imgConn && (
|
||||
<span className={`text-xs ${imgConn.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
|
||||
{imgConn.ok ? "✓ sd-server reachable" : `✕ ${imgConn.error}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)]">
|
||||
New to this? Open the <strong>Image</strong> tab → "First time? Set up image generation" for a full step-by-step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Embedding Model (Lore RAG)</h3>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { navItems } from "../App";
|
||||
|
||||
interface ShortcutHelpProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ponytail: a single source of truth for the global shortcuts, shown via the
|
||||
// `?` key. Tool-local shortcuts (space-to-roll in Dice) live in their tool's
|
||||
// own help text — this is only the app-shell set the DM can't otherwise
|
||||
// discover because the rail is icon-only.
|
||||
const GLOBAL: { keys: string; label: string }[] = [
|
||||
{ keys: "⌘K", label: "Command palette — jump to any tool" },
|
||||
{ keys: "⌘H", label: "History — re-open a past generation" },
|
||||
{ keys: "⌘,", label: "Settings" },
|
||||
{ keys: "Esc", label: "Close palette / overlay" },
|
||||
{ keys: "?", label: "This help" },
|
||||
];
|
||||
|
||||
export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) {
|
||||
if (!open) return null;
|
||||
|
||||
// ponytail: digit shortcuts come from navItems (single source of truth), so
|
||||
// this list can't drift from the rail. Only items with a shortcut show.
|
||||
const digits = navItems.filter((n) => n.shortcut);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div
|
||||
className="relative w-full max-w-md glass-card p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Keyboard size={16} className="text-[var(--color-gold-bright)]" />
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-wide">
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Global shortcuts */}
|
||||
<ul className="flex flex-col gap-1.5 mb-3">
|
||||
{GLOBAL.map((s) => (
|
||||
<li key={s.keys} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">{s.label}</span>
|
||||
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||
{s.keys}
|
||||
</kbd>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Digit shortcuts — derived from the rail so they can't drift. */}
|
||||
{digits.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-[var(--color-border-subtle)] pt-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
|
||||
Jump to tool
|
||||
</span>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{digits.map((n) => (
|
||||
<li key={n.view} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-[var(--color-text-secondary)] truncate">{n.label}</span>
|
||||
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||
{n.shortcut}
|
||||
</kbd>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-[var(--color-border-subtle)] text-[10px] text-[var(--color-text-dim)]">
|
||||
<span>In a tool? Each one lists its own shortcuts inline.</span>
|
||||
<span>esc to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+141
-63
@@ -27,10 +27,16 @@ const SFX_SOUNDS = [
|
||||
{ id: "footsteps", label: "Steps", icon: "👣" },
|
||||
];
|
||||
|
||||
type Scene = { name: string; ambients: string[] };
|
||||
|
||||
export function Soundboard() {
|
||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
||||
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
|
||||
// ponytail: scenes store only the set of ambient ids + a name; per-scene
|
||||
// volume is deferred (global volume is enough until a DM actually asks).
|
||||
const [scenes, setScenes] = usePersistentState<Scene[]>("sound.scenes", []);
|
||||
const [newSceneName, setNewSceneName] = useState("");
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||
const { addToast } = useToast();
|
||||
@@ -81,69 +87,89 @@ export function Soundboard() {
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
// Shared start/stop so scenes and the toggle button route through one path.
|
||||
const startAmbient = useCallback((id: string) => {
|
||||
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
||||
if (!sound) return;
|
||||
const ctx = getAudioCtx();
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0; // Start silent, fade in
|
||||
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = sound.filterFreq;
|
||||
|
||||
let source: OscillatorNode | AudioBufferSourceNode;
|
||||
if (sound.type === "brown") {
|
||||
// Brown noise via buffer
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
data[i] = (last + 0.02 * white) / 1.02;
|
||||
last = data[i];
|
||||
data[i] *= 3.5; // Normalize
|
||||
}
|
||||
source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
} else {
|
||||
source = ctx.createOscillator();
|
||||
source.type = sound.type;
|
||||
source.frequency.value = sound.freq;
|
||||
}
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
||||
}, [volume, getAudioCtx]);
|
||||
|
||||
const stopAmbient = useCallback((id: string) => {
|
||||
const nodes = nodesRef.current.get(id);
|
||||
if (!nodes) return;
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
try { nodes.source.stop(); } catch {}
|
||||
nodesRef.current.delete(id);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
nodesRef.current.forEach((_, id) => stopAmbient(id));
|
||||
nodesRef.current.clear();
|
||||
setActiveAmbients(new Set());
|
||||
}, [stopAmbient]);
|
||||
|
||||
const toggleAmbient = useCallback((id: string) => {
|
||||
setActiveAmbients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
// Stop the sound
|
||||
const nodes = nodesRef.current.get(id);
|
||||
if (nodes) {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
nodes.source.stop();
|
||||
nodesRef.current.delete(id);
|
||||
}, 600);
|
||||
}
|
||||
stopAmbient(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
// Start the sound
|
||||
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
||||
if (!sound) return next;
|
||||
|
||||
const ctx = getAudioCtx();
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0; // Start silent, fade in
|
||||
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = sound.filterFreq;
|
||||
|
||||
let source: OscillatorNode | AudioBufferSourceNode;
|
||||
|
||||
if (sound.type === "brown") {
|
||||
// Brown noise via buffer
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
data[i] = (last + 0.02 * white) / 1.02;
|
||||
last = data[i];
|
||||
data[i] *= 3.5; // Normalize
|
||||
}
|
||||
source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
} else {
|
||||
// Oscillator
|
||||
source = ctx.createOscillator();
|
||||
source.type = sound.type;
|
||||
source.frequency.value = sound.freq;
|
||||
}
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
||||
startAmbient(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [volume, getAudioCtx]);
|
||||
}, [startAmbient, stopAmbient]);
|
||||
|
||||
// Apply a saved scene: stop everything, then start the scene's ambients.
|
||||
const applyScene = useCallback((scene: Scene) => {
|
||||
stopAll();
|
||||
// Defer starts one tick so the stop ramps clear first; ambients are
|
||||
// independent ids so a small delay keeps the fade clean.
|
||||
setTimeout(() => {
|
||||
setActiveAmbients(new Set(scene.ambients));
|
||||
scene.ambients.forEach((id) => startAmbient(id));
|
||||
}, 50);
|
||||
addToast(`Scene: ${scene.name}`, "info");
|
||||
}, [stopAll, startAmbient, addToast]);
|
||||
|
||||
const playSfx = useCallback((id: string) => {
|
||||
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||
@@ -359,18 +385,70 @@ export function Soundboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenes — one-click named combinations of the ambients above. */}
|
||||
<div>
|
||||
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
|
||||
🎭 Scenes
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{scenes.map((scene, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex items-center gap-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] pl-2.5 pr-1 py-1 transition-colors"
|
||||
>
|
||||
<button
|
||||
onClick={() => applyScene(scene)}
|
||||
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
|
||||
title={`Play: ${scene.ambients.map((id) => AMBIENT_SOUNDS.find((s) => s.id === id)?.label).filter(Boolean).join(", ")}`}
|
||||
>
|
||||
{scene.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScenes((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-xs px-1 cursor-pointer"
|
||||
aria-label={`Delete scene ${scene.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{scenes.length === 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">No scenes yet — turn some ambients on and save the combo.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={newSceneName}
|
||||
onChange={(e) => setNewSceneName(e.target.value)}
|
||||
placeholder={activeAmbients.size ? "Scene name (e.g. Tavern)" : "Turn ambients on first"}
|
||||
disabled={activeAmbients.size === 0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newSceneName.trim() && activeAmbients.size) {
|
||||
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||
setNewSceneName("");
|
||||
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newSceneName.trim() || activeAmbients.size === 0) return;
|
||||
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||
setNewSceneName("");
|
||||
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||
}}
|
||||
disabled={!newSceneName.trim() || activeAmbients.size === 0}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save scene
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||
<button
|
||||
onClick={() => {
|
||||
nodesRef.current.forEach((nodes) => {
|
||||
try {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
||||
} catch {}
|
||||
});
|
||||
nodesRef.current.clear();
|
||||
setActiveAmbients(new Set());
|
||||
}}
|
||||
onClick={stopAll}
|
||||
disabled={activeAmbients.size === 0}
|
||||
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addGeneration, extractTitle, type Generation } from "../lib/generations
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
import { GeneratedImage } from "./GeneratedImage";
|
||||
|
||||
interface WorldResponse {
|
||||
name: string;
|
||||
@@ -31,6 +32,8 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [mapPrompt, setMapPrompt] = useState<string | null>(null);
|
||||
const [mapNonce, setMapNonce] = useState(0);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: record a dragged pin's position into the world so it persists.
|
||||
@@ -98,6 +101,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
`World: ${parsed.name || theme}`,
|
||||
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
||||
);
|
||||
// Generate map image prompt
|
||||
const mapPrompt = `fantasy map of ${parsed.name}, ${parsed.description}, parchment texture, ink cartography, top-down`;
|
||||
setMapPrompt(mapPrompt);
|
||||
setMapNonce((n) => n + 1);
|
||||
} else {
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
@@ -157,6 +164,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{world.description}
|
||||
</p>
|
||||
</div>
|
||||
{mapPrompt && (
|
||||
<div className="mt-4">
|
||||
<GeneratedImage prompt={mapPrompt} nonce={mapNonce} className="w-full" aspect="aspect-[3/2]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// ponytail: dice notation parsing + advantage/disadvantage, extracted from
|
||||
// DiceRoller so the math is testable without the component. Pure functions,
|
||||
// no React, no DOM.
|
||||
|
||||
export type Mode = "normal" | "adv" | "dis";
|
||||
|
||||
export interface ParsedNotation {
|
||||
count: number;
|
||||
sides: number;
|
||||
modifier: number;
|
||||
}
|
||||
|
||||
// ponytail: one rolled result, shaped for the history list + breakdown view.
|
||||
// Re-exported by DiceRoller so the component owns the display but not the
|
||||
// shape.
|
||||
export interface DieResult {
|
||||
notation: string;
|
||||
rolls: number[];
|
||||
total: number;
|
||||
modifier: number;
|
||||
advantage?: Mode | null;
|
||||
keptRoll?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a dice notation string: `[count]d<sides>[+/-modifier]`.
|
||||
* Case-insensitive, trims whitespace. Returns null on a bad expression.
|
||||
* Examples: `d20`, `1d20`, `2d6+3`, `d100-2`, `4d8`.
|
||||
*/
|
||||
export function parseNotation(
|
||||
notation: string,
|
||||
): ParsedNotation | null {
|
||||
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
count: parseInt(match[1] || "1"),
|
||||
sides: parseInt(match[2]),
|
||||
modifier: parseInt(match[3] || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply advantage/disadvantage to a 2-roll d20 set. For non-d20 or non-pair
|
||||
* rolls, the first roll is kept unchanged. Returns the rolls to display plus
|
||||
* the single value to add to the modifier.
|
||||
*/
|
||||
export function applyMode(
|
||||
rolls: number[],
|
||||
sides: number,
|
||||
mode: Mode,
|
||||
): { rolls: number[]; kept: number } {
|
||||
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
|
||||
return { rolls, kept: rolls[0] ?? 0 };
|
||||
}
|
||||
if (mode === "adv") {
|
||||
return { rolls, kept: Math.max(rolls[0], rolls[1]) };
|
||||
}
|
||||
return { rolls, kept: Math.min(rolls[0], rolls[1]) };
|
||||
}
|
||||
|
||||
// ─── Self-check (run: node scripts/check-dice.ts) ──────────────
|
||||
// ponytail: the smallest thing that fails if the parser breaks. No framework
|
||||
// — plain asserts. Covers the notations a DM types every session plus the
|
||||
// adv/dis keep rule and the boundary cases that silently break (negative
|
||||
// mods, bare `d20`, `d100`, rejects garbage).
|
||||
function assert(cond: boolean, msg: string): void {
|
||||
if (!cond) throw new Error(`dice self-check failed: ${msg}`);
|
||||
}
|
||||
|
||||
export function demo(): void {
|
||||
// Basic shapes.
|
||||
assert(parseNotation("d20")!.count === 1, "bare d20 → count 1");
|
||||
assert(parseNotation("1d20")!.sides === 20, "1d20 sides");
|
||||
assert(parseNotation("2d6+3")!.count === 2, "2d6+3 count");
|
||||
assert(parseNotation("2d6+3")!.sides === 6, "2d6+3 sides");
|
||||
assert(parseNotation("2d6+3")!.modifier === 3, "2d6+3 mod +3");
|
||||
assert(parseNotation("1d20-2")!.modifier === -2, "negative modifier");
|
||||
assert(parseNotation("d100")!.sides === 100, "d100 sides");
|
||||
|
||||
// Case + whitespace tolerance.
|
||||
assert(parseNotation(" 4D8 ")!.count === 4, "uppercase + whitespace");
|
||||
assert(parseNotation("3d8-5")!.modifier === -5, "3d8-5 mod -5");
|
||||
|
||||
// Garbage rejected.
|
||||
assert(parseNotation("hello") === null, "words rejected");
|
||||
assert(parseNotation("2d") === null, "missing sides rejected");
|
||||
assert(parseNotation("d20+abc") === null, "non-numeric mod rejected");
|
||||
assert(parseNotation("") === null, "empty rejected");
|
||||
|
||||
// Advantage keeps the higher of two d20s; disadvantage the lower.
|
||||
assert(applyMode([5, 17], 20, "adv").kept === 17, "adv keeps high");
|
||||
assert(applyMode([5, 17], 20, "dis").kept === 5, "dis keeps low");
|
||||
// Non-d20 rolls ignore mode entirely.
|
||||
assert(applyMode([4, 6], 6, "adv").kept === 4, "non-d20 adv keeps first");
|
||||
// Single roll (no comparison possible) keeps the first.
|
||||
assert(applyMode([12], 20, "adv").kept === 12, "single roll keeps first");
|
||||
// Normal mode keeps the first regardless of count.
|
||||
assert(applyMode([8, 15], 20, "normal").kept === 8, "normal keeps first");
|
||||
|
||||
console.log("dice self-check passed ✓");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// ponytail: reuses the in-source demo() self-check so there's one set of
|
||||
// assertions, not two. Run: node scripts/check-encounter-budget.ts (or any
|
||||
// TS runner). No test framework, no @types/node dependency.
|
||||
import { demo } from "./encounter-budget";
|
||||
|
||||
demo();
|
||||
@@ -62,3 +62,45 @@ export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
|
||||
Hard: "var(--color-gold-bright)",
|
||||
Deadly: "var(--color-danger)",
|
||||
};
|
||||
|
||||
// ─── Self-check (run: node scripts/check-encounter-budget.ts) ────
|
||||
// ponytail: the smallest thing that fails if the budget math breaks. No
|
||||
// framework — plain asserts. Verifies the per-level table scales, the
|
||||
// party-size multiplier, level clamping, and the XP→difficulty bucketing
|
||||
// (the boundary the DMG relies on: a budget-equal XP is the *next* tier).
|
||||
function assert(cond: boolean, msg: string): void {
|
||||
if (!cond) throw new Error(`encounter-budget self-check failed: ${msg}`);
|
||||
}
|
||||
|
||||
export function demo(): void {
|
||||
// Level 5, 4 PCs — DMG p.82 per-character: Easy 250 / Med 500 / Hard 750 / Deadly 1100.
|
||||
const b = encounterBudget(5, 4);
|
||||
assert(b.easy === 1000, "L5×4 easy = 250×4");
|
||||
assert(b.medium === 2000, "L5×4 medium = 500×4");
|
||||
assert(b.hard === 3000, "L5×4 hard = 750×4");
|
||||
assert(b.deadly === 4400, "L5×4 deadly = 1100×4");
|
||||
|
||||
// Solo L1 character.
|
||||
const solo = encounterBudget(1, 1);
|
||||
assert(solo.easy === 25 && solo.deadly === 100, "L1×1 matches table");
|
||||
|
||||
// Level clamps to 1..20; size clamps to >=1.
|
||||
const low = encounterBudget(0, 1);
|
||||
assert(low.easy === 25, "level clamps up to 1");
|
||||
const high = encounterBudget(99, 1);
|
||||
assert(high.deadly === 12700, "level clamps down to 20");
|
||||
const zero = encounterBudget(5, 0);
|
||||
assert(zero.medium === 500, "size clamps up to 1");
|
||||
|
||||
// difficultyForXp: budget-equal XP lands in the *next* tier up (>=, not >),
|
||||
// and anything below the easy floor is still Easy (never undefined).
|
||||
const d = encounterBudget(5, 4);
|
||||
assert(difficultyForXp(999, d) === "Easy", "below easy floor stays Easy");
|
||||
assert(difficultyForXp(1000, d) === "Easy", "== easy budget is Easy");
|
||||
assert(difficultyForXp(2000, d) === "Medium", "== medium budget is Medium");
|
||||
assert(difficultyForXp(3000, d) === "Hard", "== hard budget is Hard");
|
||||
assert(difficultyForXp(4400, d) === "Deadly", "== deadly budget is Deadly");
|
||||
assert(difficultyForXp(9999, d) === "Deadly", "over deadly stays Deadly");
|
||||
|
||||
console.log("encounter-budget self-check passed ✓");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user