import { AlertCircle, CheckCircle2, FileText, Loader2, Plus, Trash2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { knowledgeBaseApi } from '@/api';
import type { KnowledgeDocumentStatus, KnowledgeDocumentView } from '@/api';
import { DeleteDialog } from '@/components/dialog/DeleteDialog.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty.tsx';
import { useUploadContext } from '@/context/UploadContext';
import { isInFlight } from '@/context/uploadTypes';
import type { UploadPhase, UploadTask } from '@/context/uploadTypes';
import { useDocumentStatusPolling } from '@/hooks/useDocumentStatusPolling';
import { useKnowledgeDocuments } from '@/hooks/useKnowledgeDocuments';
import { useKnowledgeSupportedContentTypes } from '@/hooks/useKnowledgeSupportedContentTypes';
import { cn } from '@/lib/utils';
interface KnowledgeDocumentsPanelProps {
knowledgeBaseId: string;
}
/**
* A row that the panel renders. Either a server-side document (the
* canonical record after upload returns) or a still-uploading local
* task. Uploads that have already produced a `documentId` are routed
* through the server row so the panel never double-renders one.
*/
type Row =
| { kind: 'server'; doc: KnowledgeDocumentView; localTask: UploadTask | null }
| { kind: 'local'; task: UploadTask };
const TERMINAL_SERVER_STATUSES: KnowledgeDocumentStatus[] = ['ready', 'error'];
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
}
function StatusBadge({ phase }: { phase: UploadPhase }) {
const { t } = useTranslation();
const label = t(`knowledge.document.status.${phase}`);
const tone = (() => {
switch (phase) {
case 'ready':
return 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400';
case 'error':
return 'bg-destructive/10 text-destructive';
case 'cancelled':
return 'bg-muted text-muted-foreground';
case 'queued':
case 'uploading':
case 'pending':
case 'parsing':
case 'chunking':
case 'indexing':
default:
return 'bg-primary/10 text-primary';
}
})();
return (
{phase === 'ready' ? (
) : phase === 'error' ? (
) : phase === 'cancelled' ? null : (
)}
{label}
);
}
interface RowViewProps {
row: Row;
onCancel: (taskId: string) => void;
onDismiss: (taskId: string) => void;
onDelete: (doc: KnowledgeDocumentView) => void;
}
function RowView({ row, onCancel, onDismiss, onDelete }: RowViewProps) {
const { t } = useTranslation();
const filename = row.kind === 'server' ? row.doc.filename : row.task.filename;
const size = row.kind === 'server' ? row.doc.size : row.task.size;
// Phase resolution — the local task wins while the server hasn't
// caught up; once the server says `ready` / `error`, that's the
// truth.
const phase: UploadPhase = (() => {
if (row.kind === 'local') return row.task.phase;
const localPhase = row.localTask?.phase;
if (
localPhase &&
!TERMINAL_SERVER_STATUSES.includes(row.doc.status as KnowledgeDocumentStatus)
) {
return localPhase;
}
return row.doc.status;
})();
// Progress bar logic: byte-level during `uploading`; otherwise a
// coarse phase-derived value so the user gets visible movement.
const { progressValue, indeterminate } = (() => {
if (phase === 'uploading' && row.kind === 'local') {
if (!row.task.size) return { progressValue: 0, indeterminate: true };
return {
progressValue: Math.min(100, Math.round((row.task.loaded / row.task.size) * 100)),
indeterminate: false,
};
}
switch (phase) {
case 'queued':
return { progressValue: 5, indeterminate: false };
case 'pending':
return { progressValue: 35, indeterminate: true };
case 'parsing':
return { progressValue: 55, indeterminate: true };
case 'chunking':
return { progressValue: 75, indeterminate: true };
case 'indexing':
return { progressValue: 90, indeterminate: true };
case 'ready':
return { progressValue: 100, indeterminate: false };
default:
return { progressValue: 0, indeterminate: false };
}
})();
const error = row.kind === 'local' ? row.task.error : row.doc.error;
const chunkCount = row.kind === 'server' ? row.doc.chunk_count : row.task.documentId ? 0 : 0;
const taskIdForCancel =
row.kind === 'local' ? row.task.taskId : (row.localTask?.taskId ?? null);
const showProgress = phase !== 'ready' && phase !== 'cancelled';
return (
{filename}
{formatSize(size)}
{chunkCount > 0 && (
<>
·
{t('knowledge.document.chunkCount', {
count: chunkCount,
})}
>
)}
{phase === 'queued' || phase === 'uploading' ? (
) : phase === 'cancelled' || phase === 'error' ? (
row.kind === 'local' ? (
) : (
)
) : row.kind === 'server' ? (
) : null}
{showProgress && (
)}
{phase === 'error' && error &&
{error}
}
);
}
export function KnowledgeDocumentsPanel({ knowledgeBaseId }: KnowledgeDocumentsPanelProps) {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const { enqueue, cancel, dismiss, tasksForKb, clearFinishedForKb } = useUploadContext();
const { documents, refetch } = useKnowledgeDocuments(knowledgeBaseId);
const tasks = tasksForKb(knowledgeBaseId);
const { statuses } = useDocumentStatusPolling({
knowledgeBaseId,
documents,
});
const { mediaTypes, extensions } = useKnowledgeSupportedContentTypes();
// Lowercased extension set used to gate `handleFiles`. Built once
// per render — the underlying list is shared across the app via a
// module-level cache so this stays cheap.
const extensionSet = useMemo(
() => new Set(extensions.map((ext) => ext.toLowerCase())),
[extensions],
);
// `` takes a comma-separated string of MIME types and
// `.ext` tokens. We hand it both because browsers diverge on which
// they honour for any given filename — extensions filter the picker
// dialog, MIME types catch programmatic drops.
const acceptAttr = useMemo(
() => [...extensions, ...mediaTypes].join(','),
[extensions, mediaTypes],
);
// Refetch the canonical list whenever a polled doc reaches a
// terminal state — this is the single hook that pulls in the worker's
// final `chunk_count` and any updates to `error`.
const terminalIdsKey = useMemo(() => {
const ids: string[] = [];
for (const id of Object.keys(statuses)) {
const s = statuses[id];
if (s.status === 'ready' || s.status === 'error') ids.push(id);
}
return ids.join(',');
}, [statuses]);
const lastRefetchedKey = useRef('');
useEffect(() => {
if (!terminalIdsKey) return;
if (terminalIdsKey === lastRefetchedKey.current) return;
lastRefetchedKey.current = terminalIdsKey;
void refetch();
}, [terminalIdsKey, refetch]);
// Merge tasks + documents into a single ordered row list.
const rows: Row[] = useMemo(() => {
const tasksByDocId = new Map();
const tasksWithoutDoc: UploadTask[] = [];
for (const task of tasks) {
if (task.documentId) tasksByDocId.set(task.documentId, task);
else tasksWithoutDoc.push(task);
}
const docRows: Row[] = documents.map((doc) => ({
kind: 'server',
doc,
localTask: tasksByDocId.get(doc.id) ?? null,
}));
const localRows: Row[] = tasksWithoutDoc.map((task) => ({
kind: 'local',
task,
}));
// Local-only rows go first (they're either uploading or queued
// — both are user-visible "I just hit upload" states).
return [...localRows, ...docRows];
}, [documents, tasks]);
const onUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFiles = useCallback(
(files: FileList | File[]) => {
const arr = Array.from(files);
if (arr.length === 0) return;
// When the capability list hasn't loaded yet, allow everything
// through — the backend still rejects unsupported uploads.
// Once we have the list, filter client-side by extension
// (`file.type` is unreliable across browsers/OSes).
const accepted: File[] = [];
for (const f of arr) {
if (extensionSet.size === 0) {
accepted.push(f);
continue;
}
const dot = f.name.lastIndexOf('.');
const ext = dot >= 0 ? f.name.slice(dot).toLowerCase() : '';
if (ext && extensionSet.has(ext)) {
accepted.push(f);
} else {
toast.error(
t('knowledge.document.unsupportedExtension', {
name: f.name,
}),
);
}
}
if (accepted.length === 0) return;
enqueue(knowledgeBaseId, accepted);
},
[enqueue, knowledgeBaseId, extensionSet, t],
);
const onFileInputChange = useCallback(
(e: React.ChangeEvent) => {
if (e.target.files) handleFiles(e.target.files);
// Reset so picking the same file twice re-fires the change.
e.target.value = '';
},
[handleFiles],
);
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files) handleFiles(e.dataTransfer.files);
},
[handleFiles],
);
const onDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const onDragLeave = useCallback(() => setDragOver(false), []);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await knowledgeBaseApi.deleteDocument(knowledgeBaseId, deleteTarget.id);
await refetch();
} catch {
toast.error(t('knowledge.document.deleteError'));
}
}, [deleteTarget, knowledgeBaseId, refetch, t]);
const hasFinishedLocalTasks = tasks.some((t) => !isInFlight(t.phase));
const totalCount = rows.length;
return (
{t('knowledge.document.countLabel', { count: totalCount })}
{hasFinishedLocalTasks && (
)}
{rows.length === 0 ? (
{t('knowledge.document.emptyTitle')}
{t('knowledge.document.emptyDescription')}
{t('knowledge.document.dropHintIdle')}
) : (
{rows.map((row) => (
))}
)}
{
if (!open) setDeleteTarget(null);
}}
title={t('knowledge.document.deleteConfirmTitle')}
description={t('knowledge.document.deleteConfirmDescription', {
name: deleteTarget?.filename ?? '',
})}
onConfirm={handleDelete}
/>
);
}