feat: deep-agent canvas, live observability, and multi-environment tooling

Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
nihalashetty
2026-07-28 01:49:19 +05:30
commit ae67bff5a3
350 changed files with 58244 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
"use client";
/* Knowledge: vertical-tab layout - Files (sources organized in folders), Q&A pairs
(free-form kinds/categories + tags), and the search debugger. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "../icons";
import { Drawer, Field, Modal, Segmented, StatusPill } from "../primitives";
import { api, ActivityEntry, KbSource, QaPair, SearchHit } from "@/lib/api";
import { ChunkMap } from "./chunk-map";
const VTABS = [
{ value: "files", label: "Files", icon: "knowledge" },
{ value: "qa", label: "Q&A pairs", icon: "n_qa" },
{ value: "search", label: "Search debugger", icon: "search" },
{ value: "map", label: "Chunk map", icon: "layers" },
] as const;
// Chunking strategies offered in the UI - mirrors CHUNK_STRATEGIES in the backend splitter.
const CHUNK_OPTIONS = [
{ value: "recursive", label: "Recursive" },
{ value: "section", label: "By section" },
{ value: "sentence", label: "By sentence" },
{ value: "semantic", label: "Semantic" },
] as const;
const CHUNK_HELP = "Recursive suits most documents; By section keeps each Markdown headings content together; By sentence groups whole sentences (good for FAQs and transcripts); Semantic splits where the meaning shifts (uses the embedder, slower to ingest).";
export function KnowledgeScreen({ project }: { project: any }) {
const [tab, setTab] = useState<string>("files");
const [histOpen, setHistOpen] = useState(false);
return (
<div className="col" style={{ flex: 1, minHeight: 0 }}>
<div className="row spread" style={{ padding: "20px 28px 14px", alignItems: "flex-start" }}>
<div>
<div className="t-display">Knowledge</div>
<div className="fg-1" style={{ marginTop: 3 }}>Ground agents in your docs (Chroma vectors, organized in folders) and deflect FAQs with categorized Q&A pairs.</div>
</div>
<button className="btn btn-secondary btn-sm" style={{ flex: "none" }} onClick={() => setHistOpen(true)} disabled={!project?.id} title="What was added, changed or removed">
<Icon name="clock" size={14} />History
</button>
</div>
<KnowledgeHistory project={project} open={histOpen} onClose={() => setHistOpen(false)} />
<div className="row" style={{ flex: 1, minHeight: 0, alignItems: "stretch" }}>
{/* vertical tab rail */}
<nav className="col" style={{ width: 184, flex: "none", padding: "4px 0 16px 20px", gap: 2 }}>
{VTABS.map((t) => (
<button
key={t.value}
onClick={() => setTab(t.value)}
className="row gap2"
style={{
alignItems: "center", textAlign: "left", padding: "8px 12px", borderRadius: 8,
border: "none", cursor: "pointer", fontSize: 13, fontWeight: tab === t.value ? 650 : 450,
background: tab === t.value ? "var(--bg-3)" : "transparent",
color: tab === t.value ? "var(--fg-0)" : "var(--fg-1)",
}}
>
<Icon name={t.icon as any} size={15} />
{t.label}
</button>
))}
</nav>
<div className="scroll-y" style={{ flex: 1, minWidth: 0, padding: "4px 28px 24px 16px" }}>
<div style={{ maxWidth: 1400 }}>
{tab === "files" && <Files project={project} />}
{tab === "qa" && <QA project={project} />}
{tab === "search" && <SearchDebugger project={project} />}
{tab === "map" && <ChunkMap project={project} />}
</div>
</div>
</div>
</div>
);
}
/* Compact "3m ago" / "2d ago" relative time, falling back to a locale date. */
function knRelTime(iso?: string | null): string {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return String(iso);
const s = Math.round((Date.now() - t) / 1000);
if (s < 60) return "just now";
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.round(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
/* Read-only, project-wide activity: what files / Q&A pairs were added, changed or removed.
Opened from the Knowledge header - no per-row clutter, no restore (content lives in the
vector store, so there's nothing to roll back). */
function KnowledgeHistory({ project, open, onClose }: { project: any; open: boolean; onClose: () => void }) {
const [rows, setRows] = useState<ActivityEntry[] | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!open || !project?.id) return;
setRows(null); setErr(null);
api.knowledgeActivity(project.id).then(setRows).catch((e) => setErr(String(e?.message || e)));
}, [open, project?.id]);
const ACTION: Record<string, { label: string; cls: string; icon: string }> = {
added: { label: "Added", cls: "pill-ok", icon: "plus" },
changed: { label: "Changed", cls: "pill-muted", icon: "edit" },
removed: { label: "Removed", cls: "pill-err", icon: "trash" },
};
return (
<Drawer open={open} onClose={onClose} title="Knowledge history" sub={project?.name} width={440}>
<div className="col" style={{ padding: 14, gap: 8 }}>
{err && <div className="card" style={{ padding: 12, color: "var(--err)" }}>{err}</div>}
{!err && rows === null && <div className="fg-2 t-caption" style={{ padding: "8px 2px" }}>Loading</div>}
{!err && rows?.length === 0 && (
<div className="col center" style={{ padding: "40px 16px", textAlign: "center", gap: 8, color: "var(--fg-2)" }}>
<Icon name="clock" size={22} />
<div className="t-body-sm">No changes yet.</div>
<div className="t-caption">Adding, editing or removing files and Q&A pairs shows up here.</div>
</div>
)}
{rows?.map((r) => {
const a = ACTION[r.action || ""] || { label: r.action || "changed", cls: "pill-muted", icon: "minus" };
return (
<div key={r.id} className="card" style={{ padding: "10px 12px" }}>
<div className="row gap2" style={{ alignItems: "center", minWidth: 0 }}>
<span className={"pill " + a.cls} style={{ height: 18, flex: "none" }}><Icon name={a.icon as any} size={11} />{a.label}</span>
<span className="typechip" style={{ flex: "none" }}>{r.entity_type === "qa_pair" ? "Q&A" : "File"}</span>
<span className="t-body-sm truncate" style={{ minWidth: 0 }}>{r.title}</span>
</div>
<div className="t-caption fg-2 truncate" style={{ marginTop: 4 }}>
{r.author_email || "unknown"}{r.created_at ? ` · ${knRelTime(r.created_at)}` : ""}
</div>
</div>
);
})}
</div>
</Drawer>
);
}
/* ---------------- Files (sources in folders) ---------------- */
const UNFILED = "";
// Fallback chunking shown/used when a source (or the project) hasn't set its own. Mirrors
// the backend defaults in services/knowledge.py; the server stays authoritative for what's
// actually applied at ingest.
const DEFAULT_CHUNK_STRATEGY = "recursive";
const DEFAULT_CHUNK_SIZE = 1000;
const DEFAULT_CHUNK_OVERLAP = 200;
function Files({ project }: { project: any }) {
const [rows, setRows] = useState<KbSource[]>([]);
const [folder, setFolder] = useState<string | null>(null); // null = All files
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [addErr, setAddErr] = useState<string | null>(null);
const [newFolder, setNewFolder] = useState<string | null>(null); // non-null = naming a new folder
// The modal never asks for a folder - sources land in the folder open at the time
// ("" = Unfiled, e.g. from the All files view).
const [targetFolder, setTargetFolder] = useState<string>("");
const [form, setForm] = useState<{ kind: string; name: string; text: string; uri: string; file: globalThis.File | null; chunkStrategy: string }>({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" });
// Multi-select + re-chunk: select sources (across any folder) and re-split/re-embed
// them with a shared strategy / size / overlap.
const [selected, setSelected] = useState<Set<string>>(new Set());
const [rechunkOpen, setRechunkOpen] = useState(false);
const [rechunkTargets, setRechunkTargets] = useState<string[]>([]);
const [rechunkBusy, setRechunkBusy] = useState(false);
const [rechunkErr, setRechunkErr] = useState<string | null>(null);
const [rechunkForm, setRechunkForm] = useState<{ strategy: string; size: number; overlap: number }>({ strategy: DEFAULT_CHUNK_STRATEGY, size: DEFAULT_CHUNK_SIZE, overlap: DEFAULT_CHUNK_OVERLAP });
const [dedupeBusy, setDedupeBusy] = useState(false);
const [dedupeMsg, setDedupeMsg] = useState<string | null>(null);
const [health, setHealth] = useState<{ needs_reembed: boolean; current_model: string; mismatched: { id: string; name: string }[] } | null>(null);
const reload = useCallback(() => {
if (!project?.id) return;
api.listSources(project.id).then(setRows).catch(() => {});
api.embeddingHealth(project.id).then(setHealth).catch(() => setHealth(null));
}, [project?.id]);
useEffect(() => { reload(); }, [reload]);
// Ingestion (chunk + embed) runs in the background, so a new/re-chunked source starts as
// "queued"/"processing". Poll until every source settles (ready/error) so the table, chunk
// counts, and dim-mismatch banner update without a manual refresh.
useEffect(() => {
const pending = rows.some((s) => s.status === "queued" || s.status === "processing");
if (!pending || !project?.id) return;
const t = setTimeout(async () => {
const next = await api.listSources(project.id).catch(() => null);
if (!next) return;
setRows(next);
if (!next.some((s) => s.status === "queued" || s.status === "processing")) {
api.embeddingHealth(project.id).then(setHealth).catch(() => {});
}
}, 1500);
return () => clearTimeout(t);
}, [rows, project?.id]);
async function reingest(id: string) { await api.reingestSource(project.id, id).catch(() => {}); reload(); }
async function dedupe() {
if (!window.confirm("Remove exact-duplicate chunks (identical text) across this project, keeping one copy of each?\n\nIf duplicates come from the same document added twice, delete the duplicate source instead — re-ingesting regenerates the chunks.")) return;
setDedupeBusy(true);
setDedupeMsg(null);
try {
const r = await api.dedupeChunks(project.id);
setDedupeMsg(r.removed === 0
? "No duplicate chunks found."
: `Removed ${r.removed} duplicate chunk${r.removed === 1 ? "" : "s"} (${r.groups} group${r.groups === 1 ? "" : "s"}) across ${r.sources_affected} source${r.sources_affected === 1 ? "" : "s"}. ${r.remaining} remain.`);
reload();
} catch (e: any) {
setDedupeMsg(`Dedupe failed: ${e?.message || e}`);
} finally { setDedupeBusy(false); }
}
const folders = useMemo(() => {
const set = new Set<string>();
rows.forEach((s) => { if (s.folder) set.add(s.folder); });
return [...set].sort();
}, [rows]);
const visible = folder === null ? rows : rows.filter((s) => (s.folder || UNFILED) === folder);
const hasUnfiled = rows.some((s) => !s.folder);
// Folder column is redundant when already viewing one named folder - show it only in
// the All-files and Unfiled views (where moving files between folders is useful).
const showFolderCol = folder === null || folder === UNFILED;
const inNamedFolder = folder !== null && folder !== UNFILED;
const allVisibleSelected = visible.length > 0 && visible.every((s) => selected.has(s.id));
function toggleSel(id: string) {
setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; });
}
function toggleAllVisible() {
setSelected((prev) => {
const n = new Set(prev);
if (allVisibleSelected) visible.forEach((s) => n.delete(s.id));
else visible.forEach((s) => n.add(s.id));
return n;
});
}
function openRechunk(ids: string[]) {
if (!ids.length) return;
const first = rows.find((r) => r.id === ids[0]);
setRechunkForm({ strategy: first?.chunking_strategy || DEFAULT_CHUNK_STRATEGY, size: first?.chunk_size || DEFAULT_CHUNK_SIZE, overlap: first?.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP });
setRechunkErr(null);
setRechunkTargets(ids);
setRechunkOpen(true);
}
async function doRechunk() {
setRechunkBusy(true);
setRechunkErr(null);
try {
await api.rechunkSources(project.id, rechunkTargets, {
chunking_strategy: rechunkForm.strategy,
chunk_size: Number(rechunkForm.size) || undefined,
chunk_overlap: Number.isFinite(rechunkForm.overlap) ? Number(rechunkForm.overlap) : undefined,
});
setRechunkOpen(false);
setSelected(new Set());
reload();
} catch (e: any) {
setRechunkErr(e?.message || "Re-chunk failed. Please try again.");
} finally { setRechunkBusy(false); }
}
function openAdd(forFolder?: string) {
setTargetFolder(forFolder ?? (inNamedFolder ? folder! : ""));
setAddErr(null);
setOpen(true);
}
async function add() {
setBusy(true);
setAddErr(null);
try {
if (form.kind === "file") {
if (!form.file) { setAddErr("Choose a file to upload."); return; }
await api.uploadSource(project.id, form.file, targetFolder || undefined, form.chunkStrategy);
} else {
await api.addSource(project.id, {
kind: form.kind, name: form.name || "Untitled", folder: targetFolder || undefined,
text: form.kind === "text" ? form.text : undefined, uri: (form.kind === "url" || form.kind === "crawl") ? form.uri : undefined,
chunking_strategy: form.chunkStrategy,
});
}
setOpen(false); setForm({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" }); reload();
} catch (e: any) {
setAddErr(String(e?.message || e));
} finally { setBusy(false); }
}
function FolderRow({ value, label, icon, count }: { value: string | null; label: string; icon: string; count: number }) {
const active = folder === value;
return (
<button onClick={() => setFolder(value)} className="row spread" style={{
width: "100%", alignItems: "center", padding: "7px 10px", borderRadius: 7, border: "none", cursor: "pointer",
background: active ? "var(--bg-3)" : "transparent", color: active ? "var(--fg-0)" : "var(--fg-1)", fontSize: 13,
}}>
<span className="row gap2" style={{ alignItems: "center", minWidth: 0 }}><Icon name={icon as any} size={14} /><span className="truncate">{label}</span></span>
<span className="t-caption fg-2 mono">{count}</span>
</button>
);
}
return (
<div className="col" style={{ gap: 12 }}>
{health?.needs_reembed && (
<div className="card row spread" style={{ padding: "10px 14px", background: "var(--warn-bg)", borderColor: "transparent" }}>
<div className="row gap2" style={{ minWidth: 0 }}><Icon name="bolt" size={15} style={{ color: "var(--warn)" }} />
<span className="t-body-sm">{health.mismatched.length} source(s) were embedded with a different model than the current one ({health.current_model}) - they won&apos;t appear in search until re-embedded.</span>
</div>
<button className="btn btn-secondary btn-sm" style={{ flex: "none" }} onClick={async () => { for (const m of health.mismatched) await reingest(m.id); }}><Icon name="refresh" size={13} />Re-embed all</button>
</div>
)}
<div className="row" style={{ gap: 18, alignItems: "flex-start" }}>
{/* folder list */}
<div className="card col" style={{ width: 218, flex: "none", padding: 10, gap: 2 }}>
<FolderRow value={null} label="All files" icon="list" count={rows.length} />
{hasUnfiled && <FolderRow value={UNFILED} label="Unfiled" icon="file" count={rows.filter((s) => !s.folder).length} />}
{folders.map((f) => (
<FolderRow key={f} value={f} label={f} icon="layers" count={rows.filter((s) => s.folder === f).length} />
))}
{newFolder === null ? (
<button className="btn btn-ghost btn-sm" style={{ justifyContent: "flex-start", marginTop: 4 }} onClick={() => setNewFolder("")}>
<Icon name="plus" size={13} />New folder
</button>
) : (
<input
autoFocus className="input" style={{ marginTop: 4, fontSize: 13 }} placeholder="Folder name…" value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newFolder.trim()) {
// Folders exist through their files: open the add modal locked to the new folder.
const nf = newFolder.trim();
setNewFolder(null);
openAdd(nf);
}
if (e.key === "Escape") setNewFolder(null);
}}
onBlur={() => setNewFolder(null)}
/>
)}
<div className="t-caption fg-2" style={{ padding: "6px 10px 2px" }}>
Retrieval nodes and knowledge_search tools can filter by folder.
</div>
</div>
{/* sources table */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row spread" style={{ marginBottom: 12 }}>
<div className="t-h2">{folder === null ? "All files" : folder === UNFILED ? "Unfiled" : folder}</div>
<div className="row gap2">
<button className="btn btn-ghost btn-sm" onClick={dedupe} disabled={dedupeBusy} title="Remove exact-duplicate chunks (identical text) so the same passage never fills two retrieval slots.">
<Icon name={dedupeBusy ? "refresh" : "layers"} size={14} style={dedupeBusy ? { animation: "spin 1s linear infinite" } : {}} />{dedupeBusy ? "Removing…" : "Remove duplicates"}
</button>
<button className="btn btn-primary btn-sm" onClick={() => openAdd()}>
<Icon name="plus" size={14} />Add source
</button>
</div>
</div>
{dedupeMsg && (
<div className="card row spread" style={{ padding: "8px 12px", marginBottom: 10, alignItems: "center" }}>
<span className="t-body-sm">{dedupeMsg}</span>
<button className="iconbtn" title="Dismiss" onClick={() => setDedupeMsg(null)}><Icon name="x" size={13} /></button>
</div>
)}
{selected.size > 0 && (
<div className="card row spread" style={{ padding: "8px 12px", marginBottom: 10, alignItems: "center" }}>
<span className="t-body-sm"><b>{selected.size}</b> selected</span>
<div className="row gap2">
<button className="btn btn-secondary btn-sm" onClick={() => openRechunk([...selected])}><Icon name="refresh" size={13} />Re-chunk selected</button>
<button className="btn btn-ghost btn-sm" onClick={() => setSelected(new Set())}>Clear</button>
</div>
</div>
)}
<div className="card" style={{ overflow: "hidden" }}>
<table className="tbl">
<thead><tr>
<th style={{ width: 30 }}><input type="checkbox" aria-label="Select all" checked={allVisibleSelected} onChange={toggleAllVisible} /></th>
<th>Name</th><th>Kind</th>{showFolderCol && <th>Folder</th>}<th>Status</th><th>Chunks</th><th>Chunking</th><th /></tr></thead>
<tbody>
{visible.map((s) => (
<tr key={s.id} style={selected.has(s.id) ? { background: "var(--bg-3)" } : undefined}>
<td><input type="checkbox" aria-label={`Select ${s.name}`} checked={selected.has(s.id)} onChange={() => toggleSel(s.id)} /></td>
<td style={{ fontWeight: 600 }}>{s.name}</td>
<td><span className="typechip">{s.kind}</span></td>
{showFolderCol && (
<td>
<select
className="select" style={{ fontSize: 12, padding: "3px 6px", maxWidth: 140 }}
value={s.folder || UNFILED}
onChange={async (e) => { await api.moveSource(project.id, s.id, e.target.value); reload(); }}
>
<option value={UNFILED}>Unfiled</option>
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
{s.folder && !folders.includes(s.folder) && <option value={s.folder}>{s.folder}</option>}
</select>
</td>
)}
<td><StatusPill status={s.status} /></td>
<td className="mono-sm">{s.chunks}</td>
<td>
<span className="typechip">{s.chunking_strategy || DEFAULT_CHUNK_STRATEGY}</span>
<div className="t-caption fg-2 mono" style={{ marginTop: 2 }}>{s.chunk_size || DEFAULT_CHUNK_SIZE}/{s.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP}</div>
</td>
<td style={{ textAlign: "right" }}>
<div className="row gap1" style={{ justifyContent: "flex-end" }}>
<button className="iconbtn" title="Re-chunk & re-embed" onClick={() => openRechunk([s.id])}><Icon name="layers" size={14} /></button>
<button className="iconbtn" title="Re-embed (reuse current chunking)" onClick={() => reingest(s.id)}><Icon name="refresh" size={14} /></button>
<button className="iconbtn" title="Delete" onClick={async () => { await api.deleteSource(project.id, s.id); reload(); }}><Icon name="trash" size={15} /></button>
</div>
</td>
</tr>
))}
{visible.length === 0 && <tr><td colSpan={showFolderCol ? 8 : 7}><div className="fg-2" style={{ padding: 22, textAlign: "center" }}>{rows.length === 0 ? "No sources yet. Add text or a URL to feed your agents." : "No files in this folder yet."}</div></td></tr>}
</tbody>
</table>
</div>
</div>
<Modal open={open} onClose={() => setOpen(false)} title={`Add source to “${targetFolder || "Unfiled"}`} width={560}
footer={<><button className="btn btn-ghost" onClick={() => setOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={add} disabled={busy}>{busy ? "Ingesting…" : "Add & ingest"}</button></>}>
<Field label="Kind"><Segmented options={[{ value: "text", label: "Paste text" }, { value: "url", label: "URL" }, { value: "crawl", label: "Crawl site" }, { value: "file", label: "Upload file" }]} value={form.kind} onChange={(v) => setForm((f) => ({ ...f, kind: v }))} /></Field>
{form.kind !== "file" && (
<Field label="Name"><input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Help Center FAQ" /></Field>
)}
{form.kind === "text" && (
<Field label="Text" help="Split into ~1000-char chunks and embedded into Chroma."><textarea className="textarea" rows={7} value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} placeholder="Paste documentation, policies, FAQs…" /></Field>
)}
{form.kind === "crawl" && (
<Field label="Start URL" help="Crawls same-domain pages from here (up to ~10), strips HTML, chunks + embeds. Re-crawl anytime with the ↻ button."><input className="input mono" value={form.uri} onChange={(e) => setForm((f) => ({ ...f, uri: e.target.value }))} placeholder="https://docs.example.com" /></Field>
)}
{form.kind === "url" && (
<Field label="URL" help="Fetched, stripped of HTML, chunked, embedded."><input className="input mono" value={form.uri} onChange={(e) => setForm((f) => ({ ...f, uri: e.target.value }))} placeholder="https://docs.example.com/help" /></Field>
)}
{form.kind === "file" && (
<Field label="File" help="Text formats (.txt, .md, .csv, .json…) and PDF. Named after the file; extracted, chunked, embedded.">
<input className="input" type="file" accept=".txt,.md,.markdown,.csv,.json,.html,.pdf,text/*,application/pdf"
onChange={(e) => setForm((f) => ({ ...f, file: e.target.files?.[0] || null }))} />
{form.file && <div className="t-caption fg-2" style={{ marginTop: 6 }}>{form.file.name} · {(form.file.size / 1024).toFixed(1)} KB</div>}
</Field>
)}
<Field label="Chunking" help={`How this source is split before embedding. ${CHUNK_HELP}`}>
<Segmented
options={CHUNK_OPTIONS as any}
value={form.chunkStrategy}
onChange={(v) => setForm((f) => ({ ...f, chunkStrategy: v }))}
/>
</Field>
{addErr && <div className="t-caption" style={{ color: "var(--err)", marginTop: 4 }}> {addErr}</div>}
</Modal>
<Modal open={rechunkOpen} onClose={() => setRechunkOpen(false)} width={520}
title={`Re-chunk ${rechunkTargets.length} source${rechunkTargets.length === 1 ? "" : "s"}`}
footer={<><button className="btn btn-ghost" onClick={() => setRechunkOpen(false)}>Cancel</button><button className="btn btn-primary" onClick={doRechunk} disabled={rechunkBusy}>{rechunkBusy ? "Re-chunking…" : "Apply & re-embed"}</button></>}>
<div className="t-caption fg-2" style={{ marginBottom: 12 }}>Re-splits and re-embeds the selected source(s) with these settings. Existing chunks are replaced. Text &amp; file sources reuse their stored content; URLs &amp; crawls are re-fetched.</div>
<Field label="Chunking strategy" help={CHUNK_HELP}>
<Segmented
options={CHUNK_OPTIONS as any}
value={rechunkForm.strategy}
onChange={(v) => setRechunkForm((f) => ({ ...f, strategy: v }))}
/>
</Field>
<div className="row gap2">
<div style={{ flex: 1 }}>
<Field label="Chunk size (chars)" help="Target characters per chunk."><input className="input" type="number" min={100} step={100} value={rechunkForm.size} onChange={(e) => setRechunkForm((f) => ({ ...f, size: Number(e.target.value) }))} /></Field>
</div>
<div style={{ flex: 1 }}>
<Field label="Overlap (chars)" help="Characters shared between adjacent chunks."><input className="input" type="number" min={0} step={20} value={rechunkForm.overlap} onChange={(e) => setRechunkForm((f) => ({ ...f, overlap: Number(e.target.value) }))} /></Field>
</div>
</div>
{rechunkErr && <div className="t-caption" style={{ color: "var(--err)", marginTop: 8 }}> {rechunkErr}</div>}
</Modal>
</div>
</div>
);
}
/* ---------------- Q&A pairs (custom kinds + tags) ---------------- */
const BUILTIN_KINDS = ["faq", "error_workaround"];
function QA({ project }: { project: any }) {
const [rows, setRows] = useState<QaPair[]>([]);
const [kind, setKind] = useState<string | null>(null); // null = All pairs
const [newKind, setNewKind] = useState<string | null>(null); // non-null = naming a new kind
const [form, setForm] = useState({ question: "", answer: "", kind: "faq", tags: "" });
const [editing, setEditing] = useState<string | null>(null);
const [edit, setEdit] = useState({ question: "", answer: "", kind: "faq", tags: "" });
const [saving, setSaving] = useState(false);
const reload = useCallback(() => { if (project?.id) api.listQa(project.id).then(setRows).catch(() => {}); }, [project?.id]);
useEffect(() => { reload(); }, [reload]);
const kinds = useMemo(() => {
const set = new Set<string>(BUILTIN_KINDS);
rows.forEach((q) => { if (q.kind) set.add(q.kind); });
return [...set].sort();
}, [rows]);
// Selecting a kind in the rail locks the add-form kind to it (mirrors Files/folders).
const lockedKind = kind;
const effectiveKind = lockedKind ?? (form.kind.trim() || "faq");
const visible = kind === null ? rows : rows.filter((q) => q.kind === kind);
// Hide the Kind column when viewing one kind - the rail already says which.
const showKindCol = kind === null;
async function add() {
if (!form.question.trim()) return;
const tags = form.tags.split(",").map((t) => t.trim()).filter(Boolean);
await api.addQa(project.id, { question: form.question, answer: form.answer, kind: effectiveKind, tags });
setForm({ question: "", answer: "", kind: form.kind, tags: "" }); reload();
}
function startEdit(q: QaPair) {
setEditing(q.id);
setEdit({ question: q.question, answer: q.answer, kind: q.kind || "faq", tags: (q.tags || []).join(", ") });
}
async function saveEdit() {
if (!editing || !edit.question.trim() || saving) return;
setSaving(true);
try {
const updated = await api.updateQa(project.id, editing, {
question: edit.question.trim(),
answer: edit.answer,
kind: edit.kind.trim() || "faq",
tags: edit.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
});
setRows((current) => current.map((row) => row.id === updated.id ? updated : row));
setEditing(null);
} finally {
setSaving(false);
}
}
function KindRow({ value, label, count }: { value: string | null; label: string; count: number }) {
const active = kind === value;
return (
<button onClick={() => setKind(value)} className="row spread" style={{
width: "100%", alignItems: "center", padding: "7px 10px", borderRadius: 7, border: "none", cursor: "pointer",
background: active ? "var(--bg-3)" : "transparent", color: active ? "var(--fg-0)" : "var(--fg-1)", fontSize: 13,
}}>
<span className="row gap2" style={{ alignItems: "center", minWidth: 0 }}><Icon name={value === null ? "list" : "n_qa"} size={14} /><span className="truncate">{label}</span></span>
<span className="t-caption fg-2 mono">{count}</span>
</button>
);
}
return (
<div className="row" style={{ gap: 18, alignItems: "flex-start" }}>
{/* kind list */}
<div className="card col" style={{ width: 218, flex: "none", padding: 10, gap: 2 }}>
<KindRow value={null} label="All pairs" count={rows.length} />
{kinds.map((k) => {
const count = rows.filter((q) => q.kind === k).length;
if (!count && kind !== k) return null;
return <KindRow key={k} value={k} label={k} count={count} />;
})}
{newKind === null ? (
<button className="btn btn-ghost btn-sm" style={{ justifyContent: "flex-start", marginTop: 4 }} onClick={() => setNewKind("")}>
<Icon name="plus" size={13} />New kind
</button>
) : (
<input
autoFocus className="input" style={{ marginTop: 4, fontSize: 13 }} placeholder="Kind name…" value={newKind}
onChange={(e) => setNewKind(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newKind.trim()) {
// Kinds exist through their pairs: select it + prime the add form.
const nk = newKind.trim();
setForm((f) => ({ ...f, kind: nk })); setKind(nk); setNewKind(null);
}
if (e.key === "Escape") setNewKind(null);
}}
onBlur={() => setNewKind(null)}
/>
)}
<div className="t-caption fg-2" style={{ padding: "6px 10px 2px" }}>
Kinds are free-form categories. Retrieval nodes (and agent Q&A) can filter by kind.
</div>
</div>
{/* add form + table */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="card" style={{ padding: 14, marginBottom: 16 }}>
<div className="row spread" style={{ marginBottom: 10 }}>
<div className="t-h3">Add Q&A pair</div>
{lockedKind ? (
<span className="row gap2 t-caption fg-1" style={{ alignItems: "center" }}><Icon name="n_qa" size={13} />kind: <b>{lockedKind}</b></span>
) : (
<div style={{ width: 200 }}>
<input className="input" list="qa-kinds" value={form.kind} placeholder="Kind (e.g. faq, billing)" onChange={(e) => setForm((f) => ({ ...f, kind: e.target.value }))} title="Category. Pick an existing kind or type a new one." />
<datalist id="qa-kinds">{kinds.map((k) => <option key={k} value={k} />)}</datalist>
</div>
)}
</div>
<div className="row gap2" style={{ marginBottom: 8 }}>
<input className="input" style={{ flex: 1 }} placeholder="Question" value={form.question} onChange={(e) => setForm((f) => ({ ...f, question: e.target.value }))} />
</div>
<div className="row gap2" style={{ marginBottom: 8 }}>
<textarea className="textarea" style={{ flex: 1, minHeight: 44 }} rows={2} placeholder="Answer" value={form.answer} onChange={(e) => setForm((f) => ({ ...f, answer: e.target.value }))} />
</div>
<div className="row gap2">
<input className="input" style={{ flex: 1 }} placeholder="Tags (comma separated, optional)" value={form.tags} onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))} />
<button className="btn btn-primary" onClick={add}><Icon name="plus" size={14} />Add</button>
</div>
</div>
<div className="card" style={{ overflow: "hidden" }}>
<table className="tbl"><thead><tr><th>Question</th><th>Answer</th>{showKindCol && <th>Kind</th>}<th>Tags</th><th /></tr></thead>
<tbody>
{visible.map((q) => editing === q.id ? (
<tr key={q.id}>
<td><input autoFocus className="input" value={edit.question} onChange={(e) => setEdit((v) => ({ ...v, question: e.target.value }))} /></td>
<td><textarea className="textarea" rows={2} value={edit.answer} onChange={(e) => setEdit((v) => ({ ...v, answer: e.target.value }))} /></td>
{showKindCol && <td><input className="input" list="qa-kinds" value={edit.kind} onChange={(e) => setEdit((v) => ({ ...v, kind: e.target.value }))} /></td>}
<td><input className="input" value={edit.tags} placeholder="tag, tag" onChange={(e) => setEdit((v) => ({ ...v, tags: e.target.value }))} /></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="iconbtn" title="Save" onClick={saveEdit} disabled={!edit.question.trim() || saving}><Icon name="check" size={15} /></button>
<button className="iconbtn" title="Cancel" onClick={() => setEditing(null)} disabled={saving}><Icon name="x" size={15} /></button>
</td>
</tr>
) : (
<tr key={q.id}>
<td style={{ fontWeight: 600, maxWidth: 260 }} className="truncate">{q.question}</td>
<td className="fg-1 truncate" style={{ maxWidth: 280 }}>{q.answer}</td>
{showKindCol && <td><span className="typechip">{q.kind}</span></td>}
<td className="fg-2 t-caption truncate" style={{ maxWidth: 140 }}>{(q.tags || []).join(", ") || "-"}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="iconbtn" title="Edit" onClick={() => startEdit(q)}><Icon name="edit" size={15} /></button>
<button className="iconbtn" title="Delete" onClick={async () => { await api.deleteQa(project.id, q.id); reload(); }}><Icon name="trash" size={15} /></button>
</td>
</tr>
))}
{visible.length === 0 && <tr><td colSpan={showKindCol ? 5 : 4}><div className="fg-2" style={{ padding: 22, textAlign: "center" }}>{rows.length === 0 ? "No Q&A pairs yet." : "No pairs of this kind yet."}</div></td></tr>}
</tbody>
</table>
</div>
</div>
</div>
);
}
/* ---------------- Search debugger ---------------- */
function SearchDebugger({ project }: { project: any }) {
const [q, setQ] = useState("");
const [hits, setHits] = useState<SearchHit[]>([]);
const [searched, setSearched] = useState(false);
const [folders, setFolders] = useState<string[]>([]);
const [folder, setFolder] = useState("");
const [mode, setMode] = useState<"vector" | "hybrid">("vector");
const [rerank, setRerank] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => { if (project?.id) api.listFolders(project.id).then(setFolders).catch(() => {}); }, [project?.id]);
async function run() {
if (busy || !q.trim()) return;
setBusy(true); setErr(null);
try {
const h = await api.searchKnowledge(project.id, q, 8, folder ? [folder] : undefined, mode === "hybrid", rerank);
setHits(h); setSearched(true);
} catch (e: any) {
setErr(e?.message || "Search failed."); setHits([]); setSearched(true);
} finally { setBusy(false); }
}
return (
<>
<div className="row gap2" style={{ marginBottom: 8, alignItems: "center" }}>
<input className="input" style={{ flex: 1 }} placeholder="Query the knowledge base…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && run()} />
{folders.length > 0 && (
<select className="select" style={{ width: 160 }} value={folder} onChange={(e) => setFolder(e.target.value)}>
<option value="">All folders</option>
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
)}
<button className="btn btn-primary" onClick={run} disabled={busy || !q.trim()}>
<Icon name={busy ? "refresh" : "search"} size={14} style={busy ? { animation: "spin 1s linear infinite" } : {}} />{busy ? "Searching…" : "Search"}
</button>
</div>
{err && <div className="t-caption" style={{ color: "var(--err)", marginBottom: 8 }}>{err}</div>}
<div className="row gap2" style={{ marginBottom: 6, alignItems: "center" }}>
<Segmented
options={[{ value: "vector", label: "Vector" }, { value: "hybrid", label: "Hybrid" }]}
value={mode}
onChange={(v) => setMode(v as "vector" | "hybrid")}
/>
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", fontSize: 13 }} title="Two-stage retrieval: a local cross-encoder re-scores the shortlist and keeps the best matches. Runs offline; adds some latency.">
<input type="checkbox" checked={rerank} onChange={(e) => setRerank(e.target.checked)} />
Rerank
</label>
<span className="t-caption fg-2">
{rerank
? "Cross-encoder rerank on. Score is the rerankers relevance (01)."
: mode === "hybrid"
? "BM25 lexical + vector, fused via RRF. Score is a normalized fusion rank (01), not cosine."
: "Vector-only. Score is cosine similarity (01) between the query and each chunk."}
</span>
</div>
<div style={{ marginBottom: 14 }} />
<div className="col gap2">
{hits.map((h, i) => (
<div key={i} className="card" style={{ padding: 12 }}>
<div className="row spread" style={{ marginBottom: 4 }}>
<span className="t-caption fg-2 mono">{h.source_id?.slice(0, 12) || "-"}</span>
<span className="chip chip-mono">score {h.score.toFixed(3)}</span>
</div>
<div className="t-body-sm" style={{ whiteSpace: "pre-wrap" }}>{h.text}</div>
</div>
))}
{searched && hits.length === 0 && <div className="fg-2" style={{ padding: 22, textAlign: "center" }}>No matches. Add sources first.</div>}
</div>
</>
);
}