466 lines
No EOL
16 KiB
TypeScript
466 lines
No EOL
16 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
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"
|
|
import { useRouter } from "next/navigation"
|
|
|
|
interface KnowledgeDropdownProps {
|
|
active?: boolean
|
|
variant?: 'navigation' | 'button'
|
|
}
|
|
|
|
export function KnowledgeDropdown({ active, variant = 'navigation' }: KnowledgeDropdownProps) {
|
|
const { addTask } = useTask()
|
|
const router = useRouter()
|
|
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 [cloudConnectors, setCloudConnectors] = useState<{[key: string]: {name: string, available: boolean, connected: boolean, hasToken: boolean}}>({})
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Check AWS availability and cloud connectors on mount
|
|
useEffect(() => {
|
|
const checkAvailability = async () => {
|
|
try {
|
|
// Check AWS
|
|
const awsRes = await fetch("/api/upload_options")
|
|
if (awsRes.ok) {
|
|
const awsData = await awsRes.json()
|
|
setAwsEnabled(Boolean(awsData.aws))
|
|
}
|
|
|
|
// Check cloud connectors
|
|
const connectorsRes = await fetch('/api/connectors')
|
|
if (connectorsRes.ok) {
|
|
const connectorsResult = await connectorsRes.json()
|
|
const cloudConnectorTypes = ['google_drive', 'onedrive', 'sharepoint']
|
|
const connectorInfo: {[key: string]: {name: string, available: boolean, connected: boolean, hasToken: boolean}} = {}
|
|
|
|
for (const type of cloudConnectorTypes) {
|
|
if (connectorsResult.connectors[type]) {
|
|
connectorInfo[type] = {
|
|
name: connectorsResult.connectors[type].name,
|
|
available: connectorsResult.connectors[type].available,
|
|
connected: false,
|
|
hasToken: false
|
|
}
|
|
|
|
// Check connection status
|
|
try {
|
|
const statusRes = await fetch(`/api/connectors/${type}/status`)
|
|
if (statusRes.ok) {
|
|
const statusData = await statusRes.json()
|
|
const connections = statusData.connections || []
|
|
const activeConnection = connections.find((conn: {is_active: boolean, connection_id: string}) => conn.is_active)
|
|
const isConnected = activeConnection !== undefined
|
|
|
|
if (isConnected && activeConnection) {
|
|
connectorInfo[type].connected = true
|
|
|
|
// Check token availability
|
|
try {
|
|
const tokenRes = await fetch(`/api/connectors/${type}/token?connection_id=${activeConnection.connection_id}`)
|
|
if (tokenRes.ok) {
|
|
const tokenData = await tokenRes.json()
|
|
if (tokenData.access_token) {
|
|
connectorInfo[type].hasToken = true
|
|
}
|
|
}
|
|
} catch {
|
|
// Token check failed
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Status check failed
|
|
}
|
|
}
|
|
}
|
|
|
|
setCloudConnectors(connectorInfo)
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to check availability", err)
|
|
}
|
|
}
|
|
checkAvailability()
|
|
}, [])
|
|
|
|
// 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 cloudConnectorItems = Object.entries(cloudConnectors)
|
|
.filter(([, info]) => info.available)
|
|
.map(([type, info]) => ({
|
|
label: info.name,
|
|
icon: PlugZap,
|
|
onClick: () => {
|
|
setIsOpen(false)
|
|
if (info.connected && info.hasToken) {
|
|
router.push(`/upload/${type}`)
|
|
} else {
|
|
router.push('/settings')
|
|
}
|
|
},
|
|
disabled: !info.connected || !info.hasToken,
|
|
tooltip: !info.connected ? `Connect ${info.name} in Settings first` :
|
|
!info.hasToken ? `Reconnect ${info.name} - access token required` :
|
|
undefined
|
|
}))
|
|
|
|
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)
|
|
}
|
|
}] : []),
|
|
...cloudConnectorItems
|
|
]
|
|
|
|
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}
|
|
disabled={'disabled' in item ? item.disabled : false}
|
|
title={'tooltip' in item ? item.tooltip : undefined}
|
|
className={cn(
|
|
"w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
|
|
'disabled' in item && item.disabled && "opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current"
|
|
)}
|
|
>
|
|
{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>
|
|
|
|
</>
|
|
)
|
|
} |