building out the components, updating the UI in overview and adding a little sqlite viewer
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface BentoCardProps {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
span?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ponytail: callers pass responsive span classes directly (e.g.
|
||||
// "sm:col-span-2"). No string regex — the old replace() mangled pre-responsive
|
||||
// classes and silently dropped unknown spans like col-span-3.
|
||||
|
||||
export function BentoCard({
|
||||
title,
|
||||
icon,
|
||||
span = "",
|
||||
children,
|
||||
className = "",
|
||||
}: BentoCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`glass-card flex flex-col overflow-hidden p-4 ${span} ${className}`}
|
||||
// ponytail: drop the hover scale — 12 cards bobs noticeably when the
|
||||
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
|
||||
whileTap={{ scale: 0.985 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 pb-2 mb-3 border-b border-[var(--color-border-glass)]">
|
||||
<span className="text-[var(--color-gold-bright)]">{icon}</span>
|
||||
<h2 className="font-heading text-sm font-semibold tracking-wide text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+349
-120
@@ -1,60 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
const MONTHS = [
|
||||
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
|
||||
"Flamerule", "Eleasis", "Eleint", "Marpenoth", "Uktar", "Nightal",
|
||||
// ponytail: the calendar is user-configurable for homebrew campaigns.
|
||||
// `months` carry their own day count so a custom calendar can have variable
|
||||
// month lengths. Default = the Forgotten Realms calendar (12 × 30).
|
||||
interface CalMonth {
|
||||
name: string;
|
||||
days: number;
|
||||
}
|
||||
interface CalConfig {
|
||||
months: CalMonth[];
|
||||
yearLabel: string; // suffix shown after the year, e.g. "DR"
|
||||
}
|
||||
|
||||
const FR_MONTHS: CalMonth[] = [
|
||||
{ name: "Hammer", days: 30 }, { name: "Alturiak", days: 30 }, { name: "Ches", days: 30 },
|
||||
{ name: "Tarsakh", days: 30 }, { name: "Mirtul", days: 30 }, { name: "Kythorn", days: 30 },
|
||||
{ name: "Flamerule", days: 30 }, { name: "Eleasis", days: 30 }, { name: "Eleint", days: 30 },
|
||||
{ name: "Marpenoth", days: 30 }, { name: "Uktar", days: 30 }, { name: "Nightal", days: 30 },
|
||||
];
|
||||
const DEFAULT_CONFIG: CalConfig = { months: FR_MONTHS, yearLabel: "DR" };
|
||||
|
||||
// ponytail: named categories with fixed colors so events are color-coded by
|
||||
// intent, not cycled arbitrarily. A DM picks the category when creating.
|
||||
const CATEGORIES = [
|
||||
{ label: "Festival", color: "#e6cc80" },
|
||||
{ label: "Quest", color: "var(--color-gold-bright)" },
|
||||
{ label: "NPC", color: "var(--color-info)" },
|
||||
{ label: "Political", color: "#a335ee" },
|
||||
{ label: "Combat", color: "var(--color-danger)" },
|
||||
{ label: "Custom", color: "var(--color-success)" },
|
||||
];
|
||||
const catColor = (cat: string) => CATEGORIES.find((c) => c.label === cat)?.color ?? "var(--color-gold-bright)";
|
||||
|
||||
type Recur = "none" | "yearly" | "monthly";
|
||||
|
||||
interface CalendarEvent {
|
||||
day: number;
|
||||
month: number;
|
||||
text: string;
|
||||
color: string;
|
||||
category: string;
|
||||
recur: Recur;
|
||||
}
|
||||
|
||||
const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-success)", "var(--color-danger)"];
|
||||
function yearLen(cfg: CalConfig): number {
|
||||
return cfg.months.reduce((s, m) => s + m.days, 0);
|
||||
}
|
||||
function daysInMonth(cfg: CalConfig, m: number): number {
|
||||
return cfg.months[m]?.days ?? 30;
|
||||
}
|
||||
function monthName(cfg: CalConfig, m: number): string {
|
||||
return cfg.months[m]?.name ?? `Month ${m + 1}`;
|
||||
}
|
||||
// absolute day index for a (day,month,year) under a variable-length calendar.
|
||||
function dayIndex(day: number, month: number, year: number, cfg: CalConfig): number {
|
||||
let idx = year * yearLen(cfg);
|
||||
for (let i = 0; i < month; i++) idx += cfg.months[i].days;
|
||||
return idx + (day - 1);
|
||||
}
|
||||
// inverse of dayIndex.
|
||||
function fromIndex(idx: number, cfg: CalConfig): { day: number; month: number; year: number } {
|
||||
const yl = yearLen(cfg);
|
||||
let year = Math.floor(idx / yl);
|
||||
let rem = idx % yl;
|
||||
let month = 0;
|
||||
while (month < cfg.months.length && rem >= cfg.months[month].days) {
|
||||
rem -= cfg.months[month].days;
|
||||
month++;
|
||||
}
|
||||
return { day: rem + 1, month, year };
|
||||
}
|
||||
|
||||
export function CalendarWidget() {
|
||||
const [config, setConfig] = usePersistentState<CalConfig>("calendar.config", DEFAULT_CONFIG);
|
||||
const [month, setMonth] = useState(0);
|
||||
const [year, setYear] = useState(1492);
|
||||
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [newEvent, setNewEvent] = useState("");
|
||||
const [newCategory, setNewCategory] = useState(CATEGORIES[0].label);
|
||||
const [newRecur, setNewRecur] = useState<Recur>("none");
|
||||
const [view, setView] = useState<"month" | "agenda" | "edit">("month");
|
||||
// ponytail: the campaign's current date, persisted so it survives reloads.
|
||||
// Advances with the "Next day" button; "Today" jumps the view to it.
|
||||
const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>(
|
||||
"calendar.today",
|
||||
{ day: 15, month: 5, year: 1492 },
|
||||
);
|
||||
|
||||
// ponytail: 8 moon phases cycling on a ~30-day lunar cycle. Derived from
|
||||
// a continuous day index so the phase is stable for any date.
|
||||
const MOON_PHASES = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];
|
||||
// ponytail: 8 moon phases on a 30-day cycle, derived from the absolute day
|
||||
// index so the phase is stable for any date under any calendar config.
|
||||
function moonPhase(day: number, month: number, year: number): string {
|
||||
const idx = Math.floor(((year * 360 + month * 30 + (day - 1)) % 30) / 30 * 8);
|
||||
return MOON_PHASES[((idx % 8) + 8) % 8];
|
||||
const idx = dayIndex(day, month, year, config);
|
||||
const p = Math.floor(((idx % 30) / 30) * 8);
|
||||
return MOON_PHASES[((p % 8) + 8) % 8];
|
||||
}
|
||||
|
||||
// ponytail: deterministic weather per day (stable for a given date).
|
||||
// A simple PRNG seeded by the date index so the forecast doesn't reshuffle
|
||||
// every render. Coarse by design — a DM can override narratively.
|
||||
const WEATHER = [
|
||||
"☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm",
|
||||
"❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural",
|
||||
];
|
||||
function weatherFor(day: number, month: number, year: number): string {
|
||||
const seed = (year * 360 + month * 30 + (day - 1)) % 97;
|
||||
return WEATHER[seed % WEATHER.length];
|
||||
const idx = dayIndex(day, month, year, config);
|
||||
return WEATHER[Math.abs(idx) % WEATHER.length];
|
||||
}
|
||||
|
||||
// Advance the campaign date by one day (30-day months, 12-month year).
|
||||
function nextDay() {
|
||||
setToday((t) => {
|
||||
let { day, month, year } = t;
|
||||
day += 1;
|
||||
if (day > 30) { day = 1; month += 1; }
|
||||
if (month > 11) { month = 0; year += 1; }
|
||||
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
|
||||
if (month >= config.months.length) { month = 0; year += 1; }
|
||||
return { day, month, year };
|
||||
});
|
||||
}
|
||||
@@ -69,7 +125,7 @@ export function CalendarWidget() {
|
||||
if (!newEvent.trim() || selectedDay === null) return;
|
||||
setEvents((prev) => [
|
||||
...prev,
|
||||
{ day: selectedDay, month, text: newEvent.trim(), color: COLORS[events.length % COLORS.length] },
|
||||
{ day: selectedDay, month, text: newEvent.trim(), category: newCategory, recur: newRecur },
|
||||
]);
|
||||
setNewEvent("");
|
||||
}
|
||||
@@ -78,130 +134,303 @@ export function CalendarWidget() {
|
||||
setEvents((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const dayEvents = events.filter((e) => e.day === selectedDay && e.month === month);
|
||||
// ponytail: does an event fall on a given (day,month)? Recurring events
|
||||
// match on month (yearly) or every month (monthly). One-off events match
|
||||
// day+month only — we don't store the creation year, so a one-off repeats
|
||||
// visually each year. Acceptable for a DM calendar; store year if it bites.
|
||||
function eventOn(e: CalendarEvent, day: number, m: number): boolean {
|
||||
if (e.recur === "monthly") return e.day === day;
|
||||
if (e.recur === "yearly") return e.day === day && e.month === m;
|
||||
return e.day === day && e.month === m;
|
||||
}
|
||||
|
||||
const dayEvents = useMemo(
|
||||
() => events.filter((e) => e.day === selectedDay && (e.recur === "monthly" || e.month === month)),
|
||||
[events, selectedDay, month],
|
||||
);
|
||||
|
||||
// ponytail: agenda — the next N events from today forward, recurring expanded.
|
||||
const agenda = useMemo(() => {
|
||||
const startIdx = dayIndex(today.day, today.month, today.year, config);
|
||||
const upcoming: { e: CalendarEvent; day: number; month: number; year: number }[] = [];
|
||||
const scan = 2 * yearLen(config); // ~2 years
|
||||
for (let off = 0; off < scan && upcoming.length < 12; off++) {
|
||||
const { day, month: dm, year: dy } = fromIndex(startIdx + off, config);
|
||||
for (const e of events) {
|
||||
if (eventOn(e, day, dm)) upcoming.push({ e, day, month: dm, year: dy });
|
||||
}
|
||||
}
|
||||
return upcoming;
|
||||
}, [events, today, config]);
|
||||
|
||||
const dim = daysInMonth(config, month);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Month navigation */}
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { if (month === 0) { setMonth(11); setYear(year - 1); } else setMonth(month - 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
||||
{MONTHS[month]}
|
||||
</div>
|
||||
<div className="text-[var(--color-text-dim)] text-[10px]">{year} DR</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (month === 11) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Campaign date controls */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">
|
||||
Today: {MONTHS[today.month]} {today.day}, {today.year} DR {moonPhase(today.day, today.month, today.year)}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={jumpToToday}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
|
||||
title="Jump the view to today"
|
||||
onClick={() => setView("month")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "month" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
>
|
||||
Today
|
||||
Month
|
||||
</button>
|
||||
<button
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
title="Advance the campaign date by one day"
|
||||
onClick={() => setView("agenda")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "agenda" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
>
|
||||
Next day →
|
||||
Agenda
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("edit")}
|
||||
className={`rounded px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${view === "edit" ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||
title="Edit the calendar (months, day counts, year label)"
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">
|
||||
Today: {monthName(config, today.month)} {today.day}, {today.year} {config.yearLabel} {moonPhase(today.day, today.month, today.year)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
||||
{d}
|
||||
{view === "edit" ? (
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-2">
|
||||
<div className="text-[10px] text-[var(--color-text-dim)]">
|
||||
Edit months, day counts, and the year label. Changes apply immediately.
|
||||
</div>
|
||||
))}
|
||||
{/* Start offset - Faerûn calendar starts on first of month */}
|
||||
{Array.from({ length: 30 }, (_, i) => {
|
||||
const dayNum = i + 1;
|
||||
const isToday = dayNum === today.day && month === today.month && year === today.year;
|
||||
const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
|
||||
const isSelected = dayNum === selectedDay;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-[var(--color-text-secondary)]">Year label</label>
|
||||
<input
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] w-20"
|
||||
value={config.yearLabel}
|
||||
onChange={(e) => setConfig({ ...config, yearLabel: e.target.value })}
|
||||
placeholder="DR"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">→ shown as “{year} {config.yearLabel}”</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.months.map((m, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<span className="text-[9px] text-[var(--color-text-dim)] w-4 font-mono shrink-0">{i + 1}</span>
|
||||
<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)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={m.name}
|
||||
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, name: e.target.value } : x) })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
className="w-14 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={m.days}
|
||||
onChange={(e) => setConfig({ ...config, months: config.months.map((x, j) => j === i ? { ...x, days: Math.max(1, parseInt(e.target.value) || 1) } : x) })}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, months: config.months.filter((_, j) => j !== i) })}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer shrink-0"
|
||||
title="Remove month"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
key={dayNum}
|
||||
onClick={() => setSelectedDay(dayNum)}
|
||||
className={`rounded text-[10px] py-1 cursor-pointer transition-colors ${
|
||||
isToday
|
||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
|
||||
: isSelected
|
||||
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
onClick={() => setConfig({ ...config, months: [...config.months, { name: `Month ${config.months.length + 1}`, days: 30 }] })}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
>
|
||||
{dayNum}
|
||||
{hasEvent && !isToday && (
|
||||
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
||||
)}
|
||||
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
||||
+ month
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfig(DEFAULT_CONFIG); setMonth(0); }}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] cursor-pointer"
|
||||
title="Reset to the Forgotten Realms calendar"
|
||||
>
|
||||
Reset to Forgotten Realms
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("month")}
|
||||
className="ml-auto rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events for selected day */}
|
||||
{selectedDay !== null && (
|
||||
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{MONTHS[month]} {selectedDay}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
|
||||
{weatherFor(selectedDay, month, year)}
|
||||
</span>
|
||||
</div>
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||
</div>
|
||||
) : view === "month" ? (
|
||||
<>
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { if (month === 0) { setMonth(config.months.length - 1); setYear(year - 1); } else setMonth(month - 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
|
||||
{monthName(config, month)}
|
||||
</div>
|
||||
<div className="text-[var(--color-text-dim)] text-[10px]">{year} {config.yearLabel}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { if (month === config.months.length - 1) { setMonth(0); setYear(year + 1); } else setMonth(month + 1); }}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer text-sm"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={jumpToToday}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
|
||||
title="Jump the view to today"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
title="Advance the campaign date by one day"
|
||||
>
|
||||
Next day →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="text-[var(--color-text-dim)] text-[9px] text-center font-medium">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: dim }, (_, i) => {
|
||||
const dayNum = i + 1;
|
||||
const isToday = dayNum === today.day && month === today.month && year === today.year;
|
||||
const dayEvts = events.filter((e) => e.day === dayNum && (e.recur === "monthly" || e.month === month));
|
||||
const isSelected = dayNum === selectedDay;
|
||||
return (
|
||||
<button
|
||||
key={dayNum}
|
||||
onClick={() => setSelectedDay(dayNum)}
|
||||
className={`rounded text-[10px] py-1 cursor-pointer transition-colors relative ${
|
||||
isToday
|
||||
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-bold"
|
||||
: isSelected
|
||||
? "bg-[var(--color-bg-card-hover)] text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
{dayNum}
|
||||
{dayEvts.length > 0 && !isToday && (
|
||||
<span className="flex justify-center gap-0.5 mt-0.5">
|
||||
{dayEvts.slice(0, 3).map((e, j) => (
|
||||
<span key={j} className="w-1 h-1 rounded-full" style={{ backgroundColor: catColor(e.category) }} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Events for selected day */}
|
||||
{selectedDay !== null && (
|
||||
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{monthName(config, month)} {selectedDay}
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
|
||||
{weatherFor(selectedDay, month, year)}
|
||||
</span>
|
||||
</div>
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||
)}
|
||||
{dayEvents.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(e.category) }} />
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
||||
{e.recur !== "none" && (
|
||||
<span className="text-[8px] text-[var(--color-text-dim)]" title={`Repeats ${e.recur}`}>↻{e.recur === "monthly" ? "m" : "y"}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeEvent(events.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<input
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={newEvent}
|
||||
onChange={(e) => setNewEvent(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addEvent()}
|
||||
placeholder="Add event…"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
|
||||
>
|
||||
{CATEGORIES.map((c) => <option key={c.label} value={c.label}>{c.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={newRecur}
|
||||
onChange={(e) => setNewRecur(e.target.value as Recur)}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[9px] cursor-pointer"
|
||||
title="Repeat"
|
||||
>
|
||||
<option value="none">Once</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={addEvent}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dayEvents.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: e.color }} />
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEvent(events.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Agenda view — next events from today forward.
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
|
||||
Upcoming events
|
||||
</div>
|
||||
{agenda.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No upcoming events.</div>
|
||||
)}
|
||||
{agenda.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 py-1 border-b border-[var(--color-border-subtle)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: catColor(a.e.category) }} />
|
||||
<span className="text-[var(--color-text-secondary)] text-[10px] font-mono shrink-0">
|
||||
{monthName(config, a.month).slice(0, 3)} {a.day}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-primary)] text-[10px] flex-1">{a.e.text}</span>
|
||||
{a.e.recur !== "none" && (
|
||||
<span className="text-[8px] text-[var(--color-text-dim)]">↻{a.e.recur === "monthly" ? "m" : "y"}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-1 mt-1">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px]"
|
||||
value={newEvent}
|
||||
onChange={(e) => setNewEvent(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addEvent()}
|
||||
placeholder="Add event…"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={addEvent}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-1 text-[10px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
|
||||
onClick={nextDay}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||
>
|
||||
+
|
||||
Next day →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+96
-132
@@ -12,10 +12,9 @@ import {
|
||||
Shuffle,
|
||||
Flag,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BentoCard } from "./BentoCard";
|
||||
import type { View } from "../App";
|
||||
import {
|
||||
listGenerations,
|
||||
@@ -28,111 +27,61 @@ interface DashboardProps {
|
||||
onNavigate: (view: View) => void;
|
||||
}
|
||||
|
||||
// ponytail: launcher cards — icon, title, one-line description, Open CTA.
|
||||
// No embedded mini-tools: the dashboard is a launcher, not an ant farm.
|
||||
interface Card {
|
||||
icon: typeof Map;
|
||||
// ponytail: a launcher tile is ONE button — the whole card is the click target
|
||||
// (the old dashboard only wired the tiny "Open" link, so the boxes felt dead).
|
||||
// No redundant double-header; just icon, title, one-line description, and an
|
||||
// optional "last used" footer. Grouped into sections so 13 tools stop being a
|
||||
// wall of identical tiles.
|
||||
interface Tile {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
view: View;
|
||||
span?: string;
|
||||
// when set, the card shows "Last: {title} · {relativeTime}" from history.
|
||||
kind?: GenerationKind;
|
||||
}
|
||||
|
||||
const CARDS: Card[] = [
|
||||
interface Section {
|
||||
label: string;
|
||||
hint: string;
|
||||
tiles: Tile[];
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
icon: Map,
|
||||
title: "World Builder",
|
||||
description: "Generate a world with regions, landmarks, and conflicts.",
|
||||
view: "world",
|
||||
span: "sm:col-span-2",
|
||||
kind: "world",
|
||||
label: "At the table",
|
||||
hint: "Live-session tools",
|
||||
tiles: [
|
||||
{ icon: Timer, title: "Initiative", description: "Track combatants, HP, conditions, and turns.", view: "initiative" },
|
||||
{ icon: Dice5, title: "Dice Roller", description: "Roll with advantage, modifiers, and templates.", view: "dice" },
|
||||
{ icon: Volume2, title: "Soundboard", description: "Synthesized ambience and SFX.", view: "sound" },
|
||||
{ icon: ScrollText, title: "Session Log", description: "Take notes and generate an AI summary.", view: "session", kind: "session" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Timer,
|
||||
title: "Initiative",
|
||||
description: "Track combatants, HP, conditions, and turn order.",
|
||||
view: "initiative",
|
||||
kind: undefined,
|
||||
label: "Prep & create",
|
||||
hint: "Generators for your next session",
|
||||
tiles: [
|
||||
{ icon: Swords, title: "Encounter", description: "Build balanced encounters with an XP budget.", view: "encounter", kind: "encounter" },
|
||||
{ icon: User, title: "NPC Generator", description: "Generate NPCs with portraits and stat blocks.", view: "npcs", kind: "npc" },
|
||||
{ icon: Flag, title: "Quest Designer", description: "Multi-step quests with twists and rewards.", view: "quest", kind: "quest" },
|
||||
{ icon: Wand2, title: "Item Forge", description: "Forge magic items with art and mechanics.", view: "items", kind: "item" },
|
||||
{ icon: ImagePlus, title: "Image Generator", description: "Portraits, maps, and scene art (macOS).", view: "image", kind: "image" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Dice5,
|
||||
title: "Dice Roller",
|
||||
description: "Roll with advantage, modifiers, and roll templates.",
|
||||
view: "dice",
|
||||
},
|
||||
{
|
||||
icon: Swords,
|
||||
title: "Encounter",
|
||||
description: "Build balanced encounters with an XP budget.",
|
||||
view: "encounter",
|
||||
kind: "encounter",
|
||||
},
|
||||
{
|
||||
icon: User,
|
||||
title: "NPC Generator",
|
||||
description: "Generate NPCs with portraits, personality, and goals.",
|
||||
view: "npcs",
|
||||
kind: "npc",
|
||||
},
|
||||
{
|
||||
icon: Flag,
|
||||
title: "Quest Designer",
|
||||
description: "Design multi-step quests with twists and rewards.",
|
||||
view: "quest",
|
||||
kind: "quest",
|
||||
},
|
||||
{
|
||||
icon: Wand2,
|
||||
title: "Item Forge",
|
||||
description: "Forge magic items with art and mechanics.",
|
||||
view: "items",
|
||||
kind: "item",
|
||||
},
|
||||
{
|
||||
icon: ScrollText,
|
||||
title: "Session Log",
|
||||
description: "Take notes and generate an AI session summary.",
|
||||
view: "session",
|
||||
kind: "session",
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: "Lore (RAG)",
|
||||
description: "Index your world bible and ground generations in it.",
|
||||
view: "lore",
|
||||
},
|
||||
{
|
||||
icon: Calendar,
|
||||
title: "Calendar",
|
||||
description: "Track the in-world date, events, and moon phases.",
|
||||
view: "calendar",
|
||||
},
|
||||
{
|
||||
icon: Shuffle,
|
||||
title: "Random Tables",
|
||||
description: "Roll on built-in and custom random tables.",
|
||||
view: "tables",
|
||||
},
|
||||
{
|
||||
icon: ImagePlus,
|
||||
title: "Image Generator",
|
||||
description: "Generate portraits, maps, and scene art (macOS).",
|
||||
view: "image",
|
||||
span: "sm:col-span-2",
|
||||
kind: "image",
|
||||
},
|
||||
{
|
||||
icon: Volume2,
|
||||
title: "Soundboard",
|
||||
description: "Synthesized ambience and SFX for the table.",
|
||||
view: "sound",
|
||||
label: "World & reference",
|
||||
hint: "Your campaign bible",
|
||||
tiles: [
|
||||
{ icon: Map, title: "World Builder", description: "Generate a world with regions and a map.", view: "world", kind: "world" },
|
||||
{ icon: BookOpen, title: "Lore (RAG)", description: "Index your world bible and ground generations.", view: "lore" },
|
||||
{ icon: Calendar, title: "Calendar", description: "Track the in-world date, events, and moon phases.", view: "calendar" },
|
||||
{ icon: Shuffle, title: "Random Tables", description: "Roll on built-in and custom tables.", view: "tables" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Dashboard({ onNavigate }: DashboardProps) {
|
||||
// ponytail: one fetch on mount → most-recent generation per kind, for the
|
||||
// ponytail: one fetch on mount → newest generation per kind, for the
|
||||
// "Last: …" footer. listGenerations returns newest-first per kind.
|
||||
const [latest, setLatest] = useState<Partial<Record<GenerationKind, GenerationSummary>>>({});
|
||||
useEffect(() => {
|
||||
@@ -151,47 +100,62 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto">
|
||||
{CARDS.map((card) => {
|
||||
const last = card.kind ? latest[card.kind] : undefined;
|
||||
return (
|
||||
<BentoCard
|
||||
key={card.view}
|
||||
title={card.title}
|
||||
icon={<card.icon size={16} />}
|
||||
span={card.span}
|
||||
>
|
||||
<div className="flex flex-col h-full min-h-[120px]">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)]">
|
||||
<card.icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{last && (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-2 truncate">
|
||||
Last:{" "}
|
||||
<span className="text-[var(--color-text-secondary)]">{last.title}</span>{" "}
|
||||
· {relativeTime(last.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate(card.view)}
|
||||
className="mt-2 self-start text-xs font-medium text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] flex items-center gap-1 cursor-pointer transition-colors"
|
||||
aria-label={`Open ${card.title}`}
|
||||
>
|
||||
Open <ArrowRight size={12} />
|
||||
</button>
|
||||
<div className="h-full overflow-y-auto bg-[var(--color-bg-deep)]">
|
||||
<div className="max-w-6xl mx-auto px-6 py-6 flex flex-col gap-7">
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section.label} className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-widest uppercase">
|
||||
{section.label}
|
||||
</h2>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] tracking-wide">
|
||||
{section.hint}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[var(--color-border-subtle)]" />
|
||||
</div>
|
||||
</BentoCard>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{section.tiles.map((tile) => {
|
||||
const last = tile.kind ? latest[tile.kind] : undefined;
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<button
|
||||
key={tile.view}
|
||||
onClick={() => onNavigate(tile.view)}
|
||||
className="glass-card group text-left p-4 rounded-2xl flex flex-col gap-2 min-h-[112px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-gold-bright)]"
|
||||
aria-label={`Open ${tile.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)] transition-colors group-hover:bg-[var(--color-gold-bright)] group-hover:text-[var(--color-bg-deep)]">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<h3 className="font-heading text-[var(--color-text-primary)] text-sm font-semibold leading-tight">
|
||||
{tile.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2 flex-1">
|
||||
{tile.description}
|
||||
</p>
|
||||
{last ? (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] truncate">
|
||||
Last: <span className="text-[var(--color-text-secondary)]">{last.title}</span> · {relativeTime(last.createdAt)}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Open →
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<p className="text-center text-[10px] text-[var(--color-text-dim)] pt-1">
|
||||
Press <kbd className="px-1 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] font-mono text-[9px]">⌘K</kbd> to jump to any tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
@@ -13,17 +13,30 @@ interface Props {
|
||||
aspect?: string;
|
||||
}
|
||||
|
||||
// ponytail: matches the Rust ImageEvent enum (tagged union).
|
||||
type ImageEvent =
|
||||
| { type: "progress"; data: { step: number | null; total: number | null } }
|
||||
| { type: "done"; data: string }
|
||||
| { 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
const [progress, setProgress] = useState<{ step: number | null; total: number | null } | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,22 +44,40 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setUnsupported(false);
|
||||
invoke<string>("generate_image", { req: { prompt } })
|
||||
.then((url) => {
|
||||
if (!cancelled) setDataUrl(url);
|
||||
})
|
||||
.catch((e) => {
|
||||
setProgress(null);
|
||||
|
||||
const channel = new Channel<ImageEvent>();
|
||||
channel.onmessage = (event) => {
|
||||
if (cancelled) return;
|
||||
if (event.type === "progress") {
|
||||
setProgress(event.data);
|
||||
} else if (event.type === "done") {
|
||||
setDataUrl(event.data);
|
||||
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");
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
invoke("generate_image_stream", { req: { prompt }, channel }).catch((e) => {
|
||||
if (!cancelled) {
|
||||
const msg = String(e);
|
||||
if (!cancelled) {
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else addToast(`Image failed: ${msg}`, "error");
|
||||
}
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else addToast(`Image failed: ${msg}`, "error");
|
||||
setLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [prompt, nonce]);
|
||||
}, [prompt, nonce, addToast]);
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
@@ -64,10 +95,20 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
}
|
||||
|
||||
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;
|
||||
return (
|
||||
<div className={box}>
|
||||
<div className="absolute inset-0 skeleton" />
|
||||
<span className="relative text-[10px] text-[var(--color-text-dim)]">Painting…</span>
|
||||
<div className="relative flex flex-col items-center gap-1 w-full px-3">
|
||||
{pct != null ? (
|
||||
<div className="w-full h-1 rounded-full bg-[var(--color-bg-surface)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)] transition-all duration-200" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">Painting…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,20 @@ import { useToast } from "./Toast";
|
||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
|
||||
interface ItemMechanics {
|
||||
attunement: string; // "none", "yes", or a condition string
|
||||
charges: string; // "0" or "N (regen 1d6 per dawn)"
|
||||
value: string; // "500 gp"
|
||||
weight: string; // "2 lbs"
|
||||
effect: string; // one-line effect description
|
||||
}
|
||||
|
||||
interface ItemResponse {
|
||||
name: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
description: string;
|
||||
mechanical: string | Record<string, unknown>;
|
||||
mechanical: string | Record<string, unknown> | ItemMechanics;
|
||||
lore: string;
|
||||
}
|
||||
|
||||
@@ -67,11 +75,11 @@ The JSON must have exactly these keys:
|
||||
- "rarity": "${rarity}" (string)
|
||||
- "type": "${itemType}" (string)
|
||||
- "description": a vivid 2-3 sentence physical description (string)
|
||||
- "mechanical": the item's game mechanics as a single descriptive paragraph (string, NOT an object)
|
||||
- "mechanical": an object with keys attunement (string: "none", "yes", or a condition), charges (string), value (string), weight (string), and effect (a one-line string describing the item's mechanical effect)
|
||||
- "lore": a 1-2 sentence piece of lore or history (string)`,
|
||||
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.85,
|
||||
max_tokens: 500,
|
||||
max_tokens: 600,
|
||||
ragQuery: `${rarity} ${itemType} ${prompt}`,
|
||||
},
|
||||
});
|
||||
@@ -108,6 +116,12 @@ The JSON must have exactly these keys:
|
||||
};
|
||||
|
||||
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
||||
// ponytail: structured mechanics if the LLM returned the object form; fall
|
||||
// back to a paragraph string for older generations.
|
||||
const mech = item?.mechanical;
|
||||
const isStructured =
|
||||
typeof mech === "object" && mech !== null && "attunement" in mech && "effect" in mech;
|
||||
const structured = isStructured ? (mech as ItemMechanics) : null;
|
||||
const hasParseError = !item && rawResponse;
|
||||
|
||||
// ponytail: one-click "surprise me" — random rarity + type, no typed prompt.
|
||||
@@ -217,7 +231,24 @@ The JSON must have exactly these keys:
|
||||
|
||||
<div className="rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-3">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Mechanics</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
|
||||
{structured ? (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{/* ponytail: structured fields — attunement/charges/value/weight + one-line effect */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
||||
<span className="text-[var(--color-text-dim)]">Attunement: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.attunement === "none" || !structured.attunement ? "No" : structured.attunement}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Charges: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.charges || "—"}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Value: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.value || "—"}</span>
|
||||
<span className="text-[var(--color-text-dim)]">Weight: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{structured.weight || "—"}</span>
|
||||
</div>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-1">{structured.effect}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{mechanicalText}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
+232
-97
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import { addToLore } from "../lib/lore";
|
||||
|
||||
interface RagSource {
|
||||
source: string;
|
||||
@@ -13,6 +15,11 @@ interface RagHit {
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface RagChunk {
|
||||
id: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export function LorePanel() {
|
||||
const [source, setSource] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
@@ -24,6 +31,13 @@ export function LorePanel() {
|
||||
const [hits, setHits] = useState<RagHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
// ponytail: source filter — multi-select chips. Empty set = search all.
|
||||
const [filterSources, setFilterSources] = useState<Set<string>>(new Set());
|
||||
// ponytail: chunk preview — expand a source to see its embedded chunks.
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [chunks, setChunks] = useState<RagChunk[]>([]);
|
||||
const [loadingChunks, setLoadingChunks] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
@@ -37,6 +51,25 @@ export function LorePanel() {
|
||||
loadSources();
|
||||
}, []);
|
||||
|
||||
async function loadChunks(s: string) {
|
||||
setLoadingChunks(true);
|
||||
try {
|
||||
setChunks(await invoke<RagChunk[]>("rag_chunks", { source: s }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setLoadingChunks(false);
|
||||
}
|
||||
|
||||
function toggleSource(s: string) {
|
||||
setFilterSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s);
|
||||
else next.add(s);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!source.trim() || !text.trim()) return;
|
||||
setAdding(true);
|
||||
@@ -55,20 +88,43 @@ export function LorePanel() {
|
||||
|
||||
async function clearOne(s: string) {
|
||||
await invoke("rag_clear", { source: s });
|
||||
if (expanded === s) setExpanded(null);
|
||||
loadSources();
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
await invoke("rag_clear", { source: null });
|
||||
setConfirmClear(false);
|
||||
setExpanded(null);
|
||||
loadSources();
|
||||
}
|
||||
|
||||
// ponytail: file upload via native HTML input — no tauri-plugin-dialog
|
||||
// needed. Reads .md/.txt contents in the webview and indexes each file as
|
||||
// its own lore source. Multiple files supported.
|
||||
function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
for (const f of files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result ?? "");
|
||||
addToLore(f.name, text).then(() => loadSources());
|
||||
};
|
||||
reader.onerror = () => addToast(`Failed to read ${f.name}`, "error");
|
||||
reader.readAsText(f);
|
||||
}
|
||||
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setHits(await invoke<RagHit[]>("rag_search", { query, topK: 5 }));
|
||||
const all = await invoke<RagHit[]>("rag_search", { query, topK: 12 });
|
||||
// ponytail: client-side source filter — the backend scans everything,
|
||||
// we just hide hits from unselected sources. Fine at this corpus size.
|
||||
setHits(filterSources.size === 0 ? all : all.filter((h) => filterSources.has(h.source)));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -76,91 +132,138 @@ export function LorePanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{/* Add lore */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
||||
<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={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
|
||||
/>
|
||||
<textarea
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-40 resize-y"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
|
||||
/>
|
||||
<button
|
||||
onClick={add}
|
||||
disabled={adding}
|
||||
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"
|
||||
>
|
||||
{adding ? "Embedding…" : "Add to Lore"}
|
||||
</button>
|
||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||
</div>
|
||||
<div className="flex gap-4 h-full text-sm">
|
||||
{/* Left pane: add + indexed sources */}
|
||||
<div className="flex flex-col gap-3 w-1/2 min-w-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
|
||||
<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={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
|
||||
/>
|
||||
<textarea
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-40 resize-y"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
|
||||
/>
|
||||
<button
|
||||
onClick={add}
|
||||
disabled={adding}
|
||||
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"
|
||||
>
|
||||
{adding ? "Embedding…" : "Add to Lore"}
|
||||
</button>
|
||||
{/* ponytail: file upload — native input, no dialog plugin. */}
|
||||
<label 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">
|
||||
<FileUp size={14} />
|
||||
Upload .md / .txt
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.txt,.markdown,text/plain,text/markdown"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||
{sources.length > 0 && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||
{sources.length > 0 && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||
>
|
||||
Yes
|
||||
clear all
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sources.map((s) => {
|
||||
const isOpen = expanded === s.source;
|
||||
return (
|
||||
<li key={s.source} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isOpen) { setExpanded(null); }
|
||||
else { setExpanded(s.source); loadChunks(s.source); }
|
||||
}}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer shrink-0"
|
||||
aria-label={isOpen ? "Collapse chunks" : "Preview chunks"}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<span className="text-[var(--color-text-primary)] text-xs truncate flex-1">
|
||||
{s.source} <span className="text-[var(--color-text-dim)]">· {s.chunks} chunks</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => clearOne(s.source)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors shrink-0"
|
||||
title="Clear this source"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="px-2 pb-2 border-t border-[var(--color-border-subtle)] pt-1.5">
|
||||
{loadingChunks ? (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">Loading chunks…</span>
|
||||
) : chunks.length === 0 ? (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">No chunks.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{chunks.map((c) => (
|
||||
<div key={c.id} className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[10px] text-[var(--color-text-secondary)] leading-relaxed">
|
||||
<span className="text-[var(--color-text-dim)] font-mono">#{c.id}</span>{" "}
|
||||
{c.preview}
|
||||
{c.preview.length >= 200 ? "…" : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{sources.map((s) => (
|
||||
<li key={s.source} className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5">
|
||||
<span className="text-[var(--color-text-primary)] text-xs truncate">
|
||||
{s.source} <span className="text-[var(--color-text-dim)]">· {s.chunks} chunks</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => clearOne(s.source)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors shrink-0"
|
||||
title="Clear this source"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test retrieval */}
|
||||
<div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Test Retrieval</h3>
|
||||
{/* Right pane: search + explore */}
|
||||
<div className="flex flex-col gap-2 w-1/2 min-w-0 border-l border-[var(--color-border-subtle)] pl-4">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Search Lore</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 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"
|
||||
className="flex-1 min-w-0 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={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
@@ -174,28 +277,60 @@ export function LorePanel() {
|
||||
{searching ? "…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{hits.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{hits.map((h, i) => (
|
||||
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||
DM can't read. Clamp 0–1, gold fill. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
||||
{/* Source filter chips */}
|
||||
{sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] self-center">Filter:</span>
|
||||
<button
|
||||
onClick={() => setFilterSources(new Set())}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterSources.size === 0
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.source}
|
||||
onClick={() => toggleSource(s.source)}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors truncate max-w-32 ${
|
||||
filterSources.has(s.source)
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{s.source}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{hits.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{hits.map((h, i) => (
|
||||
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||
DM can't read. Clamp 0–1, gold fill. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hits.length === 0 && query && !searching && (
|
||||
<p className="text-xs text-[var(--color-text-dim)]">No matches.</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--color-text-dim)] italic mt-2">
|
||||
{query ? "No matches." : "Search to retrieve the most relevant lore chunks."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,22 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
|
||||
import { Dice5 } from "lucide-react";
|
||||
|
||||
interface NpcStats {
|
||||
ac: number;
|
||||
hp: number;
|
||||
str: number;
|
||||
dex: number;
|
||||
con: number;
|
||||
int: number;
|
||||
wis: number;
|
||||
cha: number;
|
||||
}
|
||||
|
||||
interface NpcResponse {
|
||||
bio: string;
|
||||
personality: string[];
|
||||
goals: string[];
|
||||
stats: NpcStats;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -79,11 +91,12 @@ Provide the response as a JSON object with exactly these keys:
|
||||
- "bio": a 2-3 sentence backstory
|
||||
- "personality": an array of 3-5 personality traits (each trait must be a string, not an object)
|
||||
- "goals": an array of 2-3 goals or motivations (each goal must be a string, not an object)
|
||||
- "stats": an object with keys ac, hp, str, dex, con, int, wis, cha — each a number. Fit the NPC's role (a blacksmith is strong, a sage is wise). Use 5e ranges (3-20 for abilities, 10-18 for AC, 1-60 for HP).
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
|
||||
|
||||
const result = await invoke<string>("generate", {
|
||||
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512, ragQuery: `${race} ${background} ${alignment} NPC` },
|
||||
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 600, ragQuery: `${race} ${background} ${alignment} NPC` },
|
||||
});
|
||||
|
||||
// Try to parse JSON from the response
|
||||
@@ -99,9 +112,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
// stay consistent with the growing cast.
|
||||
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
|
||||
const goals = ensureArray(parsed.goals).map(flattenValue).join("; ");
|
||||
const statsLine = parsed.stats ? `AC ${parsed.stats.ac}, HP ${parsed.stats.hp}; Str ${parsed.stats.str} Dex ${parsed.stats.dex} Con ${parsed.stats.con} Int ${parsed.stats.int} Wis ${parsed.stats.wis} Cha ${parsed.stats.cha}` : "";
|
||||
void addToLore(
|
||||
`NPC: ${name || race + " " + background}`,
|
||||
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
|
||||
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}${statsLine ? `\nStats: ${statsLine}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
@@ -196,6 +210,25 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{name || "NPC"}
|
||||
</h3>
|
||||
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
|
||||
{/* ponytail: stat block — AC/HP prominent, six abilities in a grid. */}
|
||||
{npc.stats && (
|
||||
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-2">
|
||||
<div className="flex items-center gap-3 mb-1.5">
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] uppercase tracking-wider">AC</span>
|
||||
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">{npc.stats.ac}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] uppercase tracking-wider ml-1">HP</span>
|
||||
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">{npc.stats.hp}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-1 text-center">
|
||||
{[["STR", npc.stats.str], ["DEX", npc.stats.dex], ["CON", npc.stats.con], ["INT", npc.stats.int], ["WIS", npc.stats.wis], ["CHA", npc.stats.cha]].map(([ab, val]) => (
|
||||
<div key={ab as string} className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] py-1">
|
||||
<div className="text-[8px] text-[var(--color-text-dim)] uppercase">{ab}</div>
|
||||
<div className="font-mono text-xs font-bold text-[var(--color-text-primary)]">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseLlmJson } from "../lib/llm-parse";
|
||||
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { addToLore } from "../lib/lore";
|
||||
@@ -9,7 +9,16 @@ import { usePrefillEffect } from "../lib/usePrefill";
|
||||
interface QuestStep {
|
||||
title: string;
|
||||
description: string;
|
||||
choice?: string;
|
||||
// ponytail: the LLM occasionally returns an object/array here despite the
|
||||
// prompt; coerce to string at use sites so React never renders a raw object.
|
||||
choice?: unknown;
|
||||
}
|
||||
|
||||
interface QuestReward {
|
||||
xp: number; // XP per character
|
||||
gold: number; // total gold (split as the party sees fit)
|
||||
items: string[]; // magic items / treasure
|
||||
notes: string; // any other reward (boons, favors, reputation)
|
||||
}
|
||||
|
||||
interface QuestResponse {
|
||||
@@ -17,7 +26,7 @@ interface QuestResponse {
|
||||
hook: string;
|
||||
steps: QuestStep[];
|
||||
twist: string;
|
||||
reward: string;
|
||||
reward: string | QuestReward;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -28,6 +37,7 @@ interface Props {
|
||||
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [theme, setTheme] = useState("");
|
||||
const [level, setLevel] = useState(5);
|
||||
const [partySize, setPartySize] = useState(4);
|
||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -52,20 +62,20 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
try {
|
||||
const result = await invoke<string>("generate", {
|
||||
req: {
|
||||
prompt: `Design a D&D quest for level ${level} characters.
|
||||
prompt: `Design a D&D quest for ${partySize} level ${level} characters.
|
||||
${theme ? `Theme: ${theme}` : ""}
|
||||
|
||||
Provide the response as a JSON object with exactly these keys:
|
||||
- "title": a catchy quest name
|
||||
- "hook": 1-2 sentences describing how the party gets involved
|
||||
- "steps": an array of 3-5 quest steps, each with "title" and "description" (2-3 sentences each), and optionally "choice" (a meaningful decision the party faces)
|
||||
- "steps": an array of 3-5 quest steps, each with "title" (string), "description" (2-3 sentence string), and optionally "choice" — a SINGLE short string describing a meaningful decision the party faces (e.g. "Spare the bandit or turn him in?"). "choice" MUST be a string or null, NEVER an array or object. Example step: {"title":"The Crossroads","description":"...","choice":"Fight or flee?"}
|
||||
- "twist": a surprise revelation or complication
|
||||
- "reward": what the party gains on completion
|
||||
- "reward": an object with keys xp (number, XP awarded to EACH character), gold (number, total gp), items (array of strings, magic items or notable treasure), and notes (string, any other reward like boons or favors)
|
||||
|
||||
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
|
||||
system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
|
||||
temperature: 0.85,
|
||||
max_tokens: 700,
|
||||
max_tokens: 800,
|
||||
},
|
||||
});
|
||||
const parsed = parseLlmJson<QuestResponse>(result);
|
||||
@@ -76,11 +86,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
const source = `Level ${level}${theme ? `, ${theme}` : ""}`;
|
||||
void addGeneration({ kind: "quest", title, data: result, source });
|
||||
const steps = (parsed.steps || [])
|
||||
.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.choice ? ` (Choice: ${s.choice})` : ""}`)
|
||||
.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.choice ? ` (Choice: ${flattenValue(s.choice)})` : ""}`)
|
||||
.join("\n");
|
||||
void addToLore(
|
||||
`Quest: ${parsed.title || "Untitled"}`,
|
||||
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
|
||||
`Quest: ${parsed.title || "Untitled"} (level ${level}, ${partySize} PCs${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${typeof parsed.reward === "string" ? parsed.reward : `XP ${parsed.reward?.xp}/each, ${parsed.reward?.gold} gp, items: ${(parsed.reward?.items || []).join(", ")}; ${parsed.reward?.notes || ""}`}`,
|
||||
);
|
||||
} else {
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
@@ -117,6 +127,17 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 max-w-32">
|
||||
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Size</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
value={partySize}
|
||||
onChange={(e) => setPartySize(parseInt(e.target.value) || 1)}
|
||||
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-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={loading}
|
||||
@@ -195,10 +216,10 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
|
||||
{quest.steps[currentStep].description}
|
||||
</p>
|
||||
{quest.steps[currentStep].choice && !playerView && (
|
||||
{!!quest.steps[currentStep].choice && !playerView && (
|
||||
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{flattenValue(quest.steps[currentStep].choice)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between mt-3">
|
||||
@@ -233,7 +254,27 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{quest.reward && (
|
||||
<div className="rounded-lg border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-4 py-3">
|
||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Reward</span>
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.reward}</p>
|
||||
{/* ponytail: structured reward breakdown if the LLM returned the
|
||||
object form; fall back to a paragraph for older generations. */}
|
||||
{typeof quest.reward === "object" && quest.reward !== null && "xp" in quest.reward ? (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-sm">
|
||||
<span><span className="text-[var(--color-text-dim)]">XP</span> <span className="font-mono font-bold text-[var(--color-gold-bright)]">{quest.reward.xp}</span> <span className="text-[var(--color-text-dim)]">/ character</span></span>
|
||||
<span><span className="text-[var(--color-text-dim)]">Gold</span> <span className="font-mono font-bold text-[var(--color-gold-bright)]">{quest.reward.gold} gp</span></span>
|
||||
</div>
|
||||
{quest.reward.items?.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--color-text-dim)]">Items: </span>
|
||||
<span className="text-[var(--color-text-primary)]">{quest.reward.items.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{quest.reward.notes && (
|
||||
<p className="text-[var(--color-text-secondary)] text-sm italic">{quest.reward.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{typeof quest.reward === "string" ? quest.reward : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+281
-17
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface TableEntry {
|
||||
min: number;
|
||||
@@ -10,6 +12,8 @@ interface RandomTable {
|
||||
name: string;
|
||||
dice: string;
|
||||
entries: TableEntry[];
|
||||
/** true for user-created tables (editable / deletable). */
|
||||
custom?: boolean;
|
||||
}
|
||||
|
||||
const BUILTIN_TABLES: RandomTable[] = [
|
||||
@@ -206,6 +210,59 @@ function rollDice(notation: string): number {
|
||||
return total + modifier;
|
||||
}
|
||||
|
||||
// ponytail: total faces of the table's dice (e.g. "1d20" → 20). Used to show
|
||||
// per-entry weights and flag gaps where a roll would hit "No result".
|
||||
function diceSides(notation: string): number {
|
||||
const m = notation.toLowerCase().match(/^(\d+)d(\d+)/);
|
||||
return m ? parseInt(m[2]) * parseInt(m[1]) : 20;
|
||||
}
|
||||
|
||||
// ponytail: parse a pasted markdown/plain list into table entries. Accepts
|
||||
// "1d20" style dice on a header line, or defaults to 1dN where N = entry count.
|
||||
// Each non-empty line becomes one entry; ranges like "3-4 result" are honoured.
|
||||
function parseMarkdownTable(raw: string): { name: string; dice: string; entries: TableEntry[] } | null {
|
||||
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
let name = "Imported Table";
|
||||
let dice = "";
|
||||
const entryLines: string[] = [];
|
||||
for (const l of lines) {
|
||||
const hdr = l.match(/^#{1,6}\s+(.+)$/);
|
||||
const diceMatch = l.match(/(\d+d\d+(?:[+-]\d+)?)/i);
|
||||
if (hdr) {
|
||||
name = hdr[1].trim();
|
||||
continue;
|
||||
}
|
||||
if (diceMatch && !dice && entryLines.length === 0) {
|
||||
dice = diceMatch[1];
|
||||
// keep the line if it also has a result, else it's a dice-only header
|
||||
if (l.replace(diceMatch[1], "").trim()) entryLines.push(l);
|
||||
continue;
|
||||
}
|
||||
entryLines.push(l);
|
||||
}
|
||||
if (entryLines.length === 0) return null;
|
||||
const entries: TableEntry[] = entryLines.map((l, i) => {
|
||||
// strip leading list markers / numbers
|
||||
const cleaned = l.replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "");
|
||||
const range = cleaned.match(/^(\d+)\s*[-–]\s*(\d+)\s+(.+)$/);
|
||||
if (range) {
|
||||
return { min: parseInt(range[1]), max: parseInt(range[2]), result: range[3].trim() };
|
||||
}
|
||||
// single leading number: "5 Gilded Flask" → entry 5
|
||||
const single = cleaned.match(/^(\d+)\s+(.+)$/);
|
||||
if (single) {
|
||||
return { min: parseInt(single[1]), max: parseInt(single[1]), result: single[2].trim() };
|
||||
}
|
||||
return { min: i + 1, max: i + 1, result: cleaned };
|
||||
});
|
||||
if (!dice) {
|
||||
const max = entries.reduce((m, e) => Math.max(m, e.max), 0);
|
||||
dice = `1d${Math.max(2, max)}`;
|
||||
}
|
||||
return { name, dice, entries };
|
||||
}
|
||||
|
||||
interface RollLog {
|
||||
tableName: string;
|
||||
roll: number;
|
||||
@@ -216,8 +273,24 @@ export function RandomTables() {
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [log, setLog] = useState<RollLog[]>([]);
|
||||
const [customRoll, setCustomRoll] = useState<number | null>(null);
|
||||
// ponytail: user-defined tables persist alongside builtins. Editing only
|
||||
// affects custom tables; builtins are read-only.
|
||||
const [customTables, setCustomTables] = usePersistentState<RandomTable[]>("tables.custom", []);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importName, setImportName] = useState("");
|
||||
const [importDice, setImportDice] = useState("");
|
||||
const { addToast } = useToast();
|
||||
|
||||
const table = BUILTIN_TABLES[selected];
|
||||
const allTables = useMemo(() => [...BUILTIN_TABLES, ...customTables], [customTables]);
|
||||
const table = allTables[selected] ?? BUILTIN_TABLES[0];
|
||||
const isCustom = table.custom === true;
|
||||
// ponytail: weighted tables - count dice faces not covered by any entry.
|
||||
const sides = diceSides(table.dice);
|
||||
const coveredFaces = new Set<number>();
|
||||
for (const e of table.entries) for (let v = e.min; v <= e.max; v++) coveredFaces.add(v);
|
||||
const gaps = sides - coveredFaces.size;
|
||||
|
||||
function exportTable() {
|
||||
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
|
||||
@@ -237,23 +310,129 @@ export function RandomTables() {
|
||||
setCustomRoll(rollVal);
|
||||
}, [table]);
|
||||
|
||||
function newCustomTable() {
|
||||
const t: RandomTable = {
|
||||
name: `My Table ${customTables.length + 1}`,
|
||||
dice: "1d6",
|
||||
custom: true,
|
||||
entries: [
|
||||
{ min: 1, max: 1, result: "First result" },
|
||||
{ min: 2, max: 2, result: "Second result" },
|
||||
],
|
||||
};
|
||||
setCustomTables((prev) => [...prev, t]);
|
||||
setSelected(BUILTIN_TABLES.length + customTables.length);
|
||||
setEditing(true);
|
||||
addToast("New table created — edit it below", "info");
|
||||
}
|
||||
|
||||
function updateCurrent(updater: (t: RandomTable) => RandomTable) {
|
||||
if (!isCustom) return;
|
||||
const idx = selected - BUILTIN_TABLES.length;
|
||||
setCustomTables((prev) => prev.map((t, i) => (i === idx ? updater(t) : t)));
|
||||
}
|
||||
|
||||
function deleteCurrent() {
|
||||
if (!isCustom) return;
|
||||
const idx = selected - BUILTIN_TABLES.length;
|
||||
setCustomTables((prev) => prev.filter((_, i) => i !== idx));
|
||||
setSelected(0);
|
||||
setEditing(false);
|
||||
addToast("Table deleted", "info");
|
||||
}
|
||||
|
||||
function doImport() {
|
||||
let parsed: { name: string; dice: string; entries: TableEntry[] } | null = null;
|
||||
if (importText.trim()) {
|
||||
parsed = parseMarkdownTable(importText);
|
||||
if (!parsed) {
|
||||
addToast("Couldn't parse the pasted table", "error");
|
||||
return;
|
||||
}
|
||||
} else if (importName.trim()) {
|
||||
parsed = { name: importName.trim(), dice: importDice.trim() || "1d6", entries: [] };
|
||||
}
|
||||
if (!parsed) return;
|
||||
const t: RandomTable = { ...parsed, custom: true };
|
||||
setCustomTables((prev) => [...prev, t]);
|
||||
setSelected(BUILTIN_TABLES.length + customTables.length);
|
||||
setImportOpen(false);
|
||||
setImportText("");
|
||||
setImportName("");
|
||||
setImportDice("");
|
||||
addToast(`Imported "${t.name}"`, "success");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Table selector */}
|
||||
<select
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(parseInt(e.target.value));
|
||||
setCustomRoll(null);
|
||||
}}
|
||||
>
|
||||
{BUILTIN_TABLES.map((t, i) => (
|
||||
<option key={t.name} value={i}>
|
||||
{t.name} ({t.dice})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
value={selected}
|
||||
onChange={(e) => {
|
||||
setSelected(parseInt(e.target.value));
|
||||
setCustomRoll(null);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{allTables.map((t, i) => (
|
||||
<option key={`${t.name}-${i}`} value={i}>
|
||||
{t.custom ? "✎ " : ""}{t.name} ({t.dice})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={newCustomTable}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
title="New custom table"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportOpen((o) => !o)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
title="Import a table from pasted Markdown"
|
||||
>
|
||||
⬆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import panel */}
|
||||
{importOpen && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2 flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)] font-medium">Import table</span>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)]">
|
||||
Paste a list — one result per line. A <code># Title</code> line sets the name, a line with dice (e.g. <code>1d20</code>) sets the dice. Lines like <code>3-4 result</code> set ranges.
|
||||
</p>
|
||||
<textarea
|
||||
className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs min-h-24 resize-y font-mono"
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
placeholder={"# Tavern Names\n1d20\n1-2 The Rusty Tankard\n3-4 The Sleeping Dragon"}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<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={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
placeholder="Name (optional, overrides #)"
|
||||
/>
|
||||
<input
|
||||
className="w-20 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={importDice}
|
||||
onChange={(e) => setImportDice(e.target.value)}
|
||||
placeholder="1d20"
|
||||
/>
|
||||
<button
|
||||
onClick={doImport}
|
||||
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)]"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roll + export */}
|
||||
<div className="flex gap-2">
|
||||
@@ -270,8 +449,87 @@ export function RandomTables() {
|
||||
>
|
||||
⬇
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
editing
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title="Edit this custom table"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={deleteCurrent}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
|
||||
title="Delete this custom table"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor for custom tables */}
|
||||
{isCustom && editing && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2 flex flex-col gap-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<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)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={table.name}
|
||||
onChange={(e) => updateCurrent((t) => ({ ...t, name: e.target.value }))}
|
||||
placeholder="Table name"
|
||||
/>
|
||||
<input
|
||||
className="w-20 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
|
||||
value={table.dice}
|
||||
onChange={(e) => updateCurrent((t) => ({ ...t, dice: e.target.value }))}
|
||||
placeholder="1d20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto">
|
||||
{table.entries.map((e, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="w-10 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={e.min}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, min: parseInt(ev.target.value) || 0 } : x) }))}
|
||||
/>
|
||||
<span className="text-[var(--color-text-dim)] text-[10px]">-</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-10 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-[10px] font-mono"
|
||||
value={e.max}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, max: parseInt(ev.target.value) || 0 } : x) }))}
|
||||
/>
|
||||
<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)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={e.result}
|
||||
onChange={(ev) => updateCurrent((t) => ({ ...t, entries: t.entries.map((x, j) => j === i ? { ...x, result: ev.target.value } : x) }))}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateCurrent((t) => ({ ...t, entries: t.entries.filter((_, j) => j !== i) }))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer shrink-0"
|
||||
title="Remove entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateCurrent((t) => ({ ...t, entries: [...t.entries, { min: t.entries.length + 1, max: t.entries.length + 1, result: "" }] }))}
|
||||
className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-1 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
+ entry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{customRoll !== null && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-center">
|
||||
@@ -286,6 +544,11 @@ export function RandomTables() {
|
||||
|
||||
{/* Table preview */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{gaps > 0 ? (
|
||||
<div className="text-[9px] text-[var(--color-danger)] mb-1">
|
||||
! {gaps} of {sides} faces uncovered - some rolls hit No result.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{table.entries.map((e) => (
|
||||
<div
|
||||
@@ -297,7 +560,8 @@ export function RandomTables() {
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono w-8 text-right shrink-0">{e.min === e.max ? e.min : `${e.min}-${e.max}`}</span>
|
||||
<span className="truncate">{e.result}</span>
|
||||
<span className="truncate flex-1">{e.result}</span>
|
||||
{(() => { const s = diceSides(table.dice); const w = Math.round(((e.max - e.min + 1) / s) * 100); return w !== 100 ? <span className="font-mono text-[9px] text-[var(--color-text-dim)] shrink-0" title="Entry weight">{w}%</span> : null; })()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+280
-103
@@ -1,80 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { addToLore } from "../lib/lore";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { renderMarkdown } from "../lib/markdown";
|
||||
|
||||
interface Props {
|
||||
prefill?: Generation | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
text: string;
|
||||
time: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
entries: SessionEntry[];
|
||||
summary: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const TAGS = ["#combat", "#roleplay", "#loot", "#quest", "#npc", "#misc"];
|
||||
|
||||
function newSession(title: string): Session {
|
||||
return { id: crypto.randomUUID(), title, entries: [], summary: "", createdAt: Date.now() };
|
||||
}
|
||||
|
||||
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
// ponytail: multiple sessions as a first-class concept. The whole roster
|
||||
// persists under one key; the active session id is stored separately so a
|
||||
// reload lands on the session the DM was last editing.
|
||||
const [sessions, setSessions] = usePersistentState<Session[]>("session.list", [newSession("Session 1")]);
|
||||
const [activeId, setActiveId] = usePersistentState<string>("session.active", "");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [summary, setSummary] = usePersistentState<string>("session.summary", "");
|
||||
const [tag, setTag] = useState(TAGS[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// ponytail: streaming state — tokens arrive via the Channel and append to
|
||||
// the summary live with a gold cursor. `gotFirstToken` flips the thinking
|
||||
// dots to streaming text.
|
||||
const [gotFirstToken, setGotFirstToken] = useState(false);
|
||||
const [entries, setEntries] = usePersistentState<{ text: string; time: string }[]>("session.entries", []);
|
||||
const [filterTag, setFilterTag] = useState<string | null>(null);
|
||||
const [previewSummary, setPreviewSummary] = useState(true);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: ensure an active session exists (first run / migration).
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) {
|
||||
const s = newSession("Session 1");
|
||||
setSessions([s]);
|
||||
setActiveId(s.id);
|
||||
} else if (!sessions.some((s) => s.id === activeId) && activeId !== "") {
|
||||
setActiveId(sessions[0].id);
|
||||
} else if (activeId === "") {
|
||||
setActiveId(sessions[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessions.length]);
|
||||
|
||||
const active = useMemo(
|
||||
() => sessions.find((s) => s.id === activeId) ?? sessions[0],
|
||||
[sessions, activeId],
|
||||
);
|
||||
|
||||
// ponytail: helpers that update the active session within the roster.
|
||||
function updateActive(updater: (s: Session) => Session) {
|
||||
setSessions((prev) => prev.map((s) => (s.id === active?.id ? updater(s) : s)));
|
||||
}
|
||||
|
||||
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
||||
setSummary(g.data);
|
||||
if (!active) return;
|
||||
updateActive((s) => ({ ...s, summary: g.data }));
|
||||
addToast(`Loaded "${g.title}" from history`, "info");
|
||||
});
|
||||
|
||||
function addEntry() {
|
||||
if (!notes.trim()) return;
|
||||
setEntries((prev) => [
|
||||
{ text: notes.trim(), time: new Date().toLocaleTimeString() },
|
||||
...prev,
|
||||
]);
|
||||
if (!notes.trim() || !active) return;
|
||||
updateActive((s) => ({
|
||||
...s,
|
||||
entries: [{ text: notes.trim(), time: new Date().toLocaleString(), tag }, ...s.entries],
|
||||
}));
|
||||
setNotes("");
|
||||
}
|
||||
|
||||
// ponytail: export the session as a Markdown file the DM can share or
|
||||
// paste into Discord. Reverses the entry list so it reads chronologically.
|
||||
function removeEntry(i: number) {
|
||||
updateActive((s) => ({ ...s, entries: s.entries.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
function addSession() {
|
||||
const s = newSession(`Session ${sessions.length + 1}`);
|
||||
setSessions((prev) => [...prev, s]);
|
||||
setActiveId(s.id);
|
||||
setNotes("");
|
||||
setFilterTag(null);
|
||||
}
|
||||
|
||||
function deleteSession() {
|
||||
if (sessions.length <= 1) {
|
||||
addToast("Keep at least one session", "info");
|
||||
return;
|
||||
}
|
||||
const idx = sessions.findIndex((s) => s.id === active?.id);
|
||||
const next = sessions.filter((s) => s.id !== active?.id);
|
||||
setSessions(next);
|
||||
setActiveId(next[Math.max(0, idx - 1)].id);
|
||||
addToast("Session deleted", "info");
|
||||
}
|
||||
|
||||
function exportMarkdown() {
|
||||
if (entries.length === 0) return;
|
||||
const lines = [...entries].reverse().map((e) => `- [${e.time}] ${e.text}`);
|
||||
const md = `# Session ${new Date().toLocaleDateString()}\n\n## Notes\n\n${lines.join("\\n")}\n\n${summary ? `## AI Summary\n\n${summary}` : ""}`;
|
||||
if (!active || active.entries.length === 0) return;
|
||||
const lines = [...active.entries].reverse().map((e) => `- [${e.time}] ${e.tag ? `${e.tag} ` : ""}${e.text}`);
|
||||
const md = `# ${active.title} (${new Date(active.createdAt).toLocaleDateString()})\n\n## Notes\n\n${lines.join("\n")}\n\n${active.summary ? `## AI Summary\n\n${active.summary}` : ""}`;
|
||||
const blob = new Blob([md], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `session-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.download = `${active.title.toLowerCase().replace(/\s+/g, "-")}-${new Date().toISOString().slice(0, 10)}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast("Exported session to Markdown", "success");
|
||||
}
|
||||
|
||||
function removeEntry(i: number) {
|
||||
setEntries((prev) => prev.filter((_, j) => j !== i));
|
||||
}
|
||||
|
||||
async function summarize() {
|
||||
if (entries.length === 0) return;
|
||||
if (!active || active.entries.length === 0) return;
|
||||
setLoading(true);
|
||||
setSummary("");
|
||||
updateActive((s) => ({ ...s, summary: "" }));
|
||||
setGotFirstToken(false);
|
||||
const sessionText = entries.map((e) => `[${e.time}] ${e.text}`).join("\n");
|
||||
// ponytail: stream tokens via the existing generate_stream Channel so the
|
||||
// DM sees the summary appear word-by-word instead of staring at a button.
|
||||
const sessionText = active.entries.map((e) => `[${e.time}] ${e.tag ? `${e.tag} ` : ""}${e.text}`).join("\n");
|
||||
const channel = new Channel<{ type: string; data: string }>();
|
||||
let streamed = "";
|
||||
channel.onmessage = (event) => {
|
||||
if (event.type === "token") {
|
||||
setGotFirstToken(true);
|
||||
streamed = streamed + event.data;
|
||||
setSummary(streamed);
|
||||
updateActive((s) => ({ ...s, summary: streamed }));
|
||||
} else if (event.type === "done") {
|
||||
setGotFirstToken(true);
|
||||
streamed = event.data;
|
||||
setSummary(streamed);
|
||||
updateActive((s) => ({ ...s, summary: streamed }));
|
||||
} else if (event.type === "error") {
|
||||
addToast(`Summary failed: ${event.data}`, "error");
|
||||
}
|
||||
@@ -91,90 +157,201 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
});
|
||||
addToast("Session summary generated", "success");
|
||||
const result = streamed;
|
||||
const title = `Session ${new Date().toLocaleDateString()}`;
|
||||
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
||||
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||
const title = active.title;
|
||||
void addGeneration({ kind: "session", title, data: result, source: `${active.entries.length} notes` });
|
||||
void addToLore(`Session: ${title}`, `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||
} catch (e) {
|
||||
addToast(`Summary failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const visibleEntries = filterTag === null ? active.entries : active.entries.filter((e) => e.tag === filterTag);
|
||||
const usedTags = TAGS.filter((t) => active.entries.some((e) => e.tag === t));
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 h-full">
|
||||
{/* Notes column */}
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<textarea
|
||||
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addEntry();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a session note, press Enter to add…"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addEntry}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Session switcher */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={active.title}
|
||||
onChange={(e) => updateActive((s) => ({ ...s, title: e.target.value }))}
|
||||
onBlur={() => setRenaming(false)}
|
||||
onKeyDown={(e) => e.key === "Enter" && setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={active.id}
|
||||
onChange={(e) => { setActiveId(e.target.value); setFilterTag(null); setNotes(""); }}
|
||||
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
aria-label="Switch session"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
onClick={summarize}
|
||||
disabled={loading || entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportMarkdown}
|
||||
disabled={entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Export notes + summary to a Markdown file"
|
||||
>
|
||||
⬇ Export
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{entries.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
||||
{e.time}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEntry(i)}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title} ({s.entries.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setRenaming((r) => !r)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
title="Rename session"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
onClick={addSession}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer"
|
||||
title="New session"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteSession}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1 text-xs hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] cursor-pointer"
|
||||
title="Delete this session"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Summary column */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
|
||||
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
||||
{loading && !gotFirstToken ? (
|
||||
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
</span>
|
||||
) : summary ? (
|
||||
<span>{summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-dim)] italic">
|
||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-3 flex-1 min-h-0">
|
||||
{/* Notes column */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-h-0">
|
||||
<textarea
|
||||
className="flex-1 min-h-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addEntry();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a session note, press Enter to add…"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
aria-label="Tag for new note"
|
||||
>
|
||||
{TAGS.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={addEntry}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
<button
|
||||
onClick={summarize}
|
||||
disabled={loading || active.entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportMarkdown}
|
||||
disabled={active.entries.length === 0}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Export notes + summary to a Markdown file"
|
||||
>
|
||||
⬇ Export
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* ponytail: tag filter — click a chip to filter, click again to clear. */}
|
||||
{usedTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
<button
|
||||
onClick={() => setFilterTag(null)}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterTag === null
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
{usedTags.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setFilterTag((f) => (f === t ? null : t))}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
filterTag === t
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{visibleEntries.map((e, i) => (
|
||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
||||
{e.time}
|
||||
</span>
|
||||
{e.tag && <span className="text-[var(--color-gold-bright)] text-[10px] shrink-0">{e.tag}</span>}
|
||||
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
||||
<button
|
||||
onClick={() => removeEntry(active.entries.indexOf(e))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete entry"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary column */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)]">AI Summary</h3>
|
||||
{active.summary ? (
|
||||
<button
|
||||
onClick={() => setPreviewSummary((p) => !p)}
|
||||
className={`rounded px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||
previewSummary
|
||||
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
title="Toggle Markdown preview"
|
||||
>
|
||||
{previewSummary ? "Preview" : "Raw"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
||||
{loading && !gotFirstToken ? (
|
||||
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
<span className="thinking-dot">•</span>
|
||||
</span>
|
||||
) : active.summary ? (
|
||||
previewSummary ? (
|
||||
<div className="md-preview" dangerouslySetInnerHTML={{ __html: renderMarkdown(active.summary) }} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{active.summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[var(--color-text-dim)] italic">
|
||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface LlmConfig {
|
||||
@@ -13,6 +14,12 @@ interface LlmConfig {
|
||||
embed_model: string;
|
||||
}
|
||||
|
||||
interface DataDirInfo {
|
||||
current: string;
|
||||
configured: string | null;
|
||||
default: string;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
const [config, setConfig] = useState<LlmConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -21,6 +28,14 @@ export function SettingsPanel() {
|
||||
const [error, setError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
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: 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");
|
||||
const [sqlResult, setSqlResult] = useState<{ columns: string[]; rows: (string | number | null)[][] } | null>(null);
|
||||
const [sqlError, setSqlError] = useState("");
|
||||
const [sqlLoading, setSqlLoading] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||
@@ -32,9 +47,78 @@ export function SettingsPanel() {
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
try {
|
||||
setDataDir(await invoke<DataDirInfo>("get_data_dir"));
|
||||
} catch {
|
||||
// non-fatal — data dir control just stays hidden
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ponytail: pick a folder, copy existing campaign data into it, persist the
|
||||
// preference. The open SQLite connections stay on the old paths until the
|
||||
// DM restarts, so we tell them to relaunch.
|
||||
async function chooseDataDir() {
|
||||
try {
|
||||
const picked = await open({ directory: true, multiple: false, title: "Choose where DM-Pal saves campaign data" });
|
||||
if (!picked || typeof picked !== "string") return;
|
||||
setRelocating(true);
|
||||
const info = await invoke<DataDirInfo>("set_data_dir", { newDir: picked });
|
||||
setDataDir(info);
|
||||
addToast(`Campaign data will save to ${picked} — restart DM-Pal to apply. Existing data was copied there.`, "success");
|
||||
} catch (e) {
|
||||
addToast(`Couldn't set data location: ${e}`, "error");
|
||||
}
|
||||
setRelocating(false);
|
||||
}
|
||||
|
||||
async function resetDataDir() {
|
||||
try {
|
||||
await invoke("reset_data_dir");
|
||||
setDataDir(await invoke<DataDirInfo>("get_data_dir"));
|
||||
addToast("Reverted to default location — restart DM-Pal to apply", "info");
|
||||
} catch (e) {
|
||||
addToast(`Couldn't reset: ${e}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ponytail: run a read-only query against the selected DB. The backend gates
|
||||
// on SELECT/PRAGMA/WITH, so this is safe to expose in Settings.
|
||||
async function runSql() {
|
||||
setSqlLoading(true);
|
||||
setSqlError("");
|
||||
setSqlResult(null);
|
||||
try {
|
||||
const res = await invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db: sqlDb, sql: sqlInput } });
|
||||
setSqlResult(res);
|
||||
if (res.rows.length === 0) setSqlError("(0 rows)");
|
||||
} catch (e) {
|
||||
setSqlError(String(e));
|
||||
}
|
||||
setSqlLoading(false);
|
||||
}
|
||||
|
||||
function quickQuery(q: string) {
|
||||
setSqlInput(q);
|
||||
// run after the state flushes
|
||||
setTimeout(runSql, 0);
|
||||
}
|
||||
|
||||
function switchDb(db: "lore" | "generations") {
|
||||
setSqlDb(db);
|
||||
setSqlResult(null);
|
||||
setSqlError("");
|
||||
const q = "SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name";
|
||||
setSqlInput(q);
|
||||
// ponytail: runSql reads sqlDb from closure (stale here), so inline the
|
||||
// invoke with the new db to avoid a second render round-trip.
|
||||
setSqlLoading(true);
|
||||
invoke<{ columns: string[]; rows: (string | number | null)[][] }>("sql_query", { req: { db, sql: q } })
|
||||
.then((res) => { setSqlResult(res); if (res.rows.length === 0) setSqlError("(0 rows)"); })
|
||||
.catch((e) => setSqlError(String(e)))
|
||||
.finally(() => setSqlLoading(false));
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!config) return;
|
||||
setLoading(true);
|
||||
@@ -102,6 +186,38 @@ export function SettingsPanel() {
|
||||
addToast("Reset to defaults — click Save to apply", "info");
|
||||
}
|
||||
|
||||
// ponytail: export/import the whole config as JSON. Export downloads a file;
|
||||
// import reads a file and merges into the form (Save still required to persist).
|
||||
function exportConfig() {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "dm-pal-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
addToast("Config exported", "success");
|
||||
}
|
||||
|
||||
function importConfig(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result)) as Partial<LlmConfig>;
|
||||
setConfig((c) => ({ ...c, ...parsed } as LlmConfig));
|
||||
setConn(null);
|
||||
addToast("Config imported — click Save to apply", "info");
|
||||
} catch {
|
||||
addToast("Invalid config file", "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
@@ -115,6 +231,51 @@ export function SettingsPanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
{/* Storage - where campaign data (lore db, generations db, generated images) lives. */}
|
||||
{dataDir ? (
|
||||
<>
|
||||
<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">Storage</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Campaign data location</label>
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||
Where the lore database, generations database, and generated images are saved.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 truncate rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] text-xs" title={dataDir.current}>
|
||||
{dataDir.current}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chooseDataDir}
|
||||
disabled={relocating}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{relocating ? "Moving…" : "Choose…"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetDataDir}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-xs hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
|
||||
title="Use the OS default location"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
{dataDir.configured ? (
|
||||
dataDir.configured !== dataDir.current ? (
|
||||
<div className="rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-gold-bright)]/40 px-3 py-2 text-xs text-[var(--color-gold-bright)] flex items-center justify-between gap-2">
|
||||
<span>
|
||||
⏳ Pending move to <code className="font-mono">{dataDir.configured}</code> - restart DM-Pal to apply.
|
||||
</span>
|
||||
<button onClick={resetDataDir} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer underline" title="Cancel the pending move">
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<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">LLM Connection</h3>
|
||||
{/* Provider presets */}
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -287,7 +448,7 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={loading}
|
||||
@@ -301,7 +462,98 @@ export function SettingsPanel() {
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
<button
|
||||
onClick={exportConfig}
|
||||
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"
|
||||
>
|
||||
⬆ Export
|
||||
</button>
|
||||
<label 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 flex items-center">
|
||||
⬇ Import
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={importConfig}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced — read-only SQLite viewer for inspecting the backend DBs. */}
|
||||
<details
|
||||
className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)]"
|
||||
onToggle={(e) => { if ((e.target as HTMLDetailsElement).open && !sqlResult && !sqlLoading) runSql(); }}
|
||||
>
|
||||
<summary className="px-3 py-2 text-xs font-medium text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)] select-none">
|
||||
Advanced · SQLite viewer
|
||||
</summary>
|
||||
<div className="p-3 flex flex-col gap-2 border-t border-[var(--color-border-subtle)]">
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] leading-relaxed">
|
||||
Read-only inspection of the local databases (lore.db, generations.db). Only SELECT / PRAGMA / WITH statements run. Embedding vectors show as <code><blob N bytes></code>.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={sqlDb}
|
||||
onChange={(e) => switchDb(e.target.value as "lore" | "generations")}
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
|
||||
aria-label="Database"
|
||||
>
|
||||
<option value="generations">Generations DB</option>
|
||||
<option value="lore">Lore DB</option>
|
||||
</select>
|
||||
<button onClick={() => quickQuery("SELECT name, type FROM sqlite_master WHERE type='table' ORDER BY name")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Tables</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "PRAGMA table_info(chunks)" : "PRAGMA table_info(generations)")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Schema</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "SELECT id, source, substr(text,1,80) AS preview FROM chunks ORDER BY id DESC LIMIT 25" : "SELECT * FROM generations ORDER BY created_at DESC LIMIT 25")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Browse</button>
|
||||
<button onClick={() => quickQuery(sqlDb === "lore" ? "SELECT source, COUNT(*) AS chunks FROM chunks GROUP BY source ORDER BY chunks DESC" : "SELECT kind, COUNT(*) AS n FROM generations GROUP BY kind")} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2.5 py-1.5 text-[10px] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] cursor-pointer">Count</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono min-h-20 resize-y"
|
||||
value={sqlInput}
|
||||
onChange={(e) => setSqlInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.metaKey || e.ctrlKey) && runSql()}
|
||||
spellCheck={false}
|
||||
placeholder="SELECT * FROM … (⌘Enter to run)"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={runSql}
|
||||
disabled={sqlLoading}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{sqlLoading ? "Running…" : "Run query"}
|
||||
</button>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">⌘Enter to run · read-only</span>
|
||||
</div>
|
||||
|
||||
{sqlError ? (
|
||||
<p className="text-[10px] text-[var(--color-danger)] font-mono whitespace-pre-wrap">{sqlError}</p>
|
||||
) : null}
|
||||
|
||||
{sqlResult ? (
|
||||
<div className="rounded-lg border border-[var(--color-border-subtle)] overflow-hidden">
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<table className="w-full text-[10px] font-mono">
|
||||
<thead className="sticky top-0 bg-[var(--color-bg-surface)]">
|
||||
<tr>
|
||||
{sqlResult.columns.map((c) => <th key={c} className="text-left text-[var(--color-gold-bright)] px-2 py-1 border-b border-[var(--color-border-subtle)] whitespace-nowrap">{c}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sqlResult.rows.map((row, i) => (
|
||||
<tr key={i} className="odd:bg-[var(--color-bg-surface)]/30">
|
||||
{row.map((cell, j) => <td key={j} className="px-2 py-1 text-[var(--color-text-secondary)] max-w-64 truncate" title={String(cell)}>{cell === null ? <span className="text-[var(--color-text-dim)]">NULL</span> : String(cell)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-2 py-1 text-[10px] text-[var(--color-text-dim)] border-t border-[var(--color-border-subtle)]">
|
||||
{sqlResult.rows.length} row{sqlResult.rows.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
-1
@@ -242,4 +242,28 @@ body,
|
||||
[data-tooltip]::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ponytail: rendered Markdown for the session summary. Uses design tokens so
|
||||
it matches the glass-card surface. */
|
||||
.md-preview h1, .md-preview h2, .md-preview h3 {
|
||||
font-family: var(--font-heading), serif;
|
||||
color: var(--color-gold-bright);
|
||||
font-weight: 600;
|
||||
margin: 0.6em 0 0.3em;
|
||||
}
|
||||
.md-preview h1 { font-size: 1.15rem; }
|
||||
.md-preview h2 { font-size: 1.05rem; }
|
||||
.md-preview h3 { font-size: 0.95rem; }
|
||||
.md-preview p { margin: 0.35em 0; line-height: 1.5; }
|
||||
.md-preview ul { margin: 0.35em 0; padding-left: 1.2em; list-style: disc; }
|
||||
.md-preview li { margin: 0.15em 0; }
|
||||
.md-preview code {
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.85em;
|
||||
background: var(--color-bg-deep);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.md-preview a { color: var(--color-gold-bright); text-decoration: underline; }
|
||||
.md-preview strong { color: var(--color-text-primary); }
|
||||
@@ -34,11 +34,102 @@ export function parseLlmJson<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch (e) {
|
||||
// ponytail: LLMs often emit *almost*-valid JSON. Strict parse runs first so
|
||||
// valid output is never altered; only on failure do we try a conservative
|
||||
// repair pass (surface fixes → merge stray array literals → balance brackets).
|
||||
const repaired = repairJson(jsonStr);
|
||||
if (repaired !== jsonStr) {
|
||||
try {
|
||||
const parsed = JSON.parse(repaired) as T;
|
||||
if (parsed) return parsed;
|
||||
} catch (e2) {
|
||||
console.error("JSON parse error (after repair):", e2, "Input:", jsonStr);
|
||||
}
|
||||
}
|
||||
console.error("JSON parse error:", e, "Input:", jsonStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative repairs for common LLM JSON mistakes. String-aware (never
|
||||
* touches text inside double-quoted strings — that was the bug that turned
|
||||
* "Coop's" / "crows'" into garbage). Only applied on parse failure. */
|
||||
function repairJson(input: string): string {
|
||||
return balanceJson(mergeSeparateArrays(surfaceFixes(input)));
|
||||
}
|
||||
|
||||
/** Drop trailing commas before closers and convert smart quotes → straight,
|
||||
* only outside double-quoted strings. */
|
||||
function surfaceFixes(s: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let inStr = false;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (inStr) {
|
||||
out += ch;
|
||||
if (ch === "\\") { out += s[i + 1] ?? ""; i += 2; continue; }
|
||||
if (ch === '"') inStr = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inStr = true; out += ch; i++; continue; }
|
||||
if (ch === "“" || ch === "”" || ch === "„") { out += '"'; i++; continue; }
|
||||
if (ch === ",") {
|
||||
let j = i + 1;
|
||||
while (j < s.length && /\s/.test(s[j])) j++;
|
||||
if (s[j] === "}" || s[j] === "]") { i++; continue; } // drop trailing comma
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collapse the common "array of strings written as separate array literals"
|
||||
* mistake: ["a"], ["b"], ["c"] → "a", "b", "c" (inside one array). */
|
||||
function mergeSeparateArrays(s: string): string {
|
||||
return s.replace(/"\s*\]\s*,\s*\[\s*"/g, '", "');
|
||||
}
|
||||
|
||||
/** Stack-based bracket balance. On a closer that doesn't match the top, close
|
||||
* the open intermediate openers first (inserts the `}` the LLM forgot before a
|
||||
* `]`), then match. Closers with no matching opener anywhere are stray → skip.
|
||||
* Any still-open openers at the end get their closers appended. */
|
||||
function balanceJson(s: string): string {
|
||||
let out = "";
|
||||
const st: string[] = [];
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
const closeOf = (o: string) => (o === "{" ? "}" : "]");
|
||||
for (const ch of s) {
|
||||
if (inStr) {
|
||||
out += ch;
|
||||
if (esc) esc = false;
|
||||
else if (ch === "\\") esc = true;
|
||||
else if (ch === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inStr = true; out += ch; continue; }
|
||||
if (ch === "{" || ch === "[") { st.push(ch); out += ch; continue; }
|
||||
if (ch === "}" || ch === "]") {
|
||||
const want = ch === "}" ? "{" : "[";
|
||||
if (st.includes(want)) {
|
||||
while (st.length) {
|
||||
const top = st.pop()!;
|
||||
out += top === want ? ch : closeOf(top);
|
||||
if (top === want) break;
|
||||
}
|
||||
}
|
||||
// else: no matching opener anywhere → stray closer, skip
|
||||
continue;
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
while (st.length) out += closeOf(st.pop()!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested objects into display-friendly strings.
|
||||
* If a value is an object, stringify it nicely.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// ponytail: minimal Markdown → HTML for session notes/summaries. Renders the
|
||||
// 80% a DM actually uses (headings, bold/italic, lists, code, paragraphs) in
|
||||
// ~40 lines. No dependency. Escapes HTML first so LLM output can't inject.
|
||||
// Upgrade path: swap for @uiw/react-md-editor if you need tables/images/GFM.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render a subset of Markdown to HTML. Input is treated as untrusted. */
|
||||
export function renderMarkdown(md: string): string {
|
||||
const lines = md.split("\n");
|
||||
const html: string[] = [];
|
||||
let inList = false;
|
||||
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line.trim()) { closeList(); continue; }
|
||||
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (h) {
|
||||
closeList();
|
||||
const level = h[1].length;
|
||||
html.push(`<h${level}>${inline(h[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
const li = line.match(/^[-*+]\s+(.*)$/);
|
||||
if (li) {
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${inline(li[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
const ol = line.match(/^\d+[.)]\s+(.*)$/);
|
||||
if (ol) {
|
||||
// ponytail: render ordered items as an unordered list too — keeping it
|
||||
// one-element-type saves a state machine. A DM reading notes won't mind.
|
||||
if (!inList) { html.push("<ul>"); inList = true; }
|
||||
html.push(`<li>${inline(ol[1])}</li>`);
|
||||
continue;
|
||||
}
|
||||
closeList();
|
||||
html.push(`<p>${inline(line)}</p>`);
|
||||
}
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
// inline formatting: **bold**, *italic*, `code`, [text](url).
|
||||
function inline(s: string): string {
|
||||
let out = escapeHtml(s);
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
return out;
|
||||
}
|
||||
|
||||
// (self-check lives in the repo's node test mirror; this module is pure).
|
||||
Reference in New Issue
Block a user