Files
dm-pal/src/components/LorePanel.tsx
T
2026-08-09 13:49:54 +01:00

377 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react";
import { useToast } from "./Toast";
import { addToLore } from "../lib/lore";
interface RagSource {
source: string;
chunks: number;
}
interface RagHit {
text: string;
source: string;
score: number;
}
interface RagChunk {
id: number;
preview: string;
}
export function LorePanel() {
const [source, setSource] = useState("");
const [text, setText] = useState("");
const [sources, setSources] = useState<RagSource[]>([]);
const [adding, setAdding] = useState(false);
const [addingDir, setAddingDir] = useState(false);
const [msg, setMsg] = useState("");
const [query, setQuery] = useState("");
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 {
setSources(await invoke<RagSource[]>("rag_list"));
} catch (e) {
console.error(e);
}
}
useEffect(() => {
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);
setMsg("");
try {
const n = await invoke<number>("rag_add", { req: { source, text } });
setMsg(`Indexed ${n} chunk${n === 1 ? "" : "s"}`);
setText("");
loadSources();
} catch (e) {
setMsg(String(e));
}
setAdding(false);
setTimeout(() => setMsg(""), 3000);
}
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");
}
// ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only).
// Doing the walk in Rust sidesteps the webview fs scope — an external
// world-bible folder needs no fs permission grant this way.
async function addDirectory() {
const selected = await open({ directory: true, multiple: false });
if (!selected || typeof selected !== "string") return;
setAddingDir(true);
try {
const report = await invoke<{ files: number; chunks: number; skipped: string[] }>(
"rag_add_directory",
{ req: { path: selected } },
);
await loadSources();
if (report.files === 0) {
addToast("No .md / .txt files found in that folder", "info");
} else {
addToast(
`Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`,
"success",
);
}
for (const s of report.skipped) console.warn("lore dir import skipped:", s);
} catch (e) {
addToast(`Folder import failed: ${e}`, "error");
}
setAddingDir(false);
}
async function search() {
if (!query.trim()) return;
setSearching(true);
try {
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);
}
setSearching(false);
}
return (
<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>
{/* ponytail: directory import — walks every .md/.txt recursively in
Rust (no webview fs-scope needed for an external folder). */}
<button
onClick={addDirectory}
disabled={addingDir}
className="flex items-center gap-2 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-sm text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
>
<FolderOpen size={14} />
{addingDir ? "Indexing…" : "Add folder…"}
</button>
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
</div>
<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={() => 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>
</div>
{/* 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 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()}
placeholder="Ask something your lore should answer…"
/>
<button
onClick={search}
disabled={searching}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{searching ? "…" : "Search"}
</button>
</div>
{/* 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>
<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>
);
}