Studio: redesign KB management — per-base upload/files panels, doc pills, shared upload toast

This commit is contained in:
Roland Tannous 2026-05-29 19:02:14 +04:00
commit 80e7ad4daf
3 changed files with 288 additions and 132 deletions

View file

@ -3,115 +3,214 @@
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
import {
acquireIndexSlot,
releaseIndexSlot,
} from "@/features/chat/utils/rag-index-queue";
import { cn } from "@/lib/utils";
import { FileTextIcon, Trash2Icon, XIcon } from "lucide-react";
import { useState } from "react";
import type { KnowledgeBase } from "../api/rag-api";
import type { KnowledgeBase, RagDocument } from "../api/rag-api";
import { subscribeToJobEvents } from "../api/rag-api";
import { useKBDocuments } from "../hooks/use-kb-documents";
import { useIndexProgressStore } from "../stores/index-progress-store";
import { usePreviewStore } from "../stores/preview-store";
import { DocumentRow } from "./document-row";
import { useRagStore } from "../stores/rag-store";
import { DocumentUploadDropzone } from "./document-upload-dropzone";
import { IngestionProgress } from "./ingestion-progress";
import { KBReconfigureDialog } from "./kb-reconfigure-dialog";
export function KBDetailPanel({ kb }: { kb: KnowledgeBase }) {
const { documents, loading, error, upload, remove } = useKBDocuments(kb.id);
const [activeJobsByDoc, setActiveJobsByDoc] = useState<
Record<string, string>
>({});
function humanBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const STATUS_LABEL: Record<RagDocument["status"], string> = {
pending: "Queued",
running: "Indexing…",
completed: "Completed",
failed: "Failed",
};
export function KBDetailPanel({
kb,
panel,
onClose,
}: {
kb: KnowledgeBase;
panel: "upload" | "files";
onClose: () => void;
}) {
const { documents, loading, error, remove } = useKBDocuments(kb.id);
const [reconfigureOpen, setReconfigureOpen] = useState(false);
const openPreview = usePreviewStore((s) => s.open);
const handleFiles = async (files: File[]) => {
// Route uploads through the shared aggregate indexing toast (same as the
// chat composer) so a multi-file upload shows ONE progress bar instead of a
// per-row spinner. The doc list refreshes via uploadDocument's own job
// subscription; here we only drive the toast + concurrency semaphore.
const handleFiles = (files: File[]) => {
const indexProgress = useIndexProgressStore.getState();
const captionImages = useChatRuntimeStore.getState().ragCaptionImages;
const uploadDocument = useRagStore.getState().uploadDocument;
for (const file of files) {
try {
const { documentId, jobId } = await upload(file);
setActiveJobsByDoc((prev) => ({ ...prev, [documentId]: jobId }));
} catch (err) {
console.error("upload failed", err);
}
const chipId = crypto.randomUUID();
indexProgress.add(chipId, file.name);
void (async () => {
await acquireIndexSlot();
indexProgress.setIndexing(chipId);
let released = false;
const release = () => {
if (!released) {
released = true;
releaseIndexSlot();
}
};
try {
const { jobId, alreadyIndexed } = await uploadDocument(
{ kind: "kb", kbId: kb.id },
file,
captionImages,
);
if (alreadyIndexed || !jobId) {
indexProgress.setReady(chipId);
release();
return;
}
subscribeToJobEvents(jobId, {
onEvent: (event) => {
if (event.type === "progress") {
indexProgress.setProgress(chipId, event.progress);
} else if (event.type === "complete") {
indexProgress.setReady(chipId, event.num_chunks);
release();
} else if (event.type === "cancelled") {
release();
} else if (event.type === "error") {
indexProgress.setError(chipId);
release();
}
},
});
} catch {
indexProgress.setError(chipId);
release();
}
})();
}
};
return (
<div className="flex h-full flex-col gap-4">
<div className="flex h-full flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h2 className="text-lg font-semibold">{kb.name}</h2>
{kb.description ? (
<p className="text-sm text-muted-foreground">{kb.description}</p>
) : null}
<h2 className="truncate text-lg font-semibold">{kb.name}</h2>
<p className="text-xs text-muted-foreground">
{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""}
{kb.chunking_strategy === "late" ? "⚡ Late · " : ""}
Embedder: <code>{kb.embedding_model}</code>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReconfigureOpen(true)}
disabled={documents.length === 0}
title={
documents.length === 0
? "Upload at least one document before re-indexing"
: undefined
}
>
Reconfigure
</Button>
</div>
<DocumentUploadDropzone onFiles={handleFiles} />
<Separator />
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Documents</h3>
<span className="text-xs text-muted-foreground">
{loading ? "Loading…" : `${documents.length} total`}
</span>
</div>
{error ? <div className="text-xs text-destructive">{error}</div> : null}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 pr-2">
{documents.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground">
No documents yet. Drop some files above to get started.
</div>
) : null}
{documents.map((doc) => {
const jobId = activeJobsByDoc[doc.id];
const showProgress =
jobId && (doc.status === "pending" || doc.status === "running");
return (
<DocumentRow
key={doc.id}
doc={doc}
onDelete={() => {
void remove(doc.id);
}}
// Per decision Q9: only completed documents open a
// preview. Pending/running/failed rows degrade to
// non-interactive (no onPreview).
onPreview={
doc.status === "completed"
? () => {
void openPreview({ documentId: doc.id });
}
: undefined
}
rightSlot={
showProgress ? (
<IngestionProgress jobId={jobId} className="mt-1" />
) : null
}
/>
);
})}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setReconfigureOpen(true)}
disabled={documents.length === 0}
title={
documents.length === 0
? "Upload at least one document before re-indexing"
: undefined
}
>
Reconfigure
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Close panel"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
</ScrollArea>
</div>
{panel === "upload" ? (
<DocumentUploadDropzone onFiles={handleFiles} />
) : (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Documents</h3>
<span className="text-xs text-muted-foreground">
{loading ? "Loading…" : `${documents.length} total`}
</span>
</div>
{error ? (
<div className="text-xs text-destructive">{error}</div>
) : null}
<ScrollArea className="flex-1">
{documents.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground">
No documents yet. Use the upload button to add some.
</div>
) : (
<div className="flex flex-wrap gap-2 pr-2">
{documents.map((doc) => {
const previewable = doc.status === "completed";
return (
<div
key={doc.id}
className={cn(
"flex items-center gap-2 rounded-lg border border-foreground/20 bg-muted px-3 py-1.5 text-xs",
previewable && "cursor-pointer hover:bg-muted/70",
)}
onClick={
previewable
? () => void openPreview({ documentId: doc.id })
: undefined
}
>
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col">
<span
className="max-w-48 truncate"
title={doc.filename}
>
{doc.filename}
</span>
<span
className={cn(
"text-[10px] leading-tight text-muted-foreground",
doc.status === "failed" && "text-destructive",
)}
>
{humanBytes(doc.byte_size)} · {doc.num_chunks} chunks
{" · "}
{STATUS_LABEL[doc.status]}
</span>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
aria-label={`Delete ${doc.filename}`}
onClick={(e) => {
e.stopPropagation();
void remove(doc.id);
}}
>
<Trash2Icon className="size-3.5" />
</button>
</div>
);
})}
</div>
)}
</ScrollArea>
</>
)}
<KBReconfigureDialog
open={reconfigureOpen}

View file

@ -4,17 +4,22 @@
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { FilesIcon, Trash2Icon, UploadIcon } from "lucide-react";
import type { KnowledgeBase } from "../api/rag-api";
import { useKnowledgeBases } from "../hooks/use-knowledge-bases";
export type KBPanel = "upload" | "files";
export function KBList({
selectedId,
onSelect,
activeKbId,
activePanel,
onPanel,
onDeleted,
}: {
selectedId: string | null;
onSelect: (kb: KnowledgeBase | null) => void;
activeKbId: string | null;
activePanel: KBPanel | null;
onPanel: (kb: KnowledgeBase, panel: KBPanel) => void;
onDeleted: (kbId: string) => void;
}) {
const { knowledgeBases, loading, error, deleteKB } = useKnowledgeBases();
@ -22,8 +27,7 @@ export function KBList({
<div className="flex flex-col gap-1">
{error ? <div className="text-xs text-destructive">{error}</div> : null}
{/* Capped, content-sized list: stays short when empty (no oddly-tall
box) and scrolls internally once there are many bases. */}
{/* Capped, content-sized list: short when empty, scrolls when long. */}
<ScrollArea className="max-h-[320px]">
<div className="flex flex-col gap-1 pr-2">
{knowledgeBases.length === 0 && !loading ? (
@ -32,17 +36,16 @@ export function KBList({
</div>
) : null}
{knowledgeBases.map((kb) => {
const isSelected = kb.id === selectedId;
const isActive = kb.id === activeKbId;
return (
<div
key={kb.id}
className={cn(
"group flex items-center justify-between gap-2 rounded-md px-3 py-2 cursor-pointer transition-colors",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-accent/50",
"group flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors",
isActive
? "border-primary/50 bg-accent"
: "border-border/60 hover:bg-accent/50",
)}
onClick={() => onSelect(kb)}
>
<div className="flex min-w-0 flex-col">
<span className="flex min-w-0 items-center gap-1.5 truncate text-sm font-medium">
@ -70,26 +73,52 @@ export function KBList({
</span>
) : null}
</div>
<Button
variant="ghost"
size="icon"
aria-label="Delete knowledge base"
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
if (
window.confirm(
`Delete "${kb.name}" and all its documents?`,
)
) {
void deleteKB(kb.id).then(() => {
if (isSelected) onSelect(null);
});
}
}}
>
<HugeiconsIcon icon={Delete02Icon} size={14} />
</Button>
<div className="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={`Upload documents to ${kb.name}`}
title="Upload documents"
className={cn(
"h-7 w-7",
isActive && activePanel === "upload" && "text-primary",
)}
onClick={() => onPanel(kb, "upload")}
>
<UploadIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`View documents in ${kb.name}`}
title="View documents"
className={cn(
"h-7 w-7",
isActive && activePanel === "files" && "text-primary",
)}
onClick={() => onPanel(kb, "files")}
>
<FilesIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Delete ${kb.name}`}
title="Delete knowledge base"
className="h-7 w-7 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
onClick={() => {
if (
window.confirm(
`Delete "${kb.name}" and all its documents?`,
)
) {
void deleteKB(kb.id).then(() => onDeleted(kb.id));
}
}}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</div>
);
})}

View file

@ -6,7 +6,7 @@ import { Separator } from "@/components/ui/separator";
import type { KnowledgeBase } from "@/features/rag/api/rag-api";
import { KBCreateDialog } from "@/features/rag/components/kb-create-dialog";
import { KBDetailPanel } from "@/features/rag/components/kb-detail-panel";
import { KBList } from "@/features/rag/components/kb-list";
import { KBList, type KBPanel } from "@/features/rag/components/kb-list";
import { PreviewPanel } from "@/features/rag/components/preview-panel";
import { RagDefaultsSection } from "@/features/rag/components/rag-defaults-section";
import { ThreadIndexList } from "@/features/rag/components/thread-index-list";
@ -17,12 +17,14 @@ import { Add01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type CSSProperties, useState } from "react";
// Height of the master-detail workspace shown once a knowledge base is
// selected. Only rendered then, so the section stays compact when empty.
// Height of the master-detail workspace, only rendered once a KB's upload or
// files panel is open (or a preview is active) so the section stays compact
// when just browsing the list.
const KB_WORKSPACE_HEIGHT = "h-[360px]";
export function KnowledgeBasesTab() {
const [selected, setSelected] = useState<KnowledgeBase | null>(null);
const [activeKb, setActiveKb] = useState<KnowledgeBase | null>(null);
const [activePanel, setActivePanel] = useState<KBPanel | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const previewTarget = usePreviewStore((s) => s.target);
const previewStatus = usePreviewStore((s) => s.status);
@ -31,6 +33,24 @@ export function KnowledgeBasesTab() {
previewStatus === "loading" ||
previewStatus === "error";
const closePanel = () => {
setActiveKb(null);
setActivePanel(null);
};
const handlePanel = (kb: KnowledgeBase, panel: KBPanel) => {
// Re-clicking the active KB's active button collapses back to full width.
if (activeKb?.id === kb.id && activePanel === panel) {
closePanel();
} else {
setActiveKb(kb);
setActivePanel(panel);
}
};
const panelOpen = activeKb !== null && activePanel !== null;
const splitOpen = panelOpen || previewActive;
const {
width: previewWidth,
isResizing: previewResizing,
@ -69,26 +89,34 @@ export function KnowledgeBasesTab() {
</Button>
</div>
<p className="text-xs text-muted-foreground">
Select a knowledge base to manage its documents, or create a new one
to get started.
Create a knowledge base and pick its defaults. Use the upload and
files buttons on a base to add or manage its documents.
</p>
<div
className={cn(
"flex min-h-0 gap-4",
(selected || previewActive) && KB_WORKSPACE_HEIGHT,
splitOpen && KB_WORKSPACE_HEIGHT,
)}
>
{/* Full width when browsing (so the list / empty-state matches the
thread rows below); shrinks to a sidebar once a KB is selected
and the detail pane needs the room. */}
<div className={selected ? "w-[220px] shrink-0" : "min-w-0 flex-1"}>
<KBList selectedId={selected?.id ?? null} onSelect={setSelected} />
<div className={splitOpen ? "w-[260px] shrink-0" : "min-w-0 flex-1"}>
<KBList
activeKbId={activeKb?.id ?? null}
activePanel={activePanel}
onPanel={handlePanel}
onDeleted={(id) => {
if (activeKb?.id === id) closePanel();
}}
/>
</div>
{selected ? (
{panelOpen && activeKb && activePanel ? (
<>
<Separator orientation="vertical" className="h-auto" />
<div className="min-w-0 flex-1">
<KBDetailPanel kb={selected} />
<KBDetailPanel
kb={activeKb}
panel={activePanel}
onClose={closePanel}
/>
</div>
</>
) : null}
@ -147,7 +175,7 @@ export function KnowledgeBasesTab() {
<KBCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(kb) => setSelected(kb)}
onCreated={(kb) => handlePanel(kb, "upload")}
/>
</div>