building out the components, updating the UI in overview and adding a little sqlite viewer
This commit is contained in:
+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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user