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([]); const [adding, setAdding] = useState(false); const [addingDir, setAddingDir] = useState(false); const [msg, setMsg] = useState(""); const [query, setQuery] = useState(""); const [hits, setHits] = useState([]); const [searching, setSearching] = useState(false); const [confirmClear, setConfirmClear] = useState(false); // ponytail: source filter — multi-select chips. Empty set = search all. const [filterSources, setFilterSources] = useState>(new Set()); // ponytail: chunk preview — expand a source to see its embedded chunks. const [expanded, setExpanded] = useState(null); const [chunks, setChunks] = useState([]); const [loadingChunks, setLoadingChunks] = useState(false); const { addToast } = useToast(); async function loadSources() { try { setSources(await invoke("rag_list")); } catch (e) { console.error(e); } } useEffect(() => { loadSources(); }, []); async function loadChunks(s: string) { setLoadingChunks(true); try { setChunks(await invoke("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("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) { 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("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 (
{/* Left pane: add + indexed sources */}

Add to Lore

setSource(e.target.value)} placeholder="Source name (e.g. World Bible, Session 3 Notes)" />