340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import { Upload, File as FileIcon, Loader2, ChevronDown, ChevronUp, X } from "lucide-react"
|
|
import { getApiUrl } from "@/config/backend"
|
|
|
|
interface JobStatus {
|
|
job_id: string
|
|
stage: string
|
|
status_message?: string | null
|
|
processed_files: number
|
|
total_files: number
|
|
error?: string | null
|
|
}
|
|
|
|
const STAGE_COPY: Record<string, string> = {
|
|
received: "Job received",
|
|
saving_files: "Saving files",
|
|
extracting: "Extracting document content",
|
|
analyzing: "Calling Claude for causal relations",
|
|
building_graph: "Writing to Neo4j knowledge graph",
|
|
completed: "Completed",
|
|
failed: "Failed",
|
|
}
|
|
|
|
interface MultiDocsUploadInlineProps {
|
|
onJobCreated?: (jobId: string) => void
|
|
}
|
|
|
|
// Default export for easier importing
|
|
export default MultiDocsUploadInline
|
|
|
|
export function MultiDocsUploadInline({ onJobCreated }: MultiDocsUploadInlineProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [files, setFiles] = useState<File[]>([])
|
|
const [uploading, setUploading] = useState(false)
|
|
const [jobId, setJobId] = useState<string | null>(null)
|
|
const [status, setStatus] = useState<JobStatus | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const uploadUrl = useMemo(() => getApiUrl("/api/multi-docs/jobs"), [])
|
|
|
|
useEffect(() => {
|
|
if (!jobId) return
|
|
|
|
let cancelled = false
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const response = await fetch(getApiUrl(`/api/multi-docs/jobs/${jobId}`))
|
|
if (!response.ok) {
|
|
throw new Error(`Status request failed with ${response.status}`)
|
|
}
|
|
const data = (await response.json()) as JobStatus
|
|
if (!cancelled) {
|
|
setStatus(data)
|
|
if (data.stage === "completed" || data.stage === "failed") {
|
|
clearInterval(interval)
|
|
if (data.stage === "completed" && onJobCreated) {
|
|
onJobCreated(jobId)
|
|
}
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
if (!cancelled) {
|
|
console.error("Failed to poll job status:", err)
|
|
setError(err.message ?? "Failed to fetch job status")
|
|
clearInterval(interval)
|
|
}
|
|
}
|
|
}, 4000)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
clearInterval(interval)
|
|
}
|
|
}, [jobId, onJobCreated])
|
|
|
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!event.target.files) return
|
|
const selected = Array.from(event.target.files)
|
|
setFiles(prev => [...prev, ...selected])
|
|
}
|
|
|
|
const removeFile = (fileName: string) => {
|
|
setFiles(prev => prev.filter(file => file.name !== fileName))
|
|
}
|
|
|
|
const startUpload = async () => {
|
|
setError(null)
|
|
if (files.length === 0) {
|
|
setError("Please select at least one file.")
|
|
return
|
|
}
|
|
|
|
const formData = new FormData()
|
|
files.forEach(file => formData.append("files", file))
|
|
|
|
setUploading(true)
|
|
try {
|
|
const response = await fetch(uploadUrl, {
|
|
method: "POST",
|
|
body: formData,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `Upload failed with ${response.status}`
|
|
try {
|
|
const errorData = await response.json()
|
|
errorMessage = errorData.detail || errorData.message || errorMessage
|
|
} catch {
|
|
const text = await response.text()
|
|
errorMessage = text || errorMessage
|
|
}
|
|
throw new Error(errorMessage)
|
|
}
|
|
|
|
const data = await response.json()
|
|
setJobId(data.job_id)
|
|
setStatus({
|
|
job_id: data.job_id,
|
|
stage: data.stage,
|
|
total_files: data.total_files,
|
|
processed_files: 0,
|
|
})
|
|
setUploading(false)
|
|
setIsExpanded(true) // Keep expanded when upload starts
|
|
} catch (err: any) {
|
|
console.error("Upload failed:", err)
|
|
setError(err.message ?? "Upload failed. Please try again.")
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
// Calculate progress based on stage and file processing
|
|
const progressPercent = status
|
|
? (() => {
|
|
// File extraction progress (0-30%)
|
|
const fileProgress = status.total_files > 0
|
|
? Math.round((status.processed_files / status.total_files) * 30)
|
|
: 0
|
|
|
|
// Stage-based progress
|
|
let stageProgress = 0
|
|
if (status.stage === "saving_files") {
|
|
stageProgress = 5
|
|
} else if (status.stage === "extracting") {
|
|
stageProgress = 10 + fileProgress // 10-40%
|
|
} else if (status.stage === "analyzing") {
|
|
// Analyzing takes the longest, show 40-85%
|
|
stageProgress = 40 + Math.min(fileProgress * 1.5, 45) // 40-85%
|
|
} else if (status.stage === "building_graph") {
|
|
stageProgress = 85 + Math.min(fileProgress * 0.5, 10) // 85-95%
|
|
} else if (status.stage === "completed") {
|
|
stageProgress = 100
|
|
} else if (status.stage === "failed") {
|
|
stageProgress = fileProgress // Show progress up to failure point
|
|
}
|
|
|
|
return Math.min(stageProgress, 100)
|
|
})()
|
|
: 0
|
|
|
|
const resetUpload = () => {
|
|
setFiles([])
|
|
setJobId(null)
|
|
setStatus(null)
|
|
setError(null)
|
|
setUploading(false)
|
|
}
|
|
|
|
return (
|
|
<Card className="bg-white/5 border border-white/10 rounded-xl">
|
|
<CardHeader
|
|
className="cursor-pointer hover:bg-white/5 transition-colors"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<Upload className="h-5 w-5 text-orange-400" />
|
|
<CardTitle className="text-lg text-white">
|
|
Upload Documents for Knowledge Graph
|
|
</CardTitle>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
{status && status.stage === "completed" && (
|
|
<span className="text-xs text-green-400 bg-green-400/10 px-2 py-1 rounded-full">
|
|
Completed
|
|
</span>
|
|
)}
|
|
{status && status.stage === "failed" && (
|
|
<span className="text-xs text-red-400 bg-red-400/10 px-2 py-1 rounded-full">
|
|
Failed
|
|
</span>
|
|
)}
|
|
{isExpanded ? (
|
|
<ChevronUp className="h-5 w-5 text-white/60" />
|
|
) : (
|
|
<ChevronDown className="h-5 w-5 text-white/60" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-white/60 mt-2">
|
|
Upload documents to extract causal relationships and build a knowledge graph in Neo4j
|
|
</p>
|
|
</CardHeader>
|
|
|
|
{isExpanded && (
|
|
<CardContent className="space-y-4">
|
|
{status?.stage === "completed" ? (
|
|
<div className="space-y-3">
|
|
<Alert className="bg-green-500/10 border border-green-500/40 text-green-200">
|
|
<AlertDescription>
|
|
✅ Knowledge graph created successfully! {status.total_files} file(s) processed.
|
|
</AlertDescription>
|
|
</Alert>
|
|
<Button
|
|
onClick={resetUpload}
|
|
variant="outline"
|
|
className="w-full border-white/20 text-white hover:bg-white/10"
|
|
>
|
|
Upload More Documents
|
|
</Button>
|
|
</div>
|
|
) : status?.stage === "failed" ? (
|
|
<div className="space-y-3">
|
|
<Alert variant="destructive" className="bg-red-500/10 border border-red-500/40 text-red-200">
|
|
<AlertDescription>
|
|
{error || "Upload failed. Please try again."}
|
|
</AlertDescription>
|
|
</Alert>
|
|
<Button
|
|
onClick={resetUpload}
|
|
variant="outline"
|
|
className="w-full border-white/20 text-white hover:bg-white/10"
|
|
>
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="border border-dashed border-orange-400/40 rounded-lg p-4 text-center bg-black/40">
|
|
<input
|
|
id="multi-docs-input-inline"
|
|
type="file"
|
|
multiple
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
disabled={uploading}
|
|
/>
|
|
<label
|
|
htmlFor="multi-docs-input-inline"
|
|
className={`flex flex-col items-center cursor-pointer space-y-2 ${uploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<Upload className="h-8 w-8 text-orange-400" />
|
|
<div>
|
|
<p className="font-semibold text-white text-sm">Click to select files</p>
|
|
<p className="text-xs text-white/60">
|
|
PDF, DOCX, PPT, XLS, JSON, XML, Markdown, images, audio/video
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
{files.length > 0 && (
|
|
<div className="bg-black/40 border border-white/10 rounded-lg p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-white/70">Selected Files ({files.length})</p>
|
|
<ul className="space-y-1 max-h-32 overflow-y-auto">
|
|
{files.map(file => (
|
|
<li key={file.name} className="flex items-center justify-between text-xs text-white/80 bg-white/5 rounded px-2 py-1">
|
|
<span className="flex items-center gap-2">
|
|
<FileIcon className="h-3 w-3 text-orange-400" />
|
|
<span className="truncate max-w-[200px]">{file.name}</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="text-orange-300 hover:text-orange-200"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
removeFile(file.name)
|
|
}}
|
|
disabled={uploading}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert variant="destructive" className="bg-red-500/10 border border-red-500/40 text-red-200">
|
|
<AlertDescription className="text-xs">{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{status && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-xs text-white/70">
|
|
<span>{STAGE_COPY[status.stage] || status.stage}</span>
|
|
<span>{progressPercent}%</span>
|
|
</div>
|
|
<Progress value={progressPercent} className="h-1.5" />
|
|
{status.status_message && (
|
|
<p className="text-xs text-white/60">{status.status_message}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
startUpload()
|
|
}}
|
|
className="w-full bg-orange-500 hover:bg-orange-600 text-black font-semibold"
|
|
disabled={uploading || files.length === 0}
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Uploading...
|
|
</>
|
|
) : (
|
|
<>
|
|
Start Upload <Upload className="ml-2 h-4 w-4" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|