building out the components, updating the UI in overview and adding a little sqlite viewer

This commit is contained in:
itsamejms
2026-07-17 09:31:51 +01:00
parent 82f2dcf3df
commit b89a39ec9d
30 changed files with 2664 additions and 635 deletions
+232 -97
View File
@@ -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 01, 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 01, 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>
);