From 4e95e6ec4d2871706341777bd3ea0d31ef0cf465 Mon Sep 17 00:00:00 2001 From: Santiago Rivera <87353936+1816x@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:02:31 -0600 Subject: [PATCH 1/3] Expose document and runtime metadata to the app --- app/app/api/documents/route.ts | 12 ++++++++---- app/app/api/stats/route.ts | 13 +++++++++++++ app/app/lib/rag.ts | 16 ++++++++++++++++ service/rag_service/app.py | 4 +++- service/rag_service/store.py | 2 ++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 app/app/api/stats/route.ts diff --git a/app/app/api/documents/route.ts b/app/app/api/documents/route.ts index 396616d..fefefdd 100644 --- a/app/app/api/documents/route.ts +++ b/app/app/api/documents/route.ts @@ -14,11 +14,15 @@ export async function GET() { export async function POST(req: Request) { try { - const { title, text } = await req.json(); - if (!title || !text) { - return NextResponse.json({ error: "title and text are required" }, { status: 400 }); + const body = (await req.json()) as { title?: unknown; text?: unknown }; + if (typeof body.title !== "string" || !body.title.trim()) { + return NextResponse.json({ error: "title is required" }, { status: 400 }); } - return NextResponse.json(await addDocument(title, text)); + if (typeof body.text !== "string" || !body.text.trim()) { + return NextResponse.json({ error: "document text is required" }, { status: 400 }); + } + + return NextResponse.json(await addDocument(body.title.trim(), body.text)); } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : "unknown error" }, diff --git a/app/app/api/stats/route.ts b/app/app/api/stats/route.ts new file mode 100644 index 0000000..1027f0f --- /dev/null +++ b/app/app/api/stats/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getStats } from "@/app/lib/rag"; + +export async function GET() { + try { + return NextResponse.json(await getStats()); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "unknown error" }, + { status: 502 }, + ); + } +} diff --git a/app/app/lib/rag.ts b/app/app/lib/rag.ts index 6ad02b0..85a84d2 100644 --- a/app/app/lib/rag.ts +++ b/app/app/lib/rag.ts @@ -28,6 +28,18 @@ export interface DocumentInfo { n_chunks: number; } +export interface Stats { + documents: number; + chunks: number; + dim: number; + metric: string; + min_score: number | null; + m: number; + ef_construction: number; + embedder: string; + generation: "mock" | "claude"; +} + async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${SERVICE_URL}${path}`, { ...init, @@ -58,3 +70,7 @@ export function addDocument(title: string, text: string): Promise export function listDocuments(): Promise { return call("/documents"); } + +export function getStats(): Promise { + return call("/stats"); +} diff --git a/service/rag_service/app.py b/service/rag_service/app.py index 1ccea2f..8f488ce 100644 --- a/service/rag_service/app.py +++ b/service/rag_service/app.py @@ -131,7 +131,9 @@ def healthz() -> dict: @app.get("/stats") def stats() -> dict: - return _store.stats() + data = _store.stats() + data["generation"] = "claude" if os.environ.get("ANTHROPIC_API_KEY") else "mock" + return data @app.get("/documents", response_model=List[DocumentOut]) diff --git a/service/rag_service/store.py b/service/rag_service/store.py index d370604..86339dd 100644 --- a/service/rag_service/store.py +++ b/service/rag_service/store.py @@ -146,6 +146,8 @@ def stats(self) -> dict: "dim": self.dim, "metric": self.metric, "min_score": self.min_score, + "m": self.m, + "ef_construction": self.ef_construction, # Which embedding backend is actually live. Worth surfacing: # HashedEmbedder matches on term overlap, not meaning, so a # reader should not mistake it for semantic search. From 6500df8e19033c8039574fb1c107cbb0f33951fd Mon Sep 17 00:00:00 2001 From: Santiago Rivera <87353936+1816x@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:05:59 -0600 Subject: [PATCH 2/3] Add document workspace to the UI --- app/app/globals.css | 723 +++++++++++++++++++++++++++++++++++++++----- app/app/layout.tsx | 5 +- app/app/page.tsx | 473 +++++++++++++++++++++++++---- 3 files changed, 1067 insertions(+), 134 deletions(-) diff --git a/app/app/globals.css b/app/app/globals.css index 5f6b252..0359bd2 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -1,136 +1,711 @@ :root { - --bg: #0f1115; - --panel: #181b22; - --border: #262a33; - --text: #e6e8ec; - --muted: #9aa2b1; - --accent: #6ea8fe; - --cited: #2ea043; + --bg: #090d14; + --panel: rgba(18, 24, 35, 0.88); + --panel-strong: #141b27; + --panel-soft: #101620; + --border: #263244; + --border-soft: rgba(148, 163, 184, 0.14); + --text: #eef3f8; + --muted: #94a3b8; + --muted-strong: #bac5d3; + --accent: #7dd3fc; + --accent-strong: #38bdf8; + --green: #5ee2a0; + --amber: #f7c873; + --danger: #ff8b8b; + --radius: 16px; } * { box-sizing: border-box; } +html { + background: var(--bg); +} + body { + min-height: 100vh; margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - background: var(--bg); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; color: var(--text); - line-height: 1.5; + background: + radial-gradient(circle at 12% 0%, rgba(56, 189, 248, 0.1), transparent 28rem), + radial-gradient(circle at 88% 8%, rgba(94, 226, 160, 0.07), transparent 24rem), + var(--bg); + line-height: 1.55; } -.layout { - display: grid; - grid-template-columns: 1fr 380px; - gap: 24px; - max-width: 1100px; +button, +input, +textarea { + font: inherit; +} + +button { + border: 0; +} + +.shell { + width: min(1220px, calc(100% - 40px)); margin: 0 auto; - padding: 32px 24px; + padding: 48px 0 72px; } -@media (max-width: 820px) { - .layout { - grid-template-columns: 1fr; - } +.hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 40px; + padding: 14px 0 34px; +} + +.eyebrow, +.section-kicker { + margin: 0 0 8px; + color: var(--accent); + font-size: 0.7rem; + font-weight: 750; + letter-spacing: 0.16em; +} + +.hero h1 { + max-width: 700px; + margin: 0; + font-size: clamp(2.45rem, 6vw, 4.85rem); + font-weight: 650; + letter-spacing: -0.055em; + line-height: 0.98; +} + +.hero-copy { + max-width: 650px; + margin: 18px 0 0; + color: var(--muted-strong); + font-size: 1.02rem; +} + +.mode-strip { + display: grid; + gap: 10px; + min-width: 220px; +} + +.mode-badge { + display: flex; + align-items: center; + gap: 11px; + min-width: 210px; + padding: 10px 13px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(10, 15, 23, 0.72); + backdrop-filter: blur(12px); +} + +.mode-badge .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 14px currentColor; +} + +.mode-badge.blue { + color: var(--accent); } -h1 { - font-size: 1.5rem; - margin: 0 0 4px; +.mode-badge.green { + color: var(--green); } -.subtitle { +.mode-badge.amber { + color: var(--amber); +} + +.mode-badge span:last-child { + display: grid; +} + +.mode-badge small { color: var(--muted); - margin: 0 0 24px; - font-size: 0.9rem; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.mode-badge strong { + color: var(--text); + font-size: 0.86rem; + font-weight: 650; +} + +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; + padding: 12px 14px; + border: 1px solid rgba(255, 139, 139, 0.4); + border-radius: 12px; + background: rgba(92, 24, 34, 0.28); + color: #ffc0c0; + font-size: 0.86rem; +} + +.text-button { + padding: 0; + color: inherit; + background: transparent; + cursor: pointer; + font-weight: 700; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + overflow: hidden; + margin-bottom: 18px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(17, 23, 33, 0.7); +} + +.metric-card { + display: grid; + gap: 2px; + min-height: 112px; + padding: 20px; + border-right: 1px solid var(--border); +} + +.metric-card:last-child { + border-right: 0; +} + +.metric-card > span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-card strong { + margin-top: 7px; + font-size: 1.45rem; + font-weight: 620; + letter-spacing: -0.03em; +} + +.metric-card small { + color: var(--muted); + font-size: 0.75rem; +} + +.query-grid { + display: grid; + grid-template-columns: minmax(0, 1.18fr) minmax(340px, 0.82fr); + gap: 18px; +} + +.panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(14px); +} + +.query-panel, +.sources-panel, +.upload-panel, +.library-panel { + padding: 24px; +} + +.panel-heading, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.panel-heading { + margin-bottom: 22px; +} + +.panel-heading.compact { + margin-bottom: 18px; +} + +.panel-heading h2, +.panel-heading h3, +.section-heading h2 { + margin: 0; + font-weight: 620; + letter-spacing: -0.025em; +} + +.panel-heading h2, +.section-heading h2 { + font-size: 1.25rem; +} + +.panel-heading h3 { + font-size: 1.08rem; +} + +.step-label { + color: #506078; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; +} + +label { + display: block; + margin: 14px 0 7px; + color: var(--muted-strong); + font-size: 0.78rem; + font-weight: 650; } textarea, input { width: 100%; - background: var(--panel); border: 1px solid var(--border); - border-radius: 8px; + border-radius: 11px; + outline: none; color: var(--text); - padding: 10px 12px; - font-size: 0.95rem; - font-family: inherit; + background: var(--panel-soft); + padding: 12px 13px; + transition: + border-color 160ms ease, + box-shadow 160ms ease; } -button { - background: var(--accent); - color: #0b1020; - border: none; - border-radius: 8px; +textarea { + resize: vertical; + line-height: 1.55; +} + +textarea:focus, +input:focus { + border-color: rgba(56, 189, 248, 0.75); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1); +} + +textarea::placeholder, +input::placeholder { + color: #617086; +} + +.form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 13px; +} + +.form-hint { + color: var(--muted); + font-size: 0.72rem; +} + +.form-actions button { + flex: 0 0 auto; padding: 10px 16px; - font-weight: 600; + border-radius: 10px; + color: #061018; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); cursor: pointer; - font-size: 0.95rem; + font-size: 0.83rem; + font-weight: 750; + transition: + transform 150ms ease, + opacity 150ms ease; +} + +.form-actions button:hover:not(:disabled) { + transform: translateY(-1px); } button:disabled { - opacity: 0.5; cursor: default; + opacity: 0.46; } -.row { - display: flex; - gap: 8px; - margin-top: 12px; +.inline-message { + margin: 14px 0 0; + padding: 10px 12px; + border-radius: 9px; + font-size: 0.8rem; } -.answer { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; - margin-top: 20px; - white-space: pre-wrap; +.error-message { + color: #ffc2c2; + background: rgba(127, 29, 29, 0.28); } -.model-badge { - display: inline-block; - font-size: 0.72rem; +.success-message { + color: #aff2cd; + background: rgba(20, 83, 55, 0.32); +} + +.answer-card { + margin-top: 22px; + padding: 20px; + border: 1px solid rgba(125, 211, 252, 0.26); + border-radius: 13px; + background: rgba(9, 16, 25, 0.68); +} + +.answer-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; color: var(--muted); + font-size: 0.65rem; + font-weight: 750; + letter-spacing: 0.12em; +} + +.answer-mode { border: 1px solid var(--border); border-radius: 999px; - padding: 1px 8px; - margin-bottom: 8px; + padding: 3px 8px; + color: var(--accent); + letter-spacing: 0.04em; + text-transform: none; } -.sources h2 { - font-size: 1rem; - margin: 0 0 12px; +.answer-mode.live { + color: var(--green); } -.source { - background: var(--panel); +.answer-copy { + color: #dbe5ef; + font-size: 0.92rem; + white-space: pre-wrap; +} + +.sources-panel { + min-height: 430px; +} + +.source-count, +.document-total { + display: grid; + min-width: 30px; + height: 30px; + place-items: center; border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - margin-bottom: 10px; - font-size: 0.85rem; + border-radius: 9px; + color: var(--muted-strong); + font-size: 0.74rem; + font-weight: 700; +} + +.empty-state { + display: grid; + gap: 5px; + place-items: center; + min-height: 170px; + padding: 25px; + border: 1px dashed #2d3a4d; + border-radius: 12px; + text-align: center; +} + +.empty-state strong { + font-size: 0.9rem; + font-weight: 620; +} + +.empty-state span { + max-width: 290px; + color: var(--muted); + font-size: 0.78rem; +} + +.source-list { + display: grid; + gap: 10px; +} + +.source-card { + padding: 14px; + border: 1px solid var(--border-soft); + border-radius: 12px; + background: rgba(8, 13, 20, 0.52); +} + +.source-card.cited { + border-color: rgba(94, 226, 160, 0.52); + box-shadow: inset 3px 0 0 rgba(94, 226, 160, 0.74); +} + +.source-card-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; + color: var(--muted); + font-size: 0.7rem; +} + +.source-index { + color: var(--accent); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.source-title { + overflow: hidden; + color: var(--muted-strong); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-score { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.source.cited { - border-color: var(--cited); +.source-card p { + display: -webkit-box; + overflow: hidden; + margin: 10px 0; + color: #bdc8d6; + font-size: 0.78rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; } -.source-head { +.source-footer { display: flex; justify-content: space-between; + color: #6f7e91; + font-size: 0.66rem; +} + +.cited-label { + color: var(--green); + font-weight: 700; +} + +.document-section { + margin-top: 54px; +} + +.section-heading { + align-items: flex-end; + margin-bottom: 18px; +} + +.section-heading p:not(.section-kicker) { + max-width: 650px; + margin: 7px 0 0; + color: var(--muted); + font-size: 0.84rem; +} + +.ghost-button { + padding: 9px 13px; + border: 1px solid var(--border); + border-radius: 9px; + color: var(--muted-strong); + background: rgba(15, 21, 31, 0.75); + cursor: pointer; + font-size: 0.76rem; + font-weight: 650; +} + +.document-grid { + display: grid; + grid-template-columns: minmax(0, 0.92fr) minmax(340px, 1.08fr); + gap: 18px; +} + +.file-input { + padding: 8px; + color: var(--muted); + font-size: 0.78rem; +} + +.file-input::file-selector-button { + margin-right: 11px; + padding: 7px 10px; + border: 0; + border-radius: 7px; + color: #07121b; + background: var(--accent); + cursor: pointer; + font-weight: 700; +} + +.divider { + display: flex; + align-items: center; + gap: 10px; + margin: 17px 0 3px; + color: #607087; + font-size: 0.66rem; + text-transform: uppercase; +} + +.divider::before, +.divider::after { + height: 1px; + flex: 1; + background: var(--border-soft); + content: ""; +} + +.document-list { + display: grid; + gap: 8px; +} + +.document-row { + display: grid; + grid-template-columns: 34px 1fr auto; + gap: 12px; + align-items: center; + padding: 13px; + border: 1px solid var(--border-soft); + border-radius: 11px; + background: rgba(8, 13, 20, 0.45); +} + +.document-number { + color: #5e7189; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.7rem; +} + +.document-row > div { + display: grid; + min-width: 0; +} + +.document-row strong { + overflow: hidden; + font-size: 0.84rem; + font-weight: 620; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-row div span { + color: var(--muted); + font-size: 0.69rem; +} + +.indexed-mark { + color: var(--green); + font-size: 0.65rem; + font-weight: 700; +} + +.mode-explainer { + margin-top: 20px; + padding: 17px; + border: 1px solid rgba(125, 211, 252, 0.16); + border-radius: 12px; + background: rgba(14, 28, 40, 0.48); +} + +.mode-explainer h4 { + margin: 0 0 8px; + font-size: 0.82rem; +} + +.mode-explainer p { + margin: 6px 0 0; color: var(--muted); font-size: 0.75rem; - margin-bottom: 6px; } -.cited-tag { - color: var(--cited); - font-weight: 600; +@media (max-width: 900px) { + .hero { + align-items: flex-start; + flex-direction: column; + } + + .mode-strip { + width: 100%; + grid-template-columns: 1fr 1fr; + } + + .mode-badge { + min-width: 0; + } + + .metrics-grid { + grid-template-columns: 1fr 1fr; + } + + .metric-card:nth-child(2) { + border-right: 0; + } + + .metric-card:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + + .query-grid, + .document-grid { + grid-template-columns: 1fr; + } } -.error { - color: #ff6b6b; - margin-top: 16px; +@media (max-width: 560px) { + .shell { + width: min(100% - 24px, 1220px); + padding-top: 25px; + } + + .hero h1 { + font-size: 2.6rem; + } + + .mode-strip, + .metrics-grid { + grid-template-columns: 1fr; + } + + .metric-card, + .metric-card:nth-child(2) { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .metric-card:last-child { + border-bottom: 0; + } + + .query-panel, + .sources-panel, + .upload-panel, + .library-panel { + padding: 18px; + } + + .form-actions, + .section-heading { + align-items: stretch; + flex-direction: column; + } + + .form-actions button, + .ghost-button { + width: 100%; + } } diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 106f6c8..39707a6 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -2,8 +2,9 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "RAG Engine", - description: "Ask questions over a document set, backed by a from-scratch HNSW index.", + title: "RAG Engine · Retrieval Workbench", + description: + "Index documents, inspect a from-scratch HNSW graph, and ask grounded questions.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { diff --git a/app/app/page.tsx b/app/app/page.tsx index 98a7e05..d1f693a 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,87 +1,444 @@ "use client"; -import { useState } from "react"; -import type { QueryResult } from "./lib/rag"; +import { useCallback, useEffect, useState } from "react"; +import type { ChangeEvent, FormEvent } from "react"; +import type { DocumentInfo, QueryResult, Stats } from "./lib/rag"; + +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, init); + const body = (await response.json()) as T & { error?: string }; + if (!response.ok) { + throw new Error(body.error ?? "Request failed"); + } + return body; +} + +function ModeBadge({ + label, + value, + tone, +}: { + label: string; + value: string; + tone: "blue" | "green" | "amber"; +}) { + return ( +
+
+ ); +} + +function Metric({ + label, + value, + detail, +}: { + label: string; + value: string; + detail: string; +}) { + return ( +
+ {label} + {value} + {detail} +
+ ); +} export default function Home() { const [question, setQuestion] = useState(""); const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - async function ask(e: React.FormEvent) { - e.preventDefault(); - if (!question.trim()) return; - setLoading(true); - setError(null); + const [queryLoading, setQueryLoading] = useState(false); + const [queryError, setQueryError] = useState(null); + + const [documents, setDocuments] = useState([]); + const [stats, setStats] = useState(null); + const [workspaceLoading, setWorkspaceLoading] = useState(true); + const [workspaceError, setWorkspaceError] = useState(null); + + const [documentTitle, setDocumentTitle] = useState(""); + const [documentText, setDocumentText] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadNotice, setUploadNotice] = useState(null); + const [fileInputKey, setFileInputKey] = useState(0); + + const loadWorkspace = useCallback(async () => { + setWorkspaceLoading(true); + setWorkspaceError(null); + try { + const [nextDocuments, nextStats] = await Promise.all([ + requestJson("/api/documents"), + requestJson("/api/stats"), + ]); + setDocuments(nextDocuments); + setStats(nextStats); + } catch (err) { + setWorkspaceError(err instanceof Error ? err.message : "Unable to load index"); + } finally { + setWorkspaceLoading(false); + } + }, []); + + useEffect(() => { + void loadWorkspace(); + }, [loadWorkspace]); + + async function ask(event: FormEvent) { + event.preventDefault(); + const trimmedQuestion = question.trim(); + if (!trimmedQuestion) return; + + setQueryLoading(true); + setQueryError(null); try { - const res = await fetch("/api/query", { + const nextResult = await requestJson("/api/query", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ question, k: 5 }), + body: JSON.stringify({ question: trimmedQuestion, k: 5 }), }); - const body = await res.json(); - if (!res.ok) throw new Error(body.error ?? "request failed"); - setResult(body); + setResult(nextResult); } catch (err) { - setError(err instanceof Error ? err.message : "unknown error"); + setQueryError(err instanceof Error ? err.message : "Unable to run query"); setResult(null); } finally { - setLoading(false); + setQueryLoading(false); + } + } + + async function chooseFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + + setUploadError(null); + try { + const text = await file.text(); + setDocumentText(text); + if (!documentTitle.trim()) { + setDocumentTitle(file.name.replace(/\.(md|markdown|txt)$/i, "")); + } + } catch { + setUploadError("The selected file could not be read."); + } + } + + async function uploadDocument(event: FormEvent) { + event.preventDefault(); + if (!documentTitle.trim() || !documentText.trim()) return; + + setUploadLoading(true); + setUploadError(null); + setUploadNotice(null); + try { + const created = await requestJson("/api/documents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: documentTitle.trim(), + text: documentText, + }), + }); + setUploadNotice( + `Indexed “${created.title}” as ${created.n_chunks} ${created.n_chunks === 1 ? "chunk" : "chunks"}.`, + ); + setDocumentTitle(""); + setDocumentText(""); + setFileInputKey((key) => key + 1); + await loadWorkspace(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Unable to index document"); + } finally { + setUploadLoading(false); } } + const hashedRetrieval = stats?.embedder === "HashedEmbedder"; + const retrievalLabel = stats + ? hashedRetrieval + ? "Lexical · hashed" + : "Semantic embeddings" + : "Loading"; + const generationLabel = + stats?.generation === "claude" ? "Claude" : stats ? "Extractive mock" : "Loading"; + return ( -
-
-

RAG Engine

-

- Ask a question over the indexed documents. Retrieval runs on a - from-scratch HNSW index; the answer is generated with cited sources. -

- -
-