This commit enhances the KnowledgeDropdown component by integrating a complete file handling process that includes uploading files to Langflow, running an ingestion flow, and deleting the uploaded files. It introduces error handling for each step and dispatches appropriate events to notify the UI of the upload and ingestion results. These changes improve the robustness and maintainability of the component, contributing to a well-documented codebase.
395 lines
No EOL
13 KiB
TypeScript
395 lines
No EOL
13 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { ChevronDown, Upload, FolderOpen, Cloud, PlugZap, Plus } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { cn } from "@/lib/utils"
|
|
import { useTask } from "@/contexts/task-context"
|
|
|
|
interface KnowledgeDropdownProps {
|
|
active?: boolean
|
|
variant?: 'navigation' | 'button'
|
|
}
|
|
|
|
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
|
|
const router = useRouter()
|
|
const { addTask } = useTask()
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [showFolderDialog, setShowFolderDialog] = useState(false)
|
|
const [showS3Dialog, setShowS3Dialog] = useState(false)
|
|
const [awsEnabled, setAwsEnabled] = useState(false)
|
|
const [folderPath, setFolderPath] = useState("/app/documents/")
|
|
const [bucketUrl, setBucketUrl] = useState("s3://")
|
|
const [folderLoading, setFolderLoading] = useState(false)
|
|
const [s3Loading, setS3Loading] = useState(false)
|
|
const [fileUploading, setFileUploading] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Check AWS availability on mount
|
|
useEffect(() => {
|
|
const checkAws = async () => {
|
|
try {
|
|
const res = await fetch("/api/upload_options")
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setAwsEnabled(Boolean(data.aws))
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to check AWS availability", err)
|
|
}
|
|
}
|
|
checkAws()
|
|
}, [])
|
|
|
|
// Handle click outside to close dropdown
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
|
|
if (isOpen) {
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}
|
|
}, [isOpen])
|
|
|
|
const handleFileUpload = () => {
|
|
fileInputRef.current?.click()
|
|
}
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (files && files.length > 0) {
|
|
// Close dropdown and disable button immediately after file selection
|
|
setIsOpen(false)
|
|
setFileUploading(true)
|
|
|
|
// Trigger the same file upload event as the chat page
|
|
window.dispatchEvent(new CustomEvent('fileUploadStart', {
|
|
detail: { filename: files[0].name }
|
|
}))
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', files[0])
|
|
|
|
// 1) Upload to Langflow
|
|
const upRes = await fetch('/api/langflow/files/upload', {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
const upJson = await upRes.json()
|
|
if (!upRes.ok) {
|
|
throw new Error(upJson?.error || 'Upload to Langflow failed')
|
|
}
|
|
|
|
const fileId = upJson?.id
|
|
const filePath = upJson?.path
|
|
if (!fileId || !filePath) {
|
|
throw new Error('Langflow did not return file id/path')
|
|
}
|
|
|
|
// 2) Run ingestion flow
|
|
const runRes = await fetch('/api/langflow/ingest', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_paths: [filePath] }),
|
|
})
|
|
const runJson = await runRes.json()
|
|
if (!runRes.ok) {
|
|
throw new Error(runJson?.error || 'Langflow ingestion failed')
|
|
}
|
|
|
|
// 3) Delete file from Langflow
|
|
const delRes = await fetch('/api/langflow/files', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ file_ids: [fileId] }),
|
|
})
|
|
const delJson = await delRes.json().catch(() => ({}))
|
|
if (!delRes.ok) {
|
|
throw new Error(delJson?.error || 'Langflow file delete failed')
|
|
}
|
|
|
|
// Notify UI
|
|
window.dispatchEvent(new CustomEvent('fileUploaded', {
|
|
detail: { file: files[0], result: { file_id: fileId, file_path: filePath, run: runJson } }
|
|
}))
|
|
// Trigger search refresh after successful ingestion
|
|
window.dispatchEvent(new CustomEvent('knowledgeUpdated'))
|
|
} catch (error) {
|
|
window.dispatchEvent(new CustomEvent('fileUploadError', {
|
|
detail: { filename: files[0].name, error: error instanceof Error ? error.message : 'Upload failed' }
|
|
}))
|
|
} finally {
|
|
window.dispatchEvent(new CustomEvent('fileUploadComplete'))
|
|
setFileUploading(false)
|
|
}
|
|
}
|
|
|
|
// Reset file input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = ''
|
|
}
|
|
}
|
|
|
|
const handleFolderUpload = async () => {
|
|
if (!folderPath.trim()) return
|
|
|
|
setFolderLoading(true)
|
|
setShowFolderDialog(false)
|
|
|
|
try {
|
|
const response = await fetch("/api/upload_path", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ path: folderPath }),
|
|
})
|
|
|
|
const result = await response.json()
|
|
|
|
if (response.status === 201) {
|
|
const taskId = result.task_id || result.id
|
|
|
|
if (!taskId) {
|
|
throw new Error("No task ID received from server")
|
|
}
|
|
|
|
addTask(taskId)
|
|
setFolderPath("")
|
|
// Trigger search refresh after successful folder processing starts
|
|
window.dispatchEvent(new CustomEvent('knowledgeUpdated'))
|
|
|
|
} else if (response.ok) {
|
|
setFolderPath("")
|
|
window.dispatchEvent(new CustomEvent('knowledgeUpdated'))
|
|
} else {
|
|
console.error("Folder upload failed:", result.error)
|
|
}
|
|
} catch (error) {
|
|
console.error("Folder upload error:", error)
|
|
} finally {
|
|
setFolderLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleS3Upload = async () => {
|
|
if (!bucketUrl.trim()) return
|
|
|
|
setS3Loading(true)
|
|
setShowS3Dialog(false)
|
|
|
|
try {
|
|
const response = await fetch("/api/upload_bucket", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ s3_url: bucketUrl }),
|
|
})
|
|
|
|
const result = await response.json()
|
|
|
|
if (response.status === 201) {
|
|
const taskId = result.task_id || result.id
|
|
|
|
if (!taskId) {
|
|
throw new Error("No task ID received from server")
|
|
}
|
|
|
|
addTask(taskId)
|
|
setBucketUrl("s3://")
|
|
// Trigger search refresh after successful S3 processing starts
|
|
window.dispatchEvent(new CustomEvent('knowledgeUpdated'))
|
|
} else {
|
|
console.error("S3 upload failed:", result.error)
|
|
}
|
|
} catch (error) {
|
|
console.error("S3 upload error:", error)
|
|
} finally {
|
|
setS3Loading(false)
|
|
}
|
|
}
|
|
|
|
const menuItems = [
|
|
{
|
|
label: "Add File",
|
|
icon: Upload,
|
|
onClick: handleFileUpload
|
|
},
|
|
{
|
|
label: "Process Folder",
|
|
icon: FolderOpen,
|
|
onClick: () => {
|
|
setIsOpen(false)
|
|
setShowFolderDialog(true)
|
|
}
|
|
},
|
|
...(awsEnabled ? [{
|
|
label: "Process S3 Bucket",
|
|
icon: Cloud,
|
|
onClick: () => {
|
|
setIsOpen(false)
|
|
setShowS3Dialog(true)
|
|
}
|
|
}] : []),
|
|
{
|
|
label: "Cloud Connectors",
|
|
icon: PlugZap,
|
|
onClick: () => {
|
|
setIsOpen(false)
|
|
router.push("/settings")
|
|
}
|
|
}
|
|
]
|
|
|
|
return (
|
|
<>
|
|
<div ref={dropdownRef} className="relative">
|
|
<button
|
|
onClick={() => !(fileUploading || folderLoading || s3Loading) && setIsOpen(!isOpen)}
|
|
disabled={fileUploading || folderLoading || s3Loading}
|
|
className={cn(
|
|
variant === 'button'
|
|
? "rounded-lg h-12 px-4 flex items-center gap-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
: "text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed",
|
|
variant === 'navigation' && active
|
|
? "bg-accent text-accent-foreground shadow-sm"
|
|
: variant === 'navigation' ? "text-foreground hover:text-accent-foreground" : "",
|
|
)}
|
|
>
|
|
{variant === 'button' ? (
|
|
<>
|
|
<Plus className="h-4 w-4" />
|
|
<span>Add Knowledge</span>
|
|
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center flex-1">
|
|
<Upload className={cn("h-4 w-4 mr-3 shrink-0", active ? "text-accent-foreground" : "text-muted-foreground group-hover:text-foreground")} />
|
|
Knowledge
|
|
</div>
|
|
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-md z-50">
|
|
<div className="py-1">
|
|
{menuItems.map((item, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={item.onClick}
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
|
/>
|
|
</div>
|
|
|
|
{/* Process Folder Dialog */}
|
|
<Dialog open={showFolderDialog} onOpenChange={setShowFolderDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FolderOpen className="h-5 w-5" />
|
|
Process Folder
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Process all documents in a folder path
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="folder-path">Folder Path</Label>
|
|
<Input
|
|
id="folder-path"
|
|
type="text"
|
|
placeholder="/path/to/documents"
|
|
value={folderPath}
|
|
onChange={(e) => setFolderPath(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowFolderDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleFolderUpload}
|
|
disabled={!folderPath.trim() || folderLoading}
|
|
>
|
|
{folderLoading ? "Processing..." : "Process Folder"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Process S3 Bucket Dialog */}
|
|
<Dialog open={showS3Dialog} onOpenChange={setShowS3Dialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Cloud className="h-5 w-5" />
|
|
Process S3 Bucket
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Process all documents from an S3 bucket. AWS credentials must be configured.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bucket-url">S3 URL</Label>
|
|
<Input
|
|
id="bucket-url"
|
|
type="text"
|
|
placeholder="s3://bucket/path"
|
|
value={bucketUrl}
|
|
onChange={(e) => setBucketUrl(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowS3Dialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleS3Upload}
|
|
disabled={!bucketUrl.trim() || s3Loading}
|
|
>
|
|
{s3Loading ? "Processing..." : "Process Bucket"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
} |