Studio: redesign KB management — per-base upload/files panels, doc pills, shared upload toast
This commit is contained in:
parent
6388f3f349
commit
80e7ad4daf
3 changed files with 288 additions and 132 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue