diff --git a/studio/frontend/src/features/rag/components/kb-detail-panel.tsx b/studio/frontend/src/features/rag/components/kb-detail-panel.tsx index 3ea2bc0db6..c38057daee 100644 --- a/studio/frontend/src/features/rag/components/kb-detail-panel.tsx +++ b/studio/frontend/src/features/rag/components/kb-detail-panel.tsx @@ -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 - >({}); +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 = { + 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 ( -
+
-

{kb.name}

- {kb.description ? ( -

{kb.description}

- ) : null} +

{kb.name}

{kb.mode === "multimodal" ? "🖼️ Multimodal · " : ""} {kb.chunking_strategy === "late" ? "⚡ Late · " : ""} Embedder: {kb.embedding_model}

- -
- - - - - -
-

Documents

- - {loading ? "Loading…" : `${documents.length} total`} - -
- - {error ?
{error}
: null} - - -
- {documents.length === 0 && !loading ? ( -
- No documents yet. Drop some files above to get started. -
- ) : null} - {documents.map((doc) => { - const jobId = activeJobsByDoc[doc.id]; - const showProgress = - jobId && (doc.status === "pending" || doc.status === "running"); - return ( - { - 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 ? ( - - ) : null - } - /> - ); - })} +
+ +
- +
+ + {panel === "upload" ? ( + + ) : ( + <> +
+

Documents

+ + {loading ? "Loading…" : `${documents.length} total`} + +
+ {error ? ( +
{error}
+ ) : null} + + {documents.length === 0 && !loading ? ( +
+ No documents yet. Use the upload button to add some. +
+ ) : ( +
+ {documents.map((doc) => { + const previewable = doc.status === "completed"; + return ( +
void openPreview({ documentId: doc.id }) + : undefined + } + > + +
+ + {doc.filename} + + + {humanBytes(doc.byte_size)} · {doc.num_chunks} chunks + {" · "} + {STATUS_LABEL[doc.status]} + +
+ +
+ ); + })} +
+ )} +
+ + )} 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({
{error ?
{error}
: 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. */}
{knowledgeBases.length === 0 && !loading ? ( @@ -32,17 +36,16 @@ export function KBList({
) : null} {knowledgeBases.map((kb) => { - const isSelected = kb.id === selectedId; + const isActive = kb.id === activeKbId; return (
onSelect(kb)} >
@@ -70,26 +73,52 @@ export function KBList({ ) : null}
- +
+ + + +
); })} diff --git a/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx b/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx index 6961d5643e..d7f1f3e256 100644 --- a/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/knowledge-bases-tab.tsx @@ -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(null); + const [activeKb, setActiveKb] = useState(null); + const [activePanel, setActivePanel] = useState(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() {

- 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.

- {/* 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. */} -
- +
+ { + if (activeKb?.id === id) closePanel(); + }} + />
- {selected ? ( + {panelOpen && activeKb && activePanel ? ( <>
- +
) : null} @@ -147,7 +175,7 @@ export function KnowledgeBasesTab() { setSelected(kb)} + onCreated={(kb) => handlePanel(kb, "upload")} />