Merge branch 'main' of github.com:langflow-ai/openrag into feat/onboarding
This commit is contained in:
commit
9ad1fb3f4f
34 changed files with 2256 additions and 1623 deletions
|
|
@ -62,8 +62,7 @@ LANGFLOW_CHAT_FLOW_ID=your_chat_flow_id
|
||||||
LANGFLOW_INGEST_FLOW_ID=your_ingest_flow_id
|
LANGFLOW_INGEST_FLOW_ID=your_ingest_flow_id
|
||||||
NUDGES_FLOW_ID=your_nudges_flow_id
|
NUDGES_FLOW_ID=your_nudges_flow_id
|
||||||
```
|
```
|
||||||
ee extended configuration, including ingestion and optional variables: [docs/configuration.md](docs/
|
See extended configuration, including ingestion and optional variables: [docs/configuration.md](docs/configuration.md)
|
||||||
configuration.md)
|
|
||||||
### 3. Start OpenRAG
|
### 3. Start OpenRAG
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ services:
|
||||||
langflow:
|
langflow:
|
||||||
volumes:
|
volumes:
|
||||||
- ./flows:/app/flows:Z
|
- ./flows:/app/flows:Z
|
||||||
image: phact/langflow:${LANGFLOW_VERSION:-responses}
|
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
|
||||||
container_name: langflow
|
container_name: langflow
|
||||||
ports:
|
ports:
|
||||||
- "7860:7860"
|
- "7860:7860"
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ services:
|
||||||
langflow:
|
langflow:
|
||||||
volumes:
|
volumes:
|
||||||
- ./flows:/app/flows:Z
|
- ./flows:/app/flows:Z
|
||||||
image: phact/langflow:${LANGFLOW_VERSION:-responses}
|
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
|
||||||
container_name: langflow
|
container_name: langflow
|
||||||
ports:
|
ports:
|
||||||
- "7860:7860"
|
- "7860:7860"
|
||||||
|
|
|
||||||
84
frontend/components/confirmation-dialog.tsx
Normal file
84
frontend/components/confirmation-dialog.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "./ui/dialog";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConfirmationDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
variant?: "destructive" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title = "Are you sure?",
|
||||||
|
description = "This action cannot be undone.",
|
||||||
|
confirmText = "Confirm",
|
||||||
|
cancelText = "Cancel",
|
||||||
|
onConfirm,
|
||||||
|
isLoading = false,
|
||||||
|
variant = "destructive",
|
||||||
|
}) => {
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
} finally {
|
||||||
|
// Only close if not in loading state (let the parent handle this)
|
||||||
|
if (!isLoading) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{variant === "destructive" && (
|
||||||
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
|
)}
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,25 +1,80 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EllipsisVertical } from "lucide-react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { EllipsisVertical } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { DeleteConfirmationDialog } from "./confirmation-dialog";
|
||||||
|
import { useDeleteDocument } from "@/app/api/mutations/useDeleteDocument";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function KnowledgeActionsDropdown() {
|
interface KnowledgeActionsDropdownProps {
|
||||||
return (
|
filename: string;
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<Button variant="ghost" className="hover:bg-transparent">
|
|
||||||
<EllipsisVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="right" sideOffset={-10}>
|
|
||||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const KnowledgeActionsDropdown = ({
|
||||||
|
filename,
|
||||||
|
}: KnowledgeActionsDropdownProps) => {
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const deleteDocumentMutation = useDeleteDocument();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteDocumentMutation.mutateAsync({ filename });
|
||||||
|
toast.success(`Successfully deleted "${filename}"`);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to delete document"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button variant="ghost" className="hover:bg-transparent">
|
||||||
|
<EllipsisVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="right" sideOffset={-10}>
|
||||||
|
{/* //TODO: Implement rename and sync */}
|
||||||
|
{/* <DropdownMenuItem
|
||||||
|
className="text-primary focus:text-primary"
|
||||||
|
onClick={() => alert("Not implemented")}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-primary focus:text-primary"
|
||||||
|
onClick={() => alert("Not implemented")}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</DropdownMenuItem> */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={showDeleteDialog}
|
||||||
|
onOpenChange={setShowDeleteDialog}
|
||||||
|
title="Delete Document"
|
||||||
|
description={`Are you sure you want to delete "${filename}"? This will remove all chunks and data associated with this document. This action cannot be undone.`}
|
||||||
|
confirmText="Delete"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
isLoading={deleteDocumentMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Cloud,
|
Cloud,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
Plus,
|
Plus,
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -44,6 +44,7 @@ export function KnowledgeDropdown({
|
||||||
const [folderLoading, setFolderLoading] = useState(false);
|
const [folderLoading, setFolderLoading] = useState(false);
|
||||||
const [s3Loading, setS3Loading] = useState(false);
|
const [s3Loading, setS3Loading] = useState(false);
|
||||||
const [fileUploading, setFileUploading] = useState(false);
|
const [fileUploading, setFileUploading] = useState(false);
|
||||||
|
const [isNavigatingToCloud, setIsNavigatingToCloud] = useState(false);
|
||||||
const [cloudConnectors, setCloudConnectors] = useState<{
|
const [cloudConnectors, setCloudConnectors] = useState<{
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -55,12 +56,6 @@ export function KnowledgeDropdown({
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const refetchSearch = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check AWS availability and cloud connectors on mount
|
// Check AWS availability and cloud connectors on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAvailability = async () => {
|
const checkAvailability = async () => {
|
||||||
|
|
@ -107,7 +102,7 @@ export function KnowledgeDropdown({
|
||||||
const connections = statusData.connections || [];
|
const connections = statusData.connections || [];
|
||||||
const activeConnection = connections.find(
|
const activeConnection = connections.find(
|
||||||
(conn: { is_active: boolean; connection_id: string }) =>
|
(conn: { is_active: boolean; connection_id: string }) =>
|
||||||
conn.is_active,
|
conn.is_active
|
||||||
);
|
);
|
||||||
const isConnected = activeConnection !== undefined;
|
const isConnected = activeConnection !== undefined;
|
||||||
|
|
||||||
|
|
@ -117,7 +112,7 @@ export function KnowledgeDropdown({
|
||||||
// Check token availability
|
// Check token availability
|
||||||
try {
|
try {
|
||||||
const tokenRes = await fetch(
|
const tokenRes = await fetch(
|
||||||
`/api/connectors/${type}/token?connection_id=${activeConnection.connection_id}`,
|
`/api/connectors/${type}/token?connection_id=${activeConnection.connection_id}`
|
||||||
);
|
);
|
||||||
if (tokenRes.ok) {
|
if (tokenRes.ok) {
|
||||||
const tokenData = await tokenRes.json();
|
const tokenData = await tokenRes.json();
|
||||||
|
|
@ -178,7 +173,7 @@ export function KnowledgeDropdown({
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("fileUploadStart", {
|
new CustomEvent("fileUploadStart", {
|
||||||
detail: { filename: files[0].name },
|
detail: { filename: files[0].name },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -190,21 +185,38 @@ export function KnowledgeDropdown({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadIngestJson = await uploadIngestRes.json();
|
const uploadIngestJson = await uploadIngestRes.json();
|
||||||
|
|
||||||
if (!uploadIngestRes.ok) {
|
if (!uploadIngestRes.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
uploadIngestJson?.error || "Upload and ingest failed",
|
uploadIngestJson?.error || "Upload and ingest failed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract results from the unified response
|
// Extract results from the response - handle both unified and simple formats
|
||||||
const fileId = uploadIngestJson?.upload?.id;
|
const fileId = uploadIngestJson?.upload?.id || uploadIngestJson?.id;
|
||||||
const filePath = uploadIngestJson?.upload?.path;
|
const filePath =
|
||||||
|
uploadIngestJson?.upload?.path ||
|
||||||
|
uploadIngestJson?.path ||
|
||||||
|
"uploaded";
|
||||||
const runJson = uploadIngestJson?.ingestion;
|
const runJson = uploadIngestJson?.ingestion;
|
||||||
const deleteResult = uploadIngestJson?.deletion;
|
const deleteResult = uploadIngestJson?.deletion;
|
||||||
|
|
||||||
if (!fileId || !filePath) {
|
if (!fileId) {
|
||||||
throw new Error("Upload successful but no file id/path returned");
|
throw new Error("Upload successful but no file id returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ingestion actually succeeded
|
||||||
|
if (
|
||||||
|
runJson &&
|
||||||
|
runJson.status !== "COMPLETED" &&
|
||||||
|
runJson.status !== "SUCCESS"
|
||||||
|
) {
|
||||||
|
const errorMsg = runJson.error || "Ingestion pipeline failed";
|
||||||
|
throw new Error(
|
||||||
|
`Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log deletion status if provided
|
// Log deletion status if provided
|
||||||
|
|
@ -212,12 +224,12 @@ export function KnowledgeDropdown({
|
||||||
if (deleteResult.status === "deleted") {
|
if (deleteResult.status === "deleted") {
|
||||||
console.log(
|
console.log(
|
||||||
"File successfully cleaned up from Langflow:",
|
"File successfully cleaned up from Langflow:",
|
||||||
deleteResult.file_id,
|
deleteResult.file_id
|
||||||
);
|
);
|
||||||
} else if (deleteResult.status === "delete_failed") {
|
} else if (deleteResult.status === "delete_failed") {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Failed to cleanup file from Langflow:",
|
"Failed to cleanup file from Langflow:",
|
||||||
deleteResult.error,
|
deleteResult.error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,8 +247,9 @@ export function KnowledgeDropdown({
|
||||||
unified: true,
|
unified: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger search refresh after successful ingestion
|
// Trigger search refresh after successful ingestion
|
||||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -246,12 +259,12 @@ export function KnowledgeDropdown({
|
||||||
filename: files[0].name,
|
filename: files[0].name,
|
||||||
error: error instanceof Error ? error.message : "Upload failed",
|
error: error instanceof Error ? error.message : "Upload failed",
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
||||||
setFileUploading(false);
|
setFileUploading(false);
|
||||||
refetchSearch();
|
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,9 +301,15 @@ export function KnowledgeDropdown({
|
||||||
addTask(taskId);
|
addTask(taskId);
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
// Trigger search refresh after successful folder processing starts
|
// Trigger search refresh after successful folder processing starts
|
||||||
|
console.log(
|
||||||
|
"Folder upload successful, dispatching knowledgeUpdated event"
|
||||||
|
);
|
||||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
} else if (response.ok) {
|
} else if (response.ok) {
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
|
console.log(
|
||||||
|
"Folder upload successful (direct), dispatching knowledgeUpdated event"
|
||||||
|
);
|
||||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
} else {
|
} else {
|
||||||
console.error("Folder upload failed:", result.error);
|
console.error("Folder upload failed:", result.error);
|
||||||
|
|
@ -299,7 +318,7 @@ export function KnowledgeDropdown({
|
||||||
console.error("Folder upload error:", error);
|
console.error("Folder upload error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setFolderLoading(false);
|
setFolderLoading(false);
|
||||||
refetchSearch();
|
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -330,6 +349,7 @@ export function KnowledgeDropdown({
|
||||||
addTask(taskId);
|
addTask(taskId);
|
||||||
setBucketUrl("s3://");
|
setBucketUrl("s3://");
|
||||||
// Trigger search refresh after successful S3 processing starts
|
// Trigger search refresh after successful S3 processing starts
|
||||||
|
console.log("S3 upload successful, dispatching knowledgeUpdated event");
|
||||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
} else {
|
} else {
|
||||||
console.error("S3 upload failed:", result.error);
|
console.error("S3 upload failed:", result.error);
|
||||||
|
|
@ -338,7 +358,7 @@ export function KnowledgeDropdown({
|
||||||
console.error("S3 upload error:", error);
|
console.error("S3 upload error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setS3Loading(false);
|
setS3Loading(false);
|
||||||
refetchSearch();
|
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -347,10 +367,17 @@ export function KnowledgeDropdown({
|
||||||
.map(([type, info]) => ({
|
.map(([type, info]) => ({
|
||||||
label: info.name,
|
label: info.name,
|
||||||
icon: PlugZap,
|
icon: PlugZap,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (info.connected && info.hasToken) {
|
if (info.connected && info.hasToken) {
|
||||||
router.push(`/upload/${type}`);
|
setIsNavigatingToCloud(true);
|
||||||
|
try {
|
||||||
|
router.push(`/upload/${type}`);
|
||||||
|
// Keep loading state for a short time to show feedback
|
||||||
|
setTimeout(() => setIsNavigatingToCloud(false), 1000);
|
||||||
|
} catch {
|
||||||
|
setIsNavigatingToCloud(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}
|
}
|
||||||
|
|
@ -392,14 +419,16 @@ export function KnowledgeDropdown({
|
||||||
...cloudConnectorItems,
|
...cloudConnectorItems,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Comprehensive loading state
|
||||||
|
const isLoading =
|
||||||
|
fileUploading || folderLoading || s3Loading || isNavigatingToCloud;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={dropdownRef} className="relative">
|
<div ref={dropdownRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => !isLoading && setIsOpen(!isOpen)}
|
||||||
!(fileUploading || folderLoading || s3Loading) && setIsOpen(!isOpen)
|
disabled={isLoading}
|
||||||
}
|
|
||||||
disabled={fileUploading || folderLoading || s3Loading}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
variant === "button"
|
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"
|
? "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"
|
||||||
|
|
@ -408,44 +437,68 @@ export function KnowledgeDropdown({
|
||||||
? "bg-accent text-accent-foreground shadow-sm"
|
? "bg-accent text-accent-foreground shadow-sm"
|
||||||
: variant === "navigation"
|
: variant === "navigation"
|
||||||
? "text-foreground hover:text-accent-foreground"
|
? "text-foreground hover:text-accent-foreground"
|
||||||
: "",
|
: ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{variant === "button" ? (
|
{variant === "button" ? (
|
||||||
<>
|
<>
|
||||||
<Plus className="h-4 w-4" />
|
{isLoading ? (
|
||||||
<span>Add Knowledge</span>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<ChevronDown
|
) : (
|
||||||
className={cn(
|
<Plus className="h-4 w-4" />
|
||||||
"h-4 w-4 transition-transform",
|
)}
|
||||||
isOpen && "rotate-180",
|
<span>
|
||||||
)}
|
{isLoading
|
||||||
/>
|
? fileUploading
|
||||||
|
? "Uploading..."
|
||||||
|
: folderLoading
|
||||||
|
? "Processing Folder..."
|
||||||
|
: s3Loading
|
||||||
|
? "Processing S3..."
|
||||||
|
: isNavigatingToCloud
|
||||||
|
? "Loading..."
|
||||||
|
: "Processing..."
|
||||||
|
: "Add Knowledge"}
|
||||||
|
</span>
|
||||||
|
{!isLoading && (
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform",
|
||||||
|
isOpen && "rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
<Upload
|
{isLoading ? (
|
||||||
className={cn(
|
<Loader2 className="h-4 w-4 mr-3 shrink-0 animate-spin" />
|
||||||
"h-4 w-4 mr-3 shrink-0",
|
) : (
|
||||||
active
|
<Upload
|
||||||
? "text-accent-foreground"
|
className={cn(
|
||||||
: "text-muted-foreground group-hover:text-foreground",
|
"h-4 w-4 mr-3 shrink-0",
|
||||||
)}
|
active
|
||||||
/>
|
? "text-accent-foreground"
|
||||||
|
: "text-muted-foreground group-hover:text-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
Knowledge
|
Knowledge
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
{!isLoading && (
|
||||||
className={cn(
|
<ChevronDown
|
||||||
"h-4 w-4 transition-transform",
|
className={cn(
|
||||||
isOpen && "rotate-180",
|
"h-4 w-4 transition-transform",
|
||||||
)}
|
isOpen && "rotate-180"
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && !isLoading && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-md z-50">
|
<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">
|
<div className="py-1">
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
|
|
@ -458,7 +511,7 @@ export function KnowledgeDropdown({
|
||||||
"w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
|
"w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
"disabled" in item &&
|
"disabled" in item &&
|
||||||
item.disabled &&
|
item.disabled &&
|
||||||
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current",
|
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
@ -497,7 +550,7 @@ export function KnowledgeDropdown({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="/path/to/documents"
|
placeholder="/path/to/documents"
|
||||||
value={folderPath}
|
value={folderPath}
|
||||||
onChange={(e) => setFolderPath(e.target.value)}
|
onChange={e => setFolderPath(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|
@ -539,7 +592,7 @@ export function KnowledgeDropdown({
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="s3://bucket/path"
|
placeholder="s3://bucket/path"
|
||||||
value={bucketUrl}
|
value={bucketUrl}
|
||||||
onChange={(e) => setBucketUrl(e.target.value)}
|
onChange={e => setBucketUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[rehypeMathjax, rehypeRaw]}
|
rehypePlugins={[rehypeMathjax, rehypeRaw]}
|
||||||
linkTarget="_blank"
|
urlTransform={(url) => url}
|
||||||
components={{
|
components={{
|
||||||
p({ node, ...props }) {
|
p({ node, ...props }) {
|
||||||
return <p className="w-fit max-w-full">{props.children}</p>;
|
return <p className="w-fit max-w-full">{props.children}</p>;
|
||||||
|
|
@ -79,7 +79,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
h3({ node, ...props }) {
|
h3({ node, ...props }) {
|
||||||
return <h3 className="mb-2 mt-4">{props.children}</h3>;
|
return <h3 className="mb-2 mt-4">{props.children}</h3>;
|
||||||
},
|
},
|
||||||
hr({ node, ...props }) {
|
hr() {
|
||||||
return <hr className="w-full mt-4 mb-8" />;
|
return <hr className="w-full mt-4 mb-8" />;
|
||||||
},
|
},
|
||||||
ul({ node, ...props }) {
|
ul({ node, ...props }) {
|
||||||
|
|
@ -97,8 +97,12 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
a({ node, ...props }) {
|
||||||
|
return <a {...props} target="_blank" rel="noopener noreferrer">{props.children}</a>;
|
||||||
|
},
|
||||||
|
|
||||||
code: ({ node, className, inline, children, ...props }) => {
|
code(props) {
|
||||||
|
const { children, className, ...rest } = props;
|
||||||
let content = children as string;
|
let content = children as string;
|
||||||
if (
|
if (
|
||||||
Array.isArray(children) &&
|
Array.isArray(children) &&
|
||||||
|
|
@ -120,14 +124,15 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
|
const isInline = !className?.startsWith("language-");
|
||||||
|
|
||||||
return !inline ? (
|
return !isInline ? (
|
||||||
<CodeComponent
|
<CodeComponent
|
||||||
language={(match && match[1]) || ""}
|
language={(match && match[1]) || ""}
|
||||||
code={String(content).replace(/\n$/, "")}
|
code={String(content).replace(/\n$/, "")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<code className={className} {...props}>
|
<code className={className} {...rest}>
|
||||||
{content}
|
{content}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative block h-fit w-full text-xs",
|
"relative block h-fit w-full text-sm",
|
||||||
icon ? className : "",
|
icon ? className : ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|
@ -39,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
className={cn(
|
className={cn(
|
||||||
"primary-input !placeholder-transparent",
|
"primary-input !placeholder-transparent",
|
||||||
icon && "pl-9",
|
icon && "pl-9",
|
||||||
icon ? inputClassName : className,
|
icon ? inputClassName : className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -56,7 +56,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input";
|
||||||
|
|
|
||||||
2240
frontend/package-lock.json
generated
2240
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -42,11 +42,11 @@
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"rehype-mathjax": "^4.0.3",
|
"rehype-mathjax": "^7.1.0",
|
||||||
"rehype-raw": "^6.1.1",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "3.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.5",
|
"eslint-config-next": "15.3.5",
|
||||||
|
|
|
||||||
45
frontend/src/app/api/mutations/useDeleteDocument.ts
Normal file
45
frontend/src/app/api/mutations/useDeleteDocument.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface DeleteDocumentRequest {
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteDocumentResponse {
|
||||||
|
success: boolean;
|
||||||
|
deleted_chunks: number;
|
||||||
|
filename: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDocument = async (
|
||||||
|
data: DeleteDocumentRequest
|
||||||
|
): Promise<DeleteDocumentResponse> => {
|
||||||
|
const response = await fetch("/api/documents/delete-by-filename", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || "Failed to delete document");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteDocument = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteDocument,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate and refetch search queries to update the UI
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -14,7 +14,7 @@ const DEFAULT_NUDGES = [
|
||||||
|
|
||||||
export const useGetNudgesQuery = (
|
export const useGetNudgesQuery = (
|
||||||
chatId?: string | null,
|
chatId?: string | null,
|
||||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -26,7 +26,12 @@ export const useGetNudgesQuery = (
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/nudges${chatId ? `/${chatId}` : ""}`);
|
const response = await fetch(`/api/nudges${chatId ? `/${chatId}` : ""}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.response.split("\n").filter(Boolean) || DEFAULT_NUDGES;
|
|
||||||
|
if (data.response && typeof data.response === "string") {
|
||||||
|
return data.response.split("\n").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_NUDGES;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error getting nudges", error);
|
console.error("Error getting nudges", error);
|
||||||
return DEFAULT_NUDGES;
|
return DEFAULT_NUDGES;
|
||||||
|
|
@ -39,7 +44,7 @@ export const useGetNudgesQuery = (
|
||||||
queryFn: getNudges,
|
queryFn: getNudges,
|
||||||
...options,
|
...options,
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient
|
||||||
);
|
);
|
||||||
|
|
||||||
return { ...queryResult, cancel };
|
return { ...queryResult, cancel };
|
||||||
|
|
|
||||||
|
|
@ -48,15 +48,22 @@ export interface File {
|
||||||
export const useGetSearchQuery = (
|
export const useGetSearchQuery = (
|
||||||
query: string,
|
query: string,
|
||||||
queryData?: ParsedQueryData | null,
|
queryData?: ParsedQueryData | null,
|
||||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">
|
||||||
) => {
|
) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Normalize the query to match what will actually be searched
|
||||||
|
const effectiveQuery = query || queryData?.query || "*";
|
||||||
|
|
||||||
async function getFiles(): Promise<File[]> {
|
async function getFiles(): Promise<File[]> {
|
||||||
try {
|
try {
|
||||||
const searchPayload: SearchPayload = {
|
const searchPayload: SearchPayload = {
|
||||||
query: query || queryData?.query || "*",
|
query: effectiveQuery,
|
||||||
limit: queryData?.limit || (query.trim() === "" ? 10000 : 10), // Maximum allowed limit for wildcard searches
|
limit:
|
||||||
|
queryData?.limit ||
|
||||||
|
(effectiveQuery.trim() === "*" || effectiveQuery.trim() === ""
|
||||||
|
? 10000
|
||||||
|
: 10), // Maximum allowed limit for wildcard searches
|
||||||
scoreThreshold: queryData?.scoreThreshold || 0,
|
scoreThreshold: queryData?.scoreThreshold || 0,
|
||||||
};
|
};
|
||||||
if (queryData?.filters) {
|
if (queryData?.filters) {
|
||||||
|
|
@ -142,7 +149,7 @@ export const useGetSearchQuery = (
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const files: File[] = Array.from(fileMap.values()).map((file) => ({
|
const files: File[] = Array.from(fileMap.values()).map(file => ({
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
mimetype: file.mimetype,
|
mimetype: file.mimetype,
|
||||||
chunkCount: file.chunks.length,
|
chunkCount: file.chunks.length,
|
||||||
|
|
@ -165,12 +172,12 @@ export const useGetSearchQuery = (
|
||||||
|
|
||||||
const queryResult = useQuery(
|
const queryResult = useQuery(
|
||||||
{
|
{
|
||||||
queryKey: ["search", query],
|
queryKey: ["search", effectiveQuery],
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: prev => prev,
|
||||||
queryFn: getFiles,
|
queryFn: getFiles,
|
||||||
...options,
|
...options,
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient
|
||||||
);
|
);
|
||||||
|
|
||||||
return queryResult;
|
return queryResult;
|
||||||
|
|
|
||||||
212
frontend/src/app/knowledge/chunks/page.tsx
Normal file
212
frontend/src/app/knowledge/chunks/page.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Cloud,
|
||||||
|
FileText,
|
||||||
|
HardDrive,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { SiGoogledrive } from "react-icons/si";
|
||||||
|
import { TbBrandOnedrive } from "react-icons/tb";
|
||||||
|
import { ProtectedRoute } from "@/components/protected-route";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
|
import { useTask } from "@/contexts/task-context";
|
||||||
|
import {
|
||||||
|
type ChunkResult,
|
||||||
|
type File,
|
||||||
|
useGetSearchQuery,
|
||||||
|
} from "../../api/queries/useGetSearchQuery";
|
||||||
|
|
||||||
|
// Function to get the appropriate icon for a connector type
|
||||||
|
function getSourceIcon(connectorType?: string) {
|
||||||
|
switch (connectorType) {
|
||||||
|
case "google_drive":
|
||||||
|
return <SiGoogledrive className="h-4 w-4 text-foreground" />;
|
||||||
|
case "onedrive":
|
||||||
|
return <TbBrandOnedrive className="h-4 w-4 text-foreground" />;
|
||||||
|
case "sharepoint":
|
||||||
|
return <Building2 className="h-4 w-4 text-foreground" />;
|
||||||
|
case "s3":
|
||||||
|
return <Cloud className="h-4 w-4 text-foreground" />;
|
||||||
|
default:
|
||||||
|
return <HardDrive className="h-4 w-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChunksPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { isMenuOpen } = useTask();
|
||||||
|
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter();
|
||||||
|
|
||||||
|
const filename = searchParams.get("filename");
|
||||||
|
const [chunks, setChunks] = useState<ChunkResult[]>([]);
|
||||||
|
|
||||||
|
// Use the same search query as the knowledge page, but we'll filter for the specific file
|
||||||
|
const { data = [], isFetching } = useGetSearchQuery("*", parsedFilterData);
|
||||||
|
|
||||||
|
// Extract chunks for the specific file
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filename || !(data as File[]).length) {
|
||||||
|
setChunks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileData = (data as File[]).find(
|
||||||
|
(file: File) => file.filename === filename
|
||||||
|
);
|
||||||
|
setChunks(fileData?.chunks || []);
|
||||||
|
}, [data, filename]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<p className="text-lg text-muted-foreground">No file specified</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||||
|
Please select a file from the knowledge page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
|
||||||
|
isMenuOpen && isPanelOpen
|
||||||
|
? "md:right-[704px]"
|
||||||
|
: // Both open: 384px (menu) + 320px (KF panel)
|
||||||
|
isMenuOpen
|
||||||
|
? "md:right-96"
|
||||||
|
: // Only menu open: 384px
|
||||||
|
isPanelOpen
|
||||||
|
? "md:right-80"
|
||||||
|
: // Only KF panel open: 320px
|
||||||
|
"md:right-6" // Neither open: 24px
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 px-6 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="text-muted-foreground hover:text-foreground px-2"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-lg font-semibold">Document Chunks</h2>
|
||||||
|
<p className="text-sm text-muted-foreground truncate max-w-md">
|
||||||
|
{decodeURIComponent(filename)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{!isFetching && chunks.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{chunks.length} chunk{chunks.length !== 1 ? "s" : ""} found
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area - matches knowledge page structure */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{isFetching ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50 animate-spin" />
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
Loading chunks...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : chunks.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<p className="text-lg text-muted-foreground">No chunks found</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||||
|
This file may not have been indexed yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 pb-6">
|
||||||
|
{chunks.map((chunk, index) => (
|
||||||
|
<div
|
||||||
|
key={chunk.filename + index}
|
||||||
|
className="bg-muted/20 rounded-lg p-4 border border-border/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{chunk.filename}
|
||||||
|
</span>
|
||||||
|
{chunk.connector_type && (
|
||||||
|
<div className="ml-2">
|
||||||
|
{getSourceIcon(chunk.connector_type)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
|
||||||
|
{chunk.score.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
||||||
|
<span>{chunk.mimetype}</span>
|
||||||
|
<span>Page {chunk.page}</span>
|
||||||
|
{chunk.owner_name && <span>Owner: {chunk.owner_name}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-foreground/90 leading-relaxed">
|
||||||
|
{chunk.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChunksPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50 animate-spin" />
|
||||||
|
<p className="text-lg text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChunksPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedChunksPage() {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChunksPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Cloud,
|
Cloud,
|
||||||
FileText,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
|
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { SiGoogledrive } from "react-icons/si";
|
import { SiGoogledrive } from "react-icons/si";
|
||||||
import { TbBrandOnedrive } from "react-icons/tb";
|
import { TbBrandOnedrive } from "react-icons/tb";
|
||||||
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
|
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
|
||||||
|
|
@ -25,33 +26,46 @@ import { Input } from "@/components/ui/input";
|
||||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
import { useTask } from "@/contexts/task-context";
|
import { useTask } from "@/contexts/task-context";
|
||||||
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
|
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
|
||||||
import { ColDef, RowClickedEvent } from "ag-grid-community";
|
import { ColDef } from "ag-grid-community";
|
||||||
import "@/components/AgGrid/registerAgGridModules";
|
import "@/components/AgGrid/registerAgGridModules";
|
||||||
import "@/components/AgGrid/agGridStyles.css";
|
import "@/components/AgGrid/agGridStyles.css";
|
||||||
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
|
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
|
||||||
|
import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog";
|
||||||
|
import { useDeleteDocument } from "../api/mutations/useDeleteDocument";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// Function to get the appropriate icon for a connector type
|
// Function to get the appropriate icon for a connector type
|
||||||
function getSourceIcon(connectorType?: string) {
|
function getSourceIcon(connectorType?: string) {
|
||||||
switch (connectorType) {
|
switch (connectorType) {
|
||||||
case "google_drive":
|
case "google_drive":
|
||||||
return <SiGoogledrive className="h-4 w-4 text-foreground" />;
|
return (
|
||||||
|
<SiGoogledrive className="h-4 w-4 text-foreground flex-shrink-0" />
|
||||||
|
);
|
||||||
case "onedrive":
|
case "onedrive":
|
||||||
return <TbBrandOnedrive className="h-4 w-4 text-foreground" />;
|
return (
|
||||||
|
<TbBrandOnedrive className="h-4 w-4 text-foreground flex-shrink-0" />
|
||||||
|
);
|
||||||
case "sharepoint":
|
case "sharepoint":
|
||||||
return <Building2 className="h-4 w-4 text-foreground" />;
|
return <Building2 className="h-4 w-4 text-foreground flex-shrink-0" />;
|
||||||
case "s3":
|
case "s3":
|
||||||
return <Cloud className="h-4 w-4 text-foreground" />;
|
return <Cloud className="h-4 w-4 text-foreground flex-shrink-0" />;
|
||||||
default:
|
default:
|
||||||
return <HardDrive className="h-4 w-4 text-muted-foreground" />;
|
return (
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
|
const router = useRouter();
|
||||||
const { isMenuOpen } = useTask();
|
const { isMenuOpen } = useTask();
|
||||||
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter();
|
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [queryInputText, setQueryInputText] = useState("");
|
const [queryInputText, setQueryInputText] = useState("");
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedRows, setSelectedRows] = useState<File[]>([]);
|
||||||
|
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
|
||||||
|
|
||||||
|
const deleteDocumentMutation = useDeleteDocument();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data = [],
|
data = [],
|
||||||
|
|
@ -86,9 +100,22 @@ function SearchPage() {
|
||||||
{
|
{
|
||||||
field: "filename",
|
field: "filename",
|
||||||
headerName: "Source",
|
headerName: "Source",
|
||||||
|
checkboxSelection: true,
|
||||||
|
headerCheckboxSelection: true,
|
||||||
|
initialFlex: 2,
|
||||||
|
minWidth: 220,
|
||||||
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
|
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="flex items-center gap-2 cursor-pointer hover:text-blue-600 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(
|
||||||
|
`/knowledge/chunks?filename=${encodeURIComponent(
|
||||||
|
data?.filename ?? ""
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{getSourceIcon(data?.connector_type)}
|
{getSourceIcon(data?.connector_type)}
|
||||||
<span className="font-medium text-foreground truncate">
|
<span className="font-medium text-foreground truncate">
|
||||||
{value}
|
{value}
|
||||||
|
|
@ -111,12 +138,8 @@ function SearchPage() {
|
||||||
field: "owner",
|
field: "owner",
|
||||||
headerName: "Owner",
|
headerName: "Owner",
|
||||||
valueFormatter: (params) =>
|
valueFormatter: (params) =>
|
||||||
params.value ||
|
params.data?.owner_name || params.data?.owner_email || "—",
|
||||||
params.data?.owner_name ||
|
|
||||||
params.data?.owner_email ||
|
|
||||||
"—",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: "chunkCount",
|
field: "chunkCount",
|
||||||
headerName: "Chunks",
|
headerName: "Chunks",
|
||||||
|
|
@ -133,19 +156,20 @@ function SearchPage() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cellRenderer: () => {
|
cellRenderer: ({ data }: CustomCellRendererProps<File>) => {
|
||||||
return <KnowledgeActionsDropdown />;
|
return <KnowledgeActionsDropdown filename={data?.filename || ""} />;
|
||||||
},
|
},
|
||||||
cellStyle: {
|
cellStyle: {
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
colId: 'actions',
|
colId: "actions",
|
||||||
filter: false,
|
filter: false,
|
||||||
maxWidth: 60,
|
width: 60,
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
|
maxWidth: 60,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
initialFlex: 0,
|
initialFlex: 0,
|
||||||
|
|
@ -153,14 +177,49 @@ function SearchPage() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const defaultColDef: ColDef<File> = {
|
const defaultColDef: ColDef<File> = {
|
||||||
cellStyle: () => ({
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}),
|
|
||||||
initialFlex: 1,
|
|
||||||
minWidth: 100,
|
|
||||||
resizable: false,
|
resizable: false,
|
||||||
suppressMovable: true,
|
suppressMovable: true,
|
||||||
|
initialFlex: 1,
|
||||||
|
minWidth: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectionChanged = useCallback(() => {
|
||||||
|
if (gridRef.current) {
|
||||||
|
const selectedNodes = gridRef.current.api.getSelectedRows();
|
||||||
|
setSelectedRows(selectedNodes);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedRows.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete each file individually since the API expects one filename at a time
|
||||||
|
const deletePromises = selectedRows.map((row) =>
|
||||||
|
deleteDocumentMutation.mutateAsync({ filename: row.filename })
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Successfully deleted ${selectedRows.length} document${
|
||||||
|
selectedRows.length > 1 ? "s" : ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
setSelectedRows([]);
|
||||||
|
setShowBulkDeleteDialog(false);
|
||||||
|
|
||||||
|
// Clear selection in the grid
|
||||||
|
if (gridRef.current) {
|
||||||
|
gridRef.current.api.deselectAll();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to delete some documents"
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -183,6 +242,7 @@ function SearchPage() {
|
||||||
<h2 className="text-lg font-semibold">Project Knowledge</h2>
|
<h2 className="text-lg font-semibold">Project Knowledge</h2>
|
||||||
<KnowledgeDropdown variant="button" />
|
<KnowledgeDropdown variant="button" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Input Area */}
|
{/* Search Input Area */}
|
||||||
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
|
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
|
||||||
<form onSubmit={handleSearch} className="flex gap-3">
|
<form onSubmit={handleSearch} className="flex gap-3">
|
||||||
|
|
@ -194,12 +254,12 @@ function SearchPage() {
|
||||||
value={queryInputText}
|
value={queryInputText}
|
||||||
onChange={(e) => setQueryInputText(e.target.value)}
|
onChange={(e) => setQueryInputText(e.target.value)}
|
||||||
placeholder="Search your documents..."
|
placeholder="Search your documents..."
|
||||||
className="flex-1 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 h-12 focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex-1 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-lg h-12 w-12 p-0 flex-shrink-0"
|
className="rounded-lg p-0 flex-shrink-0"
|
||||||
>
|
>
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|
@ -207,75 +267,70 @@ function SearchPage() {
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* //TODO: Implement sync button */}
|
||||||
|
{/* <Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-lg flex-shrink-0"
|
||||||
|
onClick={() => alert("Not implemented")}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</Button> */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
className="rounded-lg flex-shrink-0"
|
||||||
|
onClick={() => setShowBulkDeleteDialog(true)}
|
||||||
|
disabled={selectedRows.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" /> Delete
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{selectedFile ? (
|
<AgGridReact
|
||||||
// Show chunks for selected file
|
className="w-full overflow-auto"
|
||||||
<>
|
columnDefs={columnDefs}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
defaultColDef={defaultColDef}
|
||||||
<Button
|
loading={isFetching}
|
||||||
variant="ghost"
|
ref={gridRef}
|
||||||
size="sm"
|
rowData={fileResults}
|
||||||
onClick={() => setSelectedFile(null)}
|
rowSelection="multiple"
|
||||||
>
|
rowMultiSelectWithClick={false}
|
||||||
← Back to files
|
suppressRowClickSelection={true}
|
||||||
</Button>
|
getRowId={(params) => params.data.filename}
|
||||||
<span className="text-sm text-muted-foreground">
|
onSelectionChanged={onSelectionChanged}
|
||||||
Chunks from {selectedFile}
|
suppressHorizontalScroll={false}
|
||||||
</span>
|
noRowsOverlayComponent={() => (
|
||||||
|
<div className="text-center">
|
||||||
|
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
No documents found
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70 mt-2">
|
||||||
|
Try adjusting your search terms
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{fileResults
|
)}
|
||||||
.filter((file) => file.filename === selectedFile)
|
/>
|
||||||
.flatMap((file) => file.chunks)
|
|
||||||
.map((chunk, index) => (
|
|
||||||
<div
|
|
||||||
key={chunk.filename + index}
|
|
||||||
className="bg-muted/20 rounded-lg p-4 border border-border/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4 text-blue-400" />
|
|
||||||
<span className="font-medium truncate">
|
|
||||||
{chunk.filename}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
|
|
||||||
{chunk.score.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground mb-2">
|
|
||||||
{chunk.mimetype} • Page {chunk.page}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-foreground/90 leading-relaxed">
|
|
||||||
{chunk.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<AgGridReact
|
|
||||||
columnDefs={columnDefs}
|
|
||||||
defaultColDef={defaultColDef}
|
|
||||||
loading={isFetching}
|
|
||||||
ref={gridRef}
|
|
||||||
rowData={fileResults}
|
|
||||||
onRowClicked={(params: RowClickedEvent<File>) => {
|
|
||||||
setSelectedFile(params.data?.filename ?? "");
|
|
||||||
}}
|
|
||||||
noRowsOverlayComponent={() => (
|
|
||||||
<div className="text-center">
|
|
||||||
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
||||||
<p className="text-lg text-muted-foreground">
|
|
||||||
No documents found
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground/70 mt-2">
|
|
||||||
Try adjusting your search terms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation Dialog */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
open={showBulkDeleteDialog}
|
||||||
|
onOpenChange={setShowBulkDeleteDialog}
|
||||||
|
title="Delete Documents"
|
||||||
|
description={`Are you sure you want to delete ${
|
||||||
|
selectedRows.length
|
||||||
|
} document${
|
||||||
|
selectedRows.length > 1 ? "s" : ""
|
||||||
|
}? This will remove all chunks and data associated with these documents. This action cannot be undone.
|
||||||
|
|
||||||
|
Documents to be deleted:
|
||||||
|
${selectedRows.map((row) => `• ${row.filename}`).join("\n")}`}
|
||||||
|
confirmText="Delete All"
|
||||||
|
onConfirm={handleBulkDelete}
|
||||||
|
isLoading={deleteDocumentMutation.isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,124 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, AlertCircle } from "lucide-react"
|
import { ArrowLeft, AlertCircle } from "lucide-react";
|
||||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
import { GoogleDrivePicker } from "@/components/google-drive-picker";
|
||||||
import { OneDrivePicker } from "@/components/onedrive-picker"
|
import { OneDrivePicker } from "@/components/onedrive-picker";
|
||||||
import { useTask } from "@/contexts/task-context"
|
import { useTask } from "@/contexts/task-context";
|
||||||
import { Toast } from "@/components/ui/toast"
|
import { Toast } from "@/components/ui/toast";
|
||||||
|
|
||||||
interface GoogleDriveFile {
|
interface GoogleDriveFile {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
mimeType: string
|
mimeType: string;
|
||||||
webViewLink?: string
|
webViewLink?: string;
|
||||||
iconLink?: string
|
iconLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OneDriveFile {
|
interface OneDriveFile {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
mimeType?: string
|
mimeType?: string;
|
||||||
webUrl?: string
|
webUrl?: string;
|
||||||
driveItem?: {
|
driveItem?: {
|
||||||
file?: { mimeType: string }
|
file?: { mimeType: string };
|
||||||
folder?: unknown
|
folder?: unknown;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloudConnector {
|
interface CloudConnector {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
status: "not_connected" | "connecting" | "connected" | "error"
|
status: "not_connected" | "connecting" | "connected" | "error";
|
||||||
type: string
|
type: string;
|
||||||
connectionId?: string
|
connectionId?: string;
|
||||||
hasAccessToken: boolean
|
hasAccessToken: boolean;
|
||||||
accessTokenError?: string
|
accessTokenError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UploadProviderPage() {
|
export default function UploadProviderPage() {
|
||||||
const params = useParams()
|
const params = useParams();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const provider = params.provider as string
|
const provider = params.provider as string;
|
||||||
const { addTask, tasks } = useTask()
|
const { addTask, tasks } = useTask();
|
||||||
|
|
||||||
const [connector, setConnector] = useState<CloudConnector | null>(null)
|
const [connector, setConnector] = useState<CloudConnector | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null)
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[] | OneDriveFile[]>([])
|
const [selectedFiles, setSelectedFiles] = useState<
|
||||||
const [isIngesting, setIsIngesting] = useState<boolean>(false)
|
GoogleDriveFile[] | OneDriveFile[]
|
||||||
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(null)
|
>([]);
|
||||||
const [showSuccessToast, setShowSuccessToast] = useState(false)
|
const [isIngesting, setIsIngesting] = useState<boolean>(false);
|
||||||
|
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchConnectorInfo = async () => {
|
const fetchConnectorInfo = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch available connectors to validate the provider
|
// Fetch available connectors to validate the provider
|
||||||
const connectorsResponse = await fetch('/api/connectors')
|
const connectorsResponse = await fetch("/api/connectors");
|
||||||
if (!connectorsResponse.ok) {
|
if (!connectorsResponse.ok) {
|
||||||
throw new Error('Failed to load connectors')
|
throw new Error("Failed to load connectors");
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectorsResult = await connectorsResponse.json()
|
const connectorsResult = await connectorsResponse.json();
|
||||||
const providerInfo = connectorsResult.connectors[provider]
|
const providerInfo = connectorsResult.connectors[provider];
|
||||||
|
|
||||||
if (!providerInfo || !providerInfo.available) {
|
if (!providerInfo || !providerInfo.available) {
|
||||||
setError(`Cloud provider "${provider}" is not available or configured.`)
|
setError(
|
||||||
return
|
`Cloud provider "${provider}" is not available or configured.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check connector status
|
// Check connector status
|
||||||
const statusResponse = await fetch(`/api/connectors/${provider}/status`)
|
const statusResponse = await fetch(
|
||||||
|
`/api/connectors/${provider}/status`
|
||||||
|
);
|
||||||
if (!statusResponse.ok) {
|
if (!statusResponse.ok) {
|
||||||
throw new Error(`Failed to check ${provider} status`)
|
throw new Error(`Failed to check ${provider} status`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusData = await statusResponse.json()
|
const statusData = await statusResponse.json();
|
||||||
const connections = statusData.connections || []
|
const connections = statusData.connections || [];
|
||||||
const activeConnection = connections.find((conn: {is_active: boolean, connection_id: string}) => conn.is_active)
|
const activeConnection = connections.find(
|
||||||
const isConnected = activeConnection !== undefined
|
(conn: { is_active: boolean; connection_id: string }) =>
|
||||||
|
conn.is_active
|
||||||
|
);
|
||||||
|
const isConnected = activeConnection !== undefined;
|
||||||
|
|
||||||
let hasAccessToken = false
|
let hasAccessToken = false;
|
||||||
let accessTokenError: string | undefined = undefined
|
let accessTokenError: string | undefined = undefined;
|
||||||
|
|
||||||
// Try to get access token for connected connectors
|
// Try to get access token for connected connectors
|
||||||
if (isConnected && activeConnection) {
|
if (isConnected && activeConnection) {
|
||||||
try {
|
try {
|
||||||
const tokenResponse = await fetch(`/api/connectors/${provider}/token?connection_id=${activeConnection.connection_id}`)
|
const tokenResponse = await fetch(
|
||||||
|
`/api/connectors/${provider}/token?connection_id=${activeConnection.connection_id}`
|
||||||
|
);
|
||||||
if (tokenResponse.ok) {
|
if (tokenResponse.ok) {
|
||||||
const tokenData = await tokenResponse.json()
|
const tokenData = await tokenResponse.json();
|
||||||
if (tokenData.access_token) {
|
if (tokenData.access_token) {
|
||||||
hasAccessToken = true
|
hasAccessToken = true;
|
||||||
setAccessToken(tokenData.access_token)
|
setAccessToken(tokenData.access_token);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorData = await tokenResponse.json().catch(() => ({ error: 'Token unavailable' }))
|
const errorData = await tokenResponse
|
||||||
accessTokenError = errorData.error || 'Access token unavailable'
|
.json()
|
||||||
|
.catch(() => ({ error: "Token unavailable" }));
|
||||||
|
accessTokenError = errorData.error || "Access token unavailable";
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
accessTokenError = 'Failed to fetch access token'
|
accessTokenError = "Failed to fetch access token";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,61 +130,71 @@ export default function UploadProviderPage() {
|
||||||
type: provider,
|
type: provider,
|
||||||
connectionId: activeConnection?.connection_id,
|
connectionId: activeConnection?.connection_id,
|
||||||
hasAccessToken,
|
hasAccessToken,
|
||||||
accessTokenError
|
accessTokenError,
|
||||||
})
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load connector info:', error)
|
console.error("Failed to load connector info:", error);
|
||||||
setError(error instanceof Error ? error.message : 'Failed to load connector information')
|
setError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load connector information"
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (provider) {
|
if (provider) {
|
||||||
fetchConnectorInfo()
|
fetchConnectorInfo();
|
||||||
}
|
}
|
||||||
}, [provider])
|
}, [provider]);
|
||||||
|
|
||||||
// Watch for sync task completion and redirect
|
// Watch for sync task completion and redirect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSyncTaskId) return
|
if (!currentSyncTaskId) return;
|
||||||
|
|
||||||
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId)
|
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId);
|
||||||
|
|
||||||
if (currentTask && currentTask.status === 'completed') {
|
if (currentTask && currentTask.status === "completed") {
|
||||||
// Task completed successfully, show toast and redirect
|
// Task completed successfully, show toast and redirect
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
setShowSuccessToast(true)
|
setShowSuccessToast(true);
|
||||||
|
|
||||||
|
// Dispatch knowledge updated event to refresh the knowledge table
|
||||||
|
console.log(
|
||||||
|
"Cloud provider task completed, dispatching knowledgeUpdated event"
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/knowledge')
|
router.push("/knowledge");
|
||||||
}, 2000) // 2 second delay to let user see toast
|
}, 2000); // 2 second delay to let user see toast
|
||||||
} else if (currentTask && currentTask.status === 'failed') {
|
} else if (currentTask && currentTask.status === "failed") {
|
||||||
// Task failed, clear the tracking but don't redirect
|
// Task failed, clear the tracking but don't redirect
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
setCurrentSyncTaskId(null)
|
setCurrentSyncTaskId(null);
|
||||||
}
|
}
|
||||||
}, [tasks, currentSyncTaskId, router])
|
}, [tasks, currentSyncTaskId, router]);
|
||||||
|
|
||||||
const handleFileSelected = (files: GoogleDriveFile[] | OneDriveFile[]) => {
|
const handleFileSelected = (files: GoogleDriveFile[] | OneDriveFile[]) => {
|
||||||
setSelectedFiles(files)
|
setSelectedFiles(files);
|
||||||
console.log(`Selected ${files.length} files from ${provider}:`, files)
|
console.log(`Selected ${files.length} files from ${provider}:`, files);
|
||||||
// You can add additional handling here like triggering sync, etc.
|
// You can add additional handling here like triggering sync, etc.
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleGoogleDriveFileSelected = (files: GoogleDriveFile[]) => {
|
const handleGoogleDriveFileSelected = (files: GoogleDriveFile[]) => {
|
||||||
handleFileSelected(files)
|
handleFileSelected(files);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleOneDriveFileSelected = (files: OneDriveFile[]) => {
|
const handleOneDriveFileSelected = (files: OneDriveFile[]) => {
|
||||||
handleFileSelected(files)
|
handleFileSelected(files);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSync = async (connector: CloudConnector) => {
|
const handleSync = async (connector: CloudConnector) => {
|
||||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
if (!connector.connectionId || selectedFiles.length === 0) return;
|
||||||
|
|
||||||
setIsIngesting(true)
|
setIsIngesting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const syncBody: {
|
const syncBody: {
|
||||||
connection_id: string;
|
connection_id: string;
|
||||||
|
|
@ -177,43 +202,43 @@ export default function UploadProviderPage() {
|
||||||
selected_files?: string[];
|
selected_files?: string[];
|
||||||
} = {
|
} = {
|
||||||
connection_id: connector.connectionId,
|
connection_id: connector.connectionId,
|
||||||
selected_files: selectedFiles.map(file => file.id)
|
selected_files: selectedFiles.map(file => file.id),
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
const response = await fetch(`/api/connectors/${connector.type}/sync`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(syncBody),
|
body: JSON.stringify(syncBody),
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.status === 201) {
|
if (response.status === 201) {
|
||||||
const taskIds = result.task_ids
|
const taskIds = result.task_ids;
|
||||||
if (taskIds && taskIds.length > 0) {
|
if (taskIds && taskIds.length > 0) {
|
||||||
const taskId = taskIds[0] // Use the first task ID
|
const taskId = taskIds[0]; // Use the first task ID
|
||||||
addTask(taskId)
|
addTask(taskId);
|
||||||
setCurrentSyncTaskId(taskId)
|
setCurrentSyncTaskId(taskId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Sync failed:', result.error)
|
console.error("Sync failed:", result.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync error:', error)
|
console.error("Sync error:", error);
|
||||||
setIsIngesting(false)
|
setIsIngesting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const getProviderDisplayName = () => {
|
const getProviderDisplayName = () => {
|
||||||
const nameMap: { [key: string]: string } = {
|
const nameMap: { [key: string]: string } = {
|
||||||
'google_drive': 'Google Drive',
|
google_drive: "Google Drive",
|
||||||
'onedrive': 'OneDrive',
|
onedrive: "OneDrive",
|
||||||
'sharepoint': 'SharePoint'
|
sharepoint: "SharePoint",
|
||||||
}
|
};
|
||||||
return nameMap[provider] || provider
|
return nameMap[provider] || provider;
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -225,15 +250,15 @@ export default function UploadProviderPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !connector) {
|
if (error || !connector) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -241,27 +266,29 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">Provider Not Available</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
Provider Not Available
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">{error}</p>
|
<p className="text-muted-foreground mb-4">{error}</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Configure Connectors
|
Configure Connectors
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connector.status !== "connected") {
|
if (connector.status !== "connected") {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -269,29 +296,32 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">{connector.name} Not Connected</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
{connector.name} Not Connected
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
You need to connect your {connector.name} account before you can select files.
|
You need to connect your {connector.name} account before you can
|
||||||
|
select files.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Connect {connector.name}
|
Connect {connector.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connector.hasAccessToken) {
|
if (!connector.hasAccessToken) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
>
|
>
|
||||||
|
|
@ -299,30 +329,30 @@ export default function UploadProviderPage() {
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center max-w-md">
|
<div className="text-center max-w-md">
|
||||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<h2 className="text-xl font-semibold mb-2">Access Token Required</h2>
|
<h2 className="text-xl font-semibold mb-2">
|
||||||
|
Access Token Required
|
||||||
|
</h2>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{connector.accessTokenError || `Unable to get access token for ${connector.name}. Try reconnecting your account.`}
|
{connector.accessTokenError ||
|
||||||
|
`Unable to get access token for ${connector.name}. Try reconnecting your account.`}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => router.push('/settings')}>
|
<Button onClick={() => router.push("/settings")}>
|
||||||
Reconnect {connector.name}
|
Reconnect {connector.name}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-3xl p-6">
|
<div className="container mx-auto max-w-3xl p-6">
|
||||||
<div className="mb-6 flex gap-2 items-center">
|
<div className="mb-6 flex gap-2 items-center">
|
||||||
<Button
|
<Button variant="ghost" onClick={() => router.back()}>
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4 scale-125 mr-2" />
|
<ArrowLeft className="h-4 w-4 scale-125 mr-2" />
|
||||||
</Button>
|
</Button>
|
||||||
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
|
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
|
||||||
|
|
@ -337,7 +367,7 @@ export default function UploadProviderPage() {
|
||||||
accessToken={accessToken || undefined}
|
accessToken={accessToken || undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
|
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
|
||||||
<OneDrivePicker
|
<OneDrivePicker
|
||||||
onFileSelected={handleOneDriveFileSelected}
|
onFileSelected={handleOneDriveFileSelected}
|
||||||
|
|
@ -352,7 +382,7 @@ export default function UploadProviderPage() {
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
<div className="max-w-3xl mx-auto mt-8">
|
<div className="max-w-3xl mx-auto mt-8">
|
||||||
<div className="flex justify-end gap-3 mb-4">
|
<div className="flex justify-end gap-3 mb-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSync(connector)}
|
onClick={() => handleSync(connector)}
|
||||||
disabled={selectedFiles.length === 0 || isIngesting}
|
disabled={selectedFiles.length === 0 || isIngesting}
|
||||||
>
|
>
|
||||||
|
|
@ -365,14 +395,14 @@ export default function UploadProviderPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success toast notification */}
|
{/* Success toast notification */}
|
||||||
<Toast
|
<Toast
|
||||||
message="Ingested successfully!."
|
message="Ingested successfully!."
|
||||||
show={showSuccessToast}
|
show={showSuccessToast}
|
||||||
onHide={() => setShowSuccessToast(false)}
|
onHide={() => setShowSuccessToast(false)}
|
||||||
duration={20000}
|
duration={20000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,30 @@ body {
|
||||||
--ag-wrapper-border: none;
|
--ag-wrapper-border: none;
|
||||||
--ag-font-family: var(--font-sans);
|
--ag-font-family: var(--font-sans);
|
||||||
|
|
||||||
|
/* Checkbox styling */
|
||||||
|
--ag-checkbox-background-color: hsl(var(--background));
|
||||||
|
--ag-checkbox-border-color: hsl(var(--border));
|
||||||
|
--ag-checkbox-checked-color: hsl(var(--primary));
|
||||||
|
--ag-checkbox-unchecked-color: transparent;
|
||||||
|
|
||||||
.ag-header {
|
.ag-header {
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.ag-row {
|
|
||||||
cursor: pointer;
|
/* Make sure checkboxes are visible */
|
||||||
|
.ag-selection-checkbox,
|
||||||
|
.ag-header-select-all {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ag-checkbox-input-wrapper {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ag-checkbox-input-wrapper.ag-checked {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
DateFilterModule,
|
DateFilterModule,
|
||||||
EventApiModule,
|
EventApiModule,
|
||||||
GridStateModule,
|
GridStateModule,
|
||||||
|
RowSelectionModule,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
|
|
||||||
// Importing necessary modules from ag-grid-community
|
// Importing necessary modules from ag-grid-community
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
DateFilterModule,
|
DateFilterModule,
|
||||||
EventApiModule,
|
EventApiModule,
|
||||||
GridStateModule,
|
GridStateModule,
|
||||||
|
RowSelectionModule,
|
||||||
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
|
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
|
||||||
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
|
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const refetchSearch = () => {
|
const refetchSearch = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["search"],
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchTasks = useCallback(async () => {
|
const fetchTasks = useCallback(async () => {
|
||||||
|
|
@ -71,12 +74,12 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
const newTasks = data.tasks || [];
|
const newTasks = data.tasks || [];
|
||||||
|
|
||||||
// Update tasks and check for status changes in the same state update
|
// Update tasks and check for status changes in the same state update
|
||||||
setTasks((prevTasks) => {
|
setTasks(prevTasks => {
|
||||||
// Check for newly completed tasks to show toasts
|
// Check for newly completed tasks to show toasts
|
||||||
if (prevTasks.length > 0) {
|
if (prevTasks.length > 0) {
|
||||||
newTasks.forEach((newTask: Task) => {
|
newTasks.forEach((newTask: Task) => {
|
||||||
const oldTask = prevTasks.find(
|
const oldTask = prevTasks.find(
|
||||||
(t) => t.task_id === newTask.task_id,
|
t => t.task_id === newTask.task_id
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
oldTask &&
|
oldTask &&
|
||||||
|
|
@ -92,6 +95,11 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
refetchSearch();
|
refetchSearch();
|
||||||
|
// Dispatch knowledge updated event for all knowledge-related pages
|
||||||
|
console.log(
|
||||||
|
"Task completed successfully, dispatching knowledgeUpdated event"
|
||||||
|
);
|
||||||
|
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||||
} else if (
|
} else if (
|
||||||
oldTask &&
|
oldTask &&
|
||||||
oldTask.status !== "failed" &&
|
oldTask.status !== "failed" &&
|
||||||
|
|
@ -130,21 +138,19 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const newTasks = data.tasks || [];
|
const newTasks = data.tasks || [];
|
||||||
const foundTask = newTasks.find(
|
const foundTask = newTasks.find(
|
||||||
(task: Task) => task.task_id === taskId,
|
(task: Task) => task.task_id === taskId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundTask) {
|
if (foundTask) {
|
||||||
// Task found! Update the tasks state
|
// Task found! Update the tasks state
|
||||||
setTasks((prevTasks) => {
|
setTasks(prevTasks => {
|
||||||
// Check if task is already in the list
|
// Check if task is already in the list
|
||||||
const exists = prevTasks.some((t) => t.task_id === taskId);
|
const exists = prevTasks.some(t => t.task_id === taskId);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return [...prevTasks, foundTask];
|
return [...prevTasks, foundTask];
|
||||||
}
|
}
|
||||||
// Update existing task
|
// Update existing task
|
||||||
return prevTasks.map((t) =>
|
return prevTasks.map(t => (t.task_id === taskId ? foundTask : t));
|
||||||
t.task_id === taskId ? foundTask : t,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return; // Stop polling, we found it
|
return; // Stop polling, we found it
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +175,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
}, [fetchTasks]);
|
}, [fetchTasks]);
|
||||||
|
|
||||||
const removeTask = useCallback((taskId: string) => {
|
const removeTask = useCallback((taskId: string) => {
|
||||||
setTasks((prev) => prev.filter((task) => task.task_id !== taskId));
|
setTasks(prev => prev.filter(task => task.task_id !== taskId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cancelTask = useCallback(
|
const cancelTask = useCallback(
|
||||||
|
|
@ -196,11 +202,11 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchTasks],
|
[fetchTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleMenu = useCallback(() => {
|
const toggleMenu = useCallback(() => {
|
||||||
setIsMenuOpen((prev) => !prev);
|
setIsMenuOpen(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Periodic polling for task updates
|
// Periodic polling for task updates
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ async def chat_endpoint(request: Request, chat_service, session_manager):
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
user_id = user.user_id
|
user_id = user.user_id
|
||||||
|
|
||||||
# Get JWT token from auth middleware
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
jwt_token = request.state.jwt_token
|
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({"error": "Prompt is required"}, status_code=400)
|
return JSONResponse({"error": "Prompt is required"}, status_code=400)
|
||||||
|
|
@ -76,8 +75,7 @@ async def langflow_endpoint(request: Request, chat_service, session_manager):
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
user_id = user.user_id
|
user_id = user.user_id
|
||||||
|
|
||||||
# Get JWT token from auth middleware
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
jwt_token = request.state.jwt_token
|
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({"error": "Prompt is required"}, status_code=400)
|
return JSONResponse({"error": "Prompt is required"}, status_code=400)
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ async def list_connectors(request: Request, connector_service, session_manager):
|
||||||
)
|
)
|
||||||
return JSONResponse({"connectors": connector_types})
|
return JSONResponse({"connectors": connector_types})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error listing connectors", error=str(e))
|
logger.info("Error listing connectors", error=str(e))
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"connectors": []})
|
||||||
|
|
||||||
|
|
||||||
async def connector_sync(request: Request, connector_service, session_manager):
|
async def connector_sync(request: Request, connector_service, session_manager):
|
||||||
|
|
@ -31,7 +31,7 @@ async def connector_sync(request: Request, connector_service, session_manager):
|
||||||
max_files=max_files,
|
max_files=max_files,
|
||||||
)
|
)
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
# Get all active connections for this connector type and user
|
# Get all active connections for this connector type and user
|
||||||
connections = await connector_service.connection_manager.list_connections(
|
connections = await connector_service.connection_manager.list_connections(
|
||||||
|
|
@ -373,3 +373,5 @@ async def connector_token(request: Request, connector_service, session_manager):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error getting connector token", error=str(e))
|
logger.error("Error getting connector token", error=str(e))
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
59
src/api/documents.py
Normal file
59
src/api/documents.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
from config.settings import INDEX_NAME
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_documents_by_filename(request: Request, document_service, session_manager):
|
||||||
|
"""Delete all documents with a specific filename"""
|
||||||
|
data = await request.json()
|
||||||
|
filename = data.get("filename")
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return JSONResponse({"error": "filename is required"}, status_code=400)
|
||||||
|
|
||||||
|
user = request.state.user
|
||||||
|
jwt_token = request.state.jwt_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get user's OpenSearch client
|
||||||
|
opensearch_client = session_manager.get_user_opensearch_client(
|
||||||
|
user.user_id, jwt_token
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete by query to remove all chunks of this document
|
||||||
|
delete_query = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{"term": {"filename": filename}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await opensearch_client.delete_by_query(
|
||||||
|
index=INDEX_NAME,
|
||||||
|
body=delete_query,
|
||||||
|
conflicts="proceed"
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = result.get("deleted", 0)
|
||||||
|
logger.info(f"Deleted {deleted_count} chunks for filename {filename}", user_id=user.user_id)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"deleted_chunks": deleted_count,
|
||||||
|
"filename": filename,
|
||||||
|
"message": f"All documents with filename '{filename}' deleted successfully"
|
||||||
|
}, status_code=200)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error deleting documents by filename", filename=filename, error=str(e))
|
||||||
|
error_str = str(e)
|
||||||
|
if "AuthenticationException" in error_str:
|
||||||
|
return JSONResponse({"error": "Access denied: insufficient permissions"}, status_code=403)
|
||||||
|
else:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
@ -26,7 +26,7 @@ async def create_knowledge_filter(
|
||||||
return JSONResponse({"error": "Query data is required"}, status_code=400)
|
return JSONResponse({"error": "Query data is required"}, status_code=400)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
# Create knowledge filter document
|
# Create knowledge filter document
|
||||||
filter_id = str(uuid.uuid4())
|
filter_id = str(uuid.uuid4())
|
||||||
|
|
@ -70,7 +70,7 @@ async def search_knowledge_filters(
|
||||||
limit = payload.get("limit", 20)
|
limit = payload.get("limit", 20)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
result = await knowledge_filter_service.search_knowledge_filters(
|
result = await knowledge_filter_service.search_knowledge_filters(
|
||||||
query, user_id=user.user_id, jwt_token=jwt_token, limit=limit
|
query, user_id=user.user_id, jwt_token=jwt_token, limit=limit
|
||||||
|
|
@ -101,7 +101,7 @@ async def get_knowledge_filter(
|
||||||
)
|
)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
result = await knowledge_filter_service.get_knowledge_filter(
|
result = await knowledge_filter_service.get_knowledge_filter(
|
||||||
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
||||||
|
|
@ -136,7 +136,7 @@ async def update_knowledge_filter(
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
# First, get the existing knowledge filter
|
# First, get the existing knowledge filter
|
||||||
existing_result = await knowledge_filter_service.get_knowledge_filter(
|
existing_result = await knowledge_filter_service.get_knowledge_filter(
|
||||||
|
|
@ -205,7 +205,7 @@ async def delete_knowledge_filter(
|
||||||
)
|
)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
result = await knowledge_filter_service.delete_knowledge_filter(
|
result = await knowledge_filter_service.delete_knowledge_filter(
|
||||||
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
||||||
|
|
@ -239,7 +239,7 @@ async def subscribe_to_knowledge_filter(
|
||||||
|
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
# Get the knowledge filter to validate it exists and get its details
|
# Get the knowledge filter to validate it exists and get its details
|
||||||
filter_result = await knowledge_filter_service.get_knowledge_filter(
|
filter_result = await knowledge_filter_service.get_knowledge_filter(
|
||||||
|
|
@ -309,7 +309,7 @@ async def list_knowledge_filter_subscriptions(
|
||||||
)
|
)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
result = await knowledge_filter_service.get_filter_subscriptions(
|
result = await knowledge_filter_service.get_filter_subscriptions(
|
||||||
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
filter_id, user_id=user.user_id, jwt_token=jwt_token
|
||||||
|
|
@ -341,7 +341,7 @@ async def cancel_knowledge_filter_subscription(
|
||||||
)
|
)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
# Get subscription details to find the monitor ID
|
# Get subscription details to find the monitor ID
|
||||||
subscriptions_result = await knowledge_filter_service.get_filter_subscriptions(
|
subscriptions_result = await knowledge_filter_service.get_filter_subscriptions(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ async def nudges_from_kb_endpoint(request: Request, chat_service, session_manage
|
||||||
"""Get nudges for a user"""
|
"""Get nudges for a user"""
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
user_id = user.user_id
|
user_id = user.user_id
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await chat_service.langflow_nudges_chat(
|
result = await chat_service.langflow_nudges_chat(
|
||||||
|
|
@ -28,7 +28,8 @@ async def nudges_from_chat_id_endpoint(request: Request, chat_service, session_m
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
user_id = user.user_id
|
user_id = user.user_id
|
||||||
chat_id = request.path_params["chat_id"]
|
chat_id = request.path_params["chat_id"]
|
||||||
jwt_token = request.state.jwt_token
|
|
||||||
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await chat_service.langflow_nudges_chat(
|
result = await chat_service.langflow_nudges_chat(
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ async def search(request: Request, search_service, session_manager):
|
||||||
) # Optional score threshold, defaults to 0
|
) # Optional score threshold, defaults to 0
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
# Extract JWT token from auth middleware
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
jwt_token = request.state.jwt_token
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Search API request",
|
"Search API request",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ async def upload(request: Request, document_service, session_manager):
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
upload_file = form["file"]
|
upload_file = form["file"]
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
from config.settings import is_no_auth_mode
|
from config.settings import is_no_auth_mode
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ async def upload_path(request: Request, task_service, session_manager):
|
||||||
return JSONResponse({"error": "No files found in directory"}, status_code=400)
|
return JSONResponse({"error": "No files found in directory"}, status_code=400)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
from config.settings import is_no_auth_mode
|
from config.settings import is_no_auth_mode
|
||||||
|
|
||||||
|
|
@ -100,8 +100,7 @@ async def upload_context(
|
||||||
previous_response_id = form.get("previous_response_id")
|
previous_response_id = form.get("previous_response_id")
|
||||||
endpoint = form.get("endpoint", "langflow")
|
endpoint = form.get("endpoint", "langflow")
|
||||||
|
|
||||||
# Get JWT token from auth middleware
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
jwt_token = request.state.jwt_token
|
|
||||||
|
|
||||||
# Get user info from request state (set by auth middleware)
|
# Get user info from request state (set by auth middleware)
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
|
|
@ -169,7 +168,7 @@ async def upload_bucket(request: Request, task_service, session_manager):
|
||||||
return JSONResponse({"error": "No files found in bucket"}, status_code=400)
|
return JSONResponse({"error": "No files found in bucket"}, status_code=400)
|
||||||
|
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
jwt_token = request.state.jwt_token
|
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
|
||||||
|
|
||||||
from models.processors import S3FileProcessor
|
from models.processors import S3FileProcessor
|
||||||
from config.settings import is_no_auth_mode
|
from config.settings import is_no_auth_mode
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ class ConnectionManager:
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_available_connector_types(self) -> Dict[str, Dict[str, str]]:
|
def get_available_connector_types(self) -> Dict[str, Dict[str, Any]]:
|
||||||
"""Get available connector types with their metadata"""
|
"""Get available connector types with their metadata"""
|
||||||
return {
|
return {
|
||||||
"google_drive": {
|
"google_drive": {
|
||||||
|
|
|
||||||
20
src/main.py
20
src/main.py
|
|
@ -30,6 +30,7 @@ from api import (
|
||||||
auth,
|
auth,
|
||||||
chat,
|
chat,
|
||||||
connectors,
|
connectors,
|
||||||
|
documents,
|
||||||
flows,
|
flows,
|
||||||
knowledge_filter,
|
knowledge_filter,
|
||||||
langflow_files,
|
langflow_files,
|
||||||
|
|
@ -58,6 +59,7 @@ from config.settings import (
|
||||||
is_no_auth_mode,
|
is_no_auth_mode,
|
||||||
)
|
)
|
||||||
from services.auth_service import AuthService
|
from services.auth_service import AuthService
|
||||||
|
from services.langflow_mcp_service import LangflowMCPService
|
||||||
from services.chat_service import ChatService
|
from services.chat_service import ChatService
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
|
@ -440,7 +442,11 @@ async def initialize_services():
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize auth service
|
# Initialize auth service
|
||||||
auth_service = AuthService(session_manager, connector_service)
|
auth_service = AuthService(
|
||||||
|
session_manager,
|
||||||
|
connector_service,
|
||||||
|
langflow_mcp_service=LangflowMCPService(),
|
||||||
|
)
|
||||||
|
|
||||||
# Load persisted connector connections at startup so webhooks and syncs
|
# Load persisted connector connections at startup so webhooks and syncs
|
||||||
# can resolve existing subscriptions immediately after server boot
|
# can resolve existing subscriptions immediately after server boot
|
||||||
|
|
@ -876,6 +882,18 @@ async def create_app():
|
||||||
),
|
),
|
||||||
methods=["POST", "GET"],
|
methods=["POST", "GET"],
|
||||||
),
|
),
|
||||||
|
# Document endpoints
|
||||||
|
Route(
|
||||||
|
"/documents/delete-by-filename",
|
||||||
|
require_auth(services["session_manager"])(
|
||||||
|
partial(
|
||||||
|
documents.delete_documents_by_filename,
|
||||||
|
document_service=services["document_service"],
|
||||||
|
session_manager=services["session_manager"],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
# OIDC endpoints
|
# OIDC endpoints
|
||||||
Route(
|
Route(
|
||||||
"/.well-known/openid-configuration",
|
"/.well-known/openid-configuration",
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import httpx
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from config.settings import WEBHOOK_BASE_URL, is_no_auth_mode
|
from config.settings import WEBHOOK_BASE_URL, is_no_auth_mode
|
||||||
from session_manager import SessionManager
|
from session_manager import SessionManager
|
||||||
|
from services.langflow_mcp_service import LangflowMCPService
|
||||||
from connectors.google_drive.oauth import GoogleDriveOAuth
|
from connectors.google_drive.oauth import GoogleDriveOAuth
|
||||||
from connectors.onedrive.oauth import OneDriveOAuth
|
from connectors.onedrive.oauth import OneDriveOAuth
|
||||||
from connectors.sharepoint.oauth import SharePointOAuth
|
from connectors.sharepoint.oauth import SharePointOAuth
|
||||||
|
|
@ -17,10 +19,12 @@ from connectors.sharepoint import SharePointConnector
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
def __init__(self, session_manager: SessionManager, connector_service=None):
|
def __init__(self, session_manager: SessionManager, connector_service=None, langflow_mcp_service: LangflowMCPService | None = None):
|
||||||
self.session_manager = session_manager
|
self.session_manager = session_manager
|
||||||
self.connector_service = connector_service
|
self.connector_service = connector_service
|
||||||
self.used_auth_codes = set() # Track used authorization codes
|
self.used_auth_codes = set() # Track used authorization codes
|
||||||
|
self.langflow_mcp_service = langflow_mcp_service
|
||||||
|
self._background_tasks = set()
|
||||||
|
|
||||||
async def init_oauth(
|
async def init_oauth(
|
||||||
self,
|
self,
|
||||||
|
|
@ -287,6 +291,20 @@ class AuthService:
|
||||||
user_info = await self.session_manager.get_user_info_from_token(
|
user_info = await self.session_manager.get_user_info_from_token(
|
||||||
token_data["access_token"]
|
token_data["access_token"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Best-effort: update Langflow MCP servers to include user's JWT header
|
||||||
|
try:
|
||||||
|
if self.langflow_mcp_service and isinstance(jwt_token, str) and jwt_token.strip():
|
||||||
|
# Run in background to avoid delaying login flow
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self.langflow_mcp_service.update_mcp_servers_with_jwt(jwt_token)
|
||||||
|
)
|
||||||
|
# Keep reference until done to avoid premature GC
|
||||||
|
self._background_tasks.add(task)
|
||||||
|
task.add_done_callback(self._background_tasks.discard)
|
||||||
|
except Exception:
|
||||||
|
# Do not block login on MCP update issues
|
||||||
|
pass
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"status": "authenticated",
|
"status": "authenticated",
|
||||||
|
|
|
||||||
|
|
@ -435,3 +435,4 @@ class DocumentService:
|
||||||
|
|
||||||
if upload_task.processed_files >= upload_task.total_files:
|
if upload_task.processed_files >= upload_task.total_files:
|
||||||
upload_task.status = TaskStatus.COMPLETED
|
upload_task.status = TaskStatus.COMPLETED
|
||||||
|
|
||||||
|
|
|
||||||
147
src/services/langflow_mcp_service.py
Normal file
147
src/services/langflow_mcp_service.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from config.settings import clients
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LangflowMCPService:
|
||||||
|
async def list_mcp_servers(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch list of MCP servers from Langflow (v2 API)."""
|
||||||
|
try:
|
||||||
|
response = await clients.langflow_request(
|
||||||
|
method="GET",
|
||||||
|
endpoint="/api/v2/mcp/servers",
|
||||||
|
params={"action_count": "false"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
logger.warning("Unexpected response format for MCP servers list", data_type=type(data).__name__)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to list MCP servers", error=str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_mcp_server(self, server_name: str) -> Dict[str, Any]:
|
||||||
|
"""Get MCP server configuration by name."""
|
||||||
|
response = await clients.langflow_request(
|
||||||
|
method="GET",
|
||||||
|
endpoint=f"/api/v2/mcp/servers/{server_name}",
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _upsert_jwt_header_in_args(self, args: List[str], jwt_token: str) -> List[str]:
|
||||||
|
"""Ensure args contains a header triplet for X-Langflow-Global-Var-JWT with the provided JWT.
|
||||||
|
|
||||||
|
Args are expected in the pattern: [..., "--headers", key, value, ...].
|
||||||
|
If the header exists, update its value; otherwise append the triplet at the end.
|
||||||
|
"""
|
||||||
|
if not isinstance(args, list):
|
||||||
|
return [
|
||||||
|
"mcp-proxy",
|
||||||
|
"--headers",
|
||||||
|
"X-Langflow-Global-Var-JWT",
|
||||||
|
jwt_token,
|
||||||
|
]
|
||||||
|
|
||||||
|
updated_args = list(args)
|
||||||
|
i = 0
|
||||||
|
found_index = -1
|
||||||
|
while i < len(updated_args):
|
||||||
|
token = updated_args[i]
|
||||||
|
if token == "--headers" and i + 2 < len(updated_args):
|
||||||
|
header_key = updated_args[i + 1]
|
||||||
|
if isinstance(header_key, str) and header_key.lower() == "x-langflow-global-var-jwt".lower():
|
||||||
|
found_index = i
|
||||||
|
break
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if found_index >= 0:
|
||||||
|
# Replace existing value at found_index + 2
|
||||||
|
if found_index + 2 < len(updated_args):
|
||||||
|
updated_args[found_index + 2] = jwt_token
|
||||||
|
else:
|
||||||
|
# Malformed existing header triplet; make sure to append a value
|
||||||
|
updated_args.append(jwt_token)
|
||||||
|
else:
|
||||||
|
updated_args.extend([
|
||||||
|
"--headers",
|
||||||
|
"X-Langflow-Global-Var-JWT",
|
||||||
|
jwt_token,
|
||||||
|
])
|
||||||
|
|
||||||
|
return updated_args
|
||||||
|
|
||||||
|
async def patch_mcp_server_args_with_jwt(self, server_name: str, jwt_token: str) -> bool:
|
||||||
|
"""Patch a single MCP server to include/update the JWT header in args."""
|
||||||
|
try:
|
||||||
|
current = await self.get_mcp_server(server_name)
|
||||||
|
command = current.get("command")
|
||||||
|
args = current.get("args", [])
|
||||||
|
updated_args = self._upsert_jwt_header_in_args(args, jwt_token)
|
||||||
|
|
||||||
|
payload = {"command": command, "args": updated_args}
|
||||||
|
response = await clients.langflow_request(
|
||||||
|
method="PATCH",
|
||||||
|
endpoint=f"/api/v2/mcp/servers/{server_name}",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code in (200, 201):
|
||||||
|
logger.info(
|
||||||
|
"Patched MCP server with JWT header",
|
||||||
|
server_name=server_name,
|
||||||
|
args_len=len(updated_args),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to patch MCP server",
|
||||||
|
server_name=server_name,
|
||||||
|
status_code=response.status_code,
|
||||||
|
body=response.text,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Exception while patching MCP server",
|
||||||
|
server_name=server_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def update_mcp_servers_with_jwt(self, jwt_token: str) -> Dict[str, Any]:
|
||||||
|
"""Fetch all MCP servers and ensure each includes the JWT header in args.
|
||||||
|
|
||||||
|
Returns a summary dict with counts.
|
||||||
|
"""
|
||||||
|
servers = await self.list_mcp_servers()
|
||||||
|
if not servers:
|
||||||
|
return {"updated": 0, "failed": 0, "total": 0}
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
for server in servers:
|
||||||
|
name = server.get("name") or server.get("server") or server.get("id")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
ok = await self.patch_mcp_server_args_with_jwt(name, jwt_token)
|
||||||
|
if ok:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
summary = {"updated": updated, "failed": failed, "total": len(servers)}
|
||||||
|
if failed == 0:
|
||||||
|
logger.info("MCP servers updated with JWT header", **summary)
|
||||||
|
else:
|
||||||
|
logger.warning("MCP servers update had failures", **summary)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -191,26 +191,8 @@ class SessionManager:
|
||||||
|
|
||||||
def get_user_opensearch_client(self, user_id: str, jwt_token: str):
|
def get_user_opensearch_client(self, user_id: str, jwt_token: str):
|
||||||
"""Get or create OpenSearch client for user with their JWT"""
|
"""Get or create OpenSearch client for user with their JWT"""
|
||||||
from config.settings import is_no_auth_mode
|
# Get the effective JWT token (handles anonymous JWT creation)
|
||||||
|
jwt_token = self.get_effective_jwt_token(user_id, jwt_token)
|
||||||
logger.debug(
|
|
||||||
"get_user_opensearch_client",
|
|
||||||
user_id=user_id,
|
|
||||||
jwt_token_present=(jwt_token is not None),
|
|
||||||
no_auth_mode=is_no_auth_mode(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# In no-auth mode, create anonymous JWT for OpenSearch DLS
|
|
||||||
if jwt_token is None and (is_no_auth_mode() or user_id in (None, AnonymousUser().user_id)):
|
|
||||||
if not hasattr(self, "_anonymous_jwt"):
|
|
||||||
# Create anonymous JWT token for OpenSearch OIDC
|
|
||||||
logger.debug("Creating anonymous JWT")
|
|
||||||
self._anonymous_jwt = self._create_anonymous_jwt()
|
|
||||||
logger.debug(
|
|
||||||
"Anonymous JWT created", jwt_prefix=self._anonymous_jwt[:50]
|
|
||||||
)
|
|
||||||
jwt_token = self._anonymous_jwt
|
|
||||||
logger.debug("Using anonymous JWT for OpenSearch")
|
|
||||||
|
|
||||||
# Check if we have a cached client for this user
|
# Check if we have a cached client for this user
|
||||||
if user_id not in self.user_opensearch_clients:
|
if user_id not in self.user_opensearch_clients:
|
||||||
|
|
@ -222,7 +204,32 @@ class SessionManager:
|
||||||
|
|
||||||
return self.user_opensearch_clients[user_id]
|
return self.user_opensearch_clients[user_id]
|
||||||
|
|
||||||
|
def get_effective_jwt_token(self, user_id: str, jwt_token: str) -> str:
|
||||||
|
"""Get the effective JWT token, creating anonymous JWT if needed in no-auth mode"""
|
||||||
|
from config.settings import is_no_auth_mode
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"get_effective_jwt_token",
|
||||||
|
user_id=user_id,
|
||||||
|
jwt_token_present=(jwt_token is not None),
|
||||||
|
no_auth_mode=is_no_auth_mode(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# In no-auth mode, create anonymous JWT if needed
|
||||||
|
if jwt_token is None and (is_no_auth_mode() or user_id in (None, AnonymousUser().user_id)):
|
||||||
|
if not hasattr(self, "_anonymous_jwt"):
|
||||||
|
# Create anonymous JWT token for OpenSearch OIDC
|
||||||
|
logger.debug("Creating anonymous JWT")
|
||||||
|
self._anonymous_jwt = self._create_anonymous_jwt()
|
||||||
|
logger.debug(
|
||||||
|
"Anonymous JWT created", jwt_prefix=self._anonymous_jwt[:50]
|
||||||
|
)
|
||||||
|
jwt_token = self._anonymous_jwt
|
||||||
|
logger.debug("Using anonymous JWT")
|
||||||
|
|
||||||
|
return jwt_token
|
||||||
|
|
||||||
def _create_anonymous_jwt(self) -> str:
|
def _create_anonymous_jwt(self) -> str:
|
||||||
"""Create JWT token for anonymous user in no-auth mode"""
|
"""Create JWT token for anonymous user in no-auth mode"""
|
||||||
anonymous_user = AnonymousUser()
|
anonymous_user = AnonymousUser()
|
||||||
return self.create_jwt_token(anonymous_user)
|
return self.create_jwt_token(anonymous_user)
|
||||||
|
|
@ -91,7 +91,7 @@ services:
|
||||||
langflow:
|
langflow:
|
||||||
volumes:
|
volumes:
|
||||||
- ./flows:/app/flows:Z
|
- ./flows:/app/flows:Z
|
||||||
image: phact/langflow:${LANGFLOW_VERSION:-responses}
|
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
|
||||||
container_name: langflow
|
container_name: langflow
|
||||||
ports:
|
ports:
|
||||||
- "7860:7860"
|
- "7860:7860"
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ services:
|
||||||
langflow:
|
langflow:
|
||||||
volumes:
|
volumes:
|
||||||
- ./flows:/app/flows:Z
|
- ./flows:/app/flows:Z
|
||||||
image: phact/langflow:${LANGFLOW_VERSION:-responses}
|
image: phact/openrag-langflow:${LANGFLOW_VERSION:-latest}
|
||||||
container_name: langflow
|
container_name: langflow
|
||||||
ports:
|
ports:
|
||||||
- "7860:7860"
|
- "7860:7860"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue