572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ChevronDown,
|
|
Cloud,
|
|
FolderOpen,
|
|
PlugZap,
|
|
Plus,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
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 { useTask } from "@/contexts/task-context";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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);
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const refetchSearch = () => {
|
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
};
|
|
|
|
// 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]);
|
|
|
|
// Use router upload and ingest endpoint (automatically routes based on configuration)
|
|
const uploadIngestRes = await fetch("/api/router/upload_ingest", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const uploadIngestJson = await uploadIngestRes.json();
|
|
if (!uploadIngestRes.ok) {
|
|
throw new Error(
|
|
uploadIngestJson?.error || "Upload and ingest failed",
|
|
);
|
|
}
|
|
|
|
// Extract results from the unified response
|
|
const fileId = uploadIngestJson?.upload?.id;
|
|
const filePath = uploadIngestJson?.upload?.path;
|
|
const runJson = uploadIngestJson?.ingestion;
|
|
const deleteResult = uploadIngestJson?.deletion;
|
|
|
|
if (!fileId || !filePath) {
|
|
throw new Error("Upload successful but no file id/path returned");
|
|
}
|
|
|
|
// Log deletion status if provided
|
|
if (deleteResult) {
|
|
if (deleteResult.status === "deleted") {
|
|
console.log(
|
|
"File successfully cleaned up from Langflow:",
|
|
deleteResult.file_id,
|
|
);
|
|
} else if (deleteResult.status === "delete_failed") {
|
|
console.warn(
|
|
"Failed to cleanup file from Langflow:",
|
|
deleteResult.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Notify UI
|
|
window.dispatchEvent(
|
|
new CustomEvent("fileUploaded", {
|
|
detail: {
|
|
file: files[0],
|
|
result: {
|
|
file_id: fileId,
|
|
file_path: filePath,
|
|
run: runJson,
|
|
deletion: deleteResult,
|
|
unified: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
// 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);
|
|
refetchSearch();
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
if (response.status === 400) {
|
|
toast.error("Upload failed", {
|
|
description: result.error || "Bad request",
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Folder upload error:", error);
|
|
} finally {
|
|
setFolderLoading(false);
|
|
refetchSearch();
|
|
}
|
|
};
|
|
|
|
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);
|
|
if (response.status === 400) {
|
|
toast.error("Upload failed", {
|
|
description: result.error || "Bad request",
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("S3 upload error:", error);
|
|
} finally {
|
|
setS3Loading(false);
|
|
refetchSearch();
|
|
}
|
|
};
|
|
|
|
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>
|
|
</>
|
|
);
|
|
}
|