Files
LangBot/web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx
2026-03-08 11:05:00 +00:00

326 lines
11 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { ParserInfo } from '@/app/infra/entities/api';
import { CustomApiError, I18nObject } from '@/app/infra/entities/common';
import { extractI18nObject } from '@/i18n/I18nProvider';
interface FileUploadZoneProps {
kbId: string;
ragEngineName?: I18nObject;
ragEngineCapabilities?: string[];
onUploadSuccess: () => void;
onUploadError: (error: string) => void;
}
export default function FileUploadZone({
kbId,
ragEngineName,
ragEngineCapabilities,
onUploadSuccess,
onUploadError,
}: FileUploadZoneProps) {
const { t } = useTranslation();
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
// Parser selection state
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [availableParsers, setAvailableParsers] = useState<ParserInfo[]>([]);
const [selectedParser, setSelectedParser] = useState<string>('builtin');
const [loadingParsers, setLoadingParsers] = useState(false);
// Whether the Knowledge Engine natively supports document parsing.
// This is a coarse-grained capability check rather than per-MIME-type filtering.
// Fine-grained MIME type declaration (e.g. supported_parse_mime_types on the engine)
// would require changes across the SDK, backend, and frontend prop chain;
// using an engine-level capability flag keeps the change minimal.
const ragEngineCanParse =
ragEngineCapabilities?.includes('doc_parsing') ?? false;
// When a file is selected, check for available parsers
useEffect(() => {
if (!pendingFile) return;
const mimeType = pendingFile.type || undefined;
setLoadingParsers(true);
httpClient
.listParsers(mimeType)
.then((resp) => {
const parsers = resp.parsers || [];
setAvailableParsers(parsers);
if (ragEngineCanParse) {
setSelectedParser('builtin');
} else if (parsers.length > 0) {
setSelectedParser(parsers[0].plugin_id);
} else {
setSelectedParser('');
}
})
.catch(() => {
setAvailableParsers([]);
})
.finally(() => {
setLoadingParsers(false);
});
}, [pendingFile, ragEngineCanParse]);
const doUpload = useCallback(
async (file: File, parserPluginId?: string) => {
setIsUploading(true);
const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile'));
try {
// Step 1: Upload file to server
const uploadResult = await httpClient.uploadDocumentFile(file);
// Step 2: Associate file with knowledge base (with optional parser)
await httpClient.uploadKnowledgeBaseFile(
kbId,
uploadResult.file_id,
parserPluginId,
);
toast.success(t('knowledge.documentsTab.uploadSuccess'), {
id: toastId,
});
onUploadSuccess();
} catch (error) {
console.error('File upload failed:', error);
const errorMessage =
t('knowledge.documentsTab.uploadError') +
(error as CustomApiError).msg;
toast.error(errorMessage, { id: toastId });
onUploadError(errorMessage);
} finally {
setIsUploading(false);
setPendingFile(null);
setAvailableParsers([]);
setSelectedParser('builtin');
}
},
[kbId, onUploadSuccess, onUploadError, t],
);
const handleFileSelected = useCallback(
async (file: File) => {
if (isUploading) return;
// Check file size (10MB limit)
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
toast.error(t('knowledge.documentsTab.fileSizeExceeded'));
return;
}
// Set loadingParsers=true BEFORE pendingFile so both state updates
// batch together in the same render. This prevents the auto-upload
// effect from firing before parser fetch completes.
setLoadingParsers(true);
setPendingFile(file);
},
[isUploading, t],
);
// Auto-upload if Knowledge Engine can parse and no external parsers available
useEffect(() => {
if (
pendingFile &&
!loadingParsers &&
ragEngineCanParse &&
availableParsers.length === 0
) {
doUpload(pendingFile);
}
}, [
pendingFile,
loadingParsers,
ragEngineCanParse,
availableParsers,
doUpload,
]);
const handleConfirmUpload = useCallback(() => {
if (!pendingFile) return;
const parserPluginId =
selectedParser === 'builtin' ? undefined : selectedParser;
doUpload(pendingFile, parserPluginId);
}, [pendingFile, selectedParser, doUpload]);
const handleCancelUpload = useCallback(() => {
setPendingFile(null);
setAvailableParsers([]);
setSelectedParser('builtin');
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFileSelected(files[0]);
}
},
[handleFileSelected],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFileSelected(files[0]);
}
// Reset the input so the same file can be selected again
e.target.value = '';
},
[handleFileSelected],
);
// Show parser selection UI when there are choices to make, or when no parser is available
const showParserSelector =
pendingFile &&
!loadingParsers &&
(availableParsers.length > 0 || !ragEngineCanParse);
const noParserAvailable = !ragEngineCanParse && availableParsers.length === 0;
return (
<Card className="mb-4">
<CardContent className="p-4">
{showParserSelector ? (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{pendingFile.name}
</p>
{noParserAvailable ? (
<div className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{t('knowledge.documentsTab.noParserAvailable')}
</p>
</div>
) : (
<div className="space-y-2">
<label className="text-sm text-gray-600 dark:text-gray-400">
{t('knowledge.documentsTab.selectParser')}
</label>
<Select
value={selectedParser}
onValueChange={setSelectedParser}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ragEngineCanParse && (
<SelectItem value="builtin">
{ragEngineName
? extractI18nObject(ragEngineName)
: t('knowledge.documentsTab.builtInParser')}
</SelectItem>
)}
{availableParsers.map((parser) => (
<SelectItem
key={parser.plugin_id}
value={parser.plugin_id}
>
{extractI18nObject(parser.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleCancelUpload}>
{t('knowledge.documentsTab.cancelUpload')}
</Button>
{!noParserAvailable && (
<Button size="sm" onClick={handleConfirmUpload}>
{t('knowledge.documentsTab.confirmUpload')}
</Button>
)}
</div>
</div>
) : (
<div
className={`
relative border-2 border-dashed rounded-lg p-4 text-center transition-colors
${
isDragOver
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
${isUploading || loadingParsers ? 'opacity-50 pointer-events-none' : ''}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.doc,.docx,.txt,.md,.html,.zip"
disabled={isUploading || loadingParsers}
/>
<label htmlFor="file-upload" className="cursor-pointer block">
<div className="space-y-2">
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div>
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
{isUploading
? t('knowledge.documentsTab.uploading')
: t('knowledge.documentsTab.dragAndDrop')}
</p>
<p className="text-xs text-gray-500 mt-1 dark:text-gray-400">
{t('knowledge.documentsTab.supportedFormats')}
</p>
</div>
</div>
</label>
</div>
)}
</CardContent>
</Card>
);
}