Implement Delete Confirmation Dialog and Enhance Knowledge Dropdown
- Added a new `DeleteConfirmationDialog` component for confirming deletions. - Updated `KnowledgeDropdown` to include a loading state and improved user feedback during file operations. - Enhanced the search page to support bulk deletion of documents with confirmation dialog. - Integrated event dispatching for knowledge updates after file operations. - Refactored various components for better readability and maintainability.
This commit is contained in:
parent
05a394a805
commit
f28ba54da3
13 changed files with 949 additions and 293 deletions
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,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
PlugZap,
|
||||
Plus,
|
||||
Upload,
|
||||
|
|
@ -44,6 +44,7 @@ export function KnowledgeDropdown({
|
|||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
const [s3Loading, setS3Loading] = useState(false);
|
||||
const [fileUploading, setFileUploading] = useState(false);
|
||||
const [isNavigatingToCloud, setIsNavigatingToCloud] = useState(false);
|
||||
const [cloudConnectors, setCloudConnectors] = useState<{
|
||||
[key: string]: {
|
||||
name: string;
|
||||
|
|
@ -55,12 +56,6 @@ export function KnowledgeDropdown({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refetchSearch = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
};
|
||||
|
||||
// Check AWS availability and cloud connectors on mount
|
||||
useEffect(() => {
|
||||
const checkAvailability = async () => {
|
||||
|
|
@ -107,7 +102,7 @@ export function KnowledgeDropdown({
|
|||
const connections = statusData.connections || [];
|
||||
const activeConnection = connections.find(
|
||||
(conn: { is_active: boolean; connection_id: string }) =>
|
||||
conn.is_active,
|
||||
conn.is_active
|
||||
);
|
||||
const isConnected = activeConnection !== undefined;
|
||||
|
||||
|
|
@ -117,7 +112,7 @@ export function KnowledgeDropdown({
|
|||
// Check token availability
|
||||
try {
|
||||
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) {
|
||||
const tokenData = await tokenRes.json();
|
||||
|
|
@ -178,7 +173,7 @@ export function KnowledgeDropdown({
|
|||
window.dispatchEvent(
|
||||
new CustomEvent("fileUploadStart", {
|
||||
detail: { filename: files[0].name },
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -190,21 +185,38 @@ export function KnowledgeDropdown({
|
|||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const uploadIngestJson = await uploadIngestRes.json();
|
||||
|
||||
if (!uploadIngestRes.ok) {
|
||||
throw new Error(
|
||||
uploadIngestJson?.error || "Upload and ingest failed",
|
||||
uploadIngestJson?.error || "Upload and ingest failed"
|
||||
);
|
||||
}
|
||||
|
||||
// Extract results from the unified response
|
||||
const fileId = uploadIngestJson?.upload?.id;
|
||||
const filePath = uploadIngestJson?.upload?.path;
|
||||
// Extract results from the response - handle both unified and simple formats
|
||||
const fileId = uploadIngestJson?.upload?.id || uploadIngestJson?.id;
|
||||
const filePath =
|
||||
uploadIngestJson?.upload?.path ||
|
||||
uploadIngestJson?.path ||
|
||||
"uploaded";
|
||||
const runJson = uploadIngestJson?.ingestion;
|
||||
const deleteResult = uploadIngestJson?.deletion;
|
||||
|
||||
if (!fileId || !filePath) {
|
||||
throw new Error("Upload successful but no file id/path returned");
|
||||
if (!fileId) {
|
||||
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
|
||||
|
|
@ -212,12 +224,12 @@ export function KnowledgeDropdown({
|
|||
if (deleteResult.status === "deleted") {
|
||||
console.log(
|
||||
"File successfully cleaned up from Langflow:",
|
||||
deleteResult.file_id,
|
||||
deleteResult.file_id
|
||||
);
|
||||
} else if (deleteResult.status === "delete_failed") {
|
||||
console.warn(
|
||||
"Failed to cleanup file from Langflow:",
|
||||
deleteResult.error,
|
||||
deleteResult.error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -235,8 +247,9 @@ export function KnowledgeDropdown({
|
|||
unified: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger search refresh after successful ingestion
|
||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||
} catch (error) {
|
||||
|
|
@ -246,12 +259,12 @@ export function KnowledgeDropdown({
|
|||
filename: files[0].name,
|
||||
error: error instanceof Error ? error.message : "Upload failed",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
||||
setFileUploading(false);
|
||||
refetchSearch();
|
||||
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,9 +301,15 @@ export function KnowledgeDropdown({
|
|||
addTask(taskId);
|
||||
setFolderPath("");
|
||||
// Trigger search refresh after successful folder processing starts
|
||||
console.log(
|
||||
"Folder upload successful, dispatching knowledgeUpdated event"
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||
} else if (response.ok) {
|
||||
setFolderPath("");
|
||||
console.log(
|
||||
"Folder upload successful (direct), dispatching knowledgeUpdated event"
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||
} else {
|
||||
console.error("Folder upload failed:", result.error);
|
||||
|
|
@ -299,7 +318,7 @@ export function KnowledgeDropdown({
|
|||
console.error("Folder upload error:", error);
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
refetchSearch();
|
||||
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -330,6 +349,7 @@ export function KnowledgeDropdown({
|
|||
addTask(taskId);
|
||||
setBucketUrl("s3://");
|
||||
// Trigger search refresh after successful S3 processing starts
|
||||
console.log("S3 upload successful, dispatching knowledgeUpdated event");
|
||||
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
|
||||
} else {
|
||||
console.error("S3 upload failed:", result.error);
|
||||
|
|
@ -338,7 +358,7 @@ export function KnowledgeDropdown({
|
|||
console.error("S3 upload error:", error);
|
||||
} finally {
|
||||
setS3Loading(false);
|
||||
refetchSearch();
|
||||
// Don't call refetchSearch() here - the knowledgeUpdated event will handle it
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -347,10 +367,17 @@ export function KnowledgeDropdown({
|
|||
.map(([type, info]) => ({
|
||||
label: info.name,
|
||||
icon: PlugZap,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
setIsOpen(false);
|
||||
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 {
|
||||
router.push("/settings");
|
||||
}
|
||||
|
|
@ -392,14 +419,16 @@ export function KnowledgeDropdown({
|
|||
...cloudConnectorItems,
|
||||
];
|
||||
|
||||
// Comprehensive loading state
|
||||
const isLoading =
|
||||
fileUploading || folderLoading || s3Loading || isNavigatingToCloud;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
!(fileUploading || folderLoading || s3Loading) && setIsOpen(!isOpen)
|
||||
}
|
||||
disabled={fileUploading || folderLoading || s3Loading}
|
||||
onClick={() => !isLoading && setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
variant === "button"
|
||||
? "rounded-lg h-12 px-4 flex items-center gap-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
|
@ -408,44 +437,68 @@ export function KnowledgeDropdown({
|
|||
? "bg-accent text-accent-foreground shadow-sm"
|
||||
: variant === "navigation"
|
||||
? "text-foreground hover:text-accent-foreground"
|
||||
: "",
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{variant === "button" ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Knowledge</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
<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">
|
||||
<Upload
|
||||
className={cn(
|
||||
"h-4 w-4 mr-3 shrink-0",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground",
|
||||
)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Upload
|
||||
className={cn(
|
||||
"h-4 w-4 mr-3 shrink-0",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground group-hover:text-foreground"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
Knowledge
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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="py-1">
|
||||
{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",
|
||||
"disabled" in item &&
|
||||
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}
|
||||
|
|
@ -497,7 +550,7 @@ export function KnowledgeDropdown({
|
|||
type="text"
|
||||
placeholder="/path/to/documents"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
onChange={e => setFolderPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
@ -539,7 +592,7 @@ export function KnowledgeDropdown({
|
|||
type="text"
|
||||
placeholder="s3://bucket/path"
|
||||
value={bucketUrl}
|
||||
onChange={(e) => setBucketUrl(e.target.value)}
|
||||
onChange={e => setBucketUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
icon?: React.ReactNode;
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
|
@ -9,7 +10,12 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
|
|||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, inputClassName, icon, type, placeholder, ...props }, ref) => {
|
||||
return (
|
||||
<label className={cn("relative block h-fit w-full text-sm", icon ? className : "")}>
|
||||
<label
|
||||
className={cn(
|
||||
"relative block h-fit w-full text-sm",
|
||||
icon ? className : ""
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted-foreground">
|
||||
{icon}
|
||||
|
|
@ -22,7 +28,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
className={cn(
|
||||
"primary-input !placeholder-transparent",
|
||||
icon && "pl-9",
|
||||
icon ? inputClassName : className,
|
||||
icon ? inputClassName : className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
|
@ -31,14 +37,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground",
|
||||
icon ? "left-9" : "left-3",
|
||||
props.value && "hidden",
|
||||
props.value && "hidden"
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const DEFAULT_NUDGES = [
|
|||
|
||||
export const useGetNudgesQuery = (
|
||||
chatId?: string | null,
|
||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -26,7 +26,12 @@ export const useGetNudgesQuery = (
|
|||
try {
|
||||
const response = await fetch(`/api/nudges${chatId ? `/${chatId}` : ""}`);
|
||||
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) {
|
||||
console.error("Error getting nudges", error);
|
||||
return DEFAULT_NUDGES;
|
||||
|
|
@ -39,7 +44,7 @@ export const useGetNudgesQuery = (
|
|||
queryFn: getNudges,
|
||||
...options,
|
||||
},
|
||||
queryClient,
|
||||
queryClient
|
||||
);
|
||||
|
||||
return { ...queryResult, cancel };
|
||||
|
|
|
|||
|
|
@ -48,15 +48,22 @@ export interface File {
|
|||
export const useGetSearchQuery = (
|
||||
query: string,
|
||||
queryData?: ParsedQueryData | null,
|
||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">,
|
||||
options?: Omit<UseQueryOptions, "queryKey" | "queryFn">
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Normalize the query to match what will actually be searched
|
||||
const effectiveQuery = query || queryData?.query || "*";
|
||||
|
||||
async function getFiles(): Promise<File[]> {
|
||||
try {
|
||||
const searchPayload: SearchPayload = {
|
||||
query: query || queryData?.query || "*",
|
||||
limit: queryData?.limit || (query.trim() === "" ? 10000 : 10), // Maximum allowed limit for wildcard searches
|
||||
query: effectiveQuery,
|
||||
limit:
|
||||
queryData?.limit ||
|
||||
(effectiveQuery.trim() === "*" || effectiveQuery.trim() === ""
|
||||
? 10000
|
||||
: 10), // Maximum allowed limit for wildcard searches
|
||||
scoreThreshold: queryData?.scoreThreshold || 0,
|
||||
};
|
||||
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,
|
||||
mimetype: file.mimetype,
|
||||
chunkCount: file.chunks.length,
|
||||
|
|
@ -165,12 +172,12 @@ export const useGetSearchQuery = (
|
|||
|
||||
const queryResult = useQuery(
|
||||
{
|
||||
queryKey: ["search", query],
|
||||
placeholderData: (prev) => prev,
|
||||
queryKey: ["search", effectiveQuery],
|
||||
placeholderData: prev => prev,
|
||||
queryFn: getFiles,
|
||||
...options,
|
||||
},
|
||||
queryClient,
|
||||
queryClient
|
||||
);
|
||||
|
||||
return queryResult;
|
||||
|
|
|
|||
|
|
@ -7,17 +7,35 @@ import {
|
|||
HardDrive,
|
||||
Loader2,
|
||||
Search,
|
||||
Trash2,
|
||||
Edit,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SiGoogledrive } from "react-icons/si";
|
||||
import { TbBrandOnedrive } from "react-icons/tb";
|
||||
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
|
||||
import { FaEllipsisVertical } from "react-icons/fa6";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ProtectedRoute } from "@/components/protected-route";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog";
|
||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||
import { useTask } from "@/contexts/task-context";
|
||||
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
|
||||
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
|
||||
|
||||
// Function to get the appropriate icon for a connector type
|
||||
function getSourceIcon(connectorType?: string) {
|
||||
|
|
@ -41,12 +59,32 @@ function SearchPage() {
|
|||
const [query, setQuery] = useState("");
|
||||
const [queryInputText, setQueryInputText] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data = [],
|
||||
isFetching,
|
||||
refetch: refetchSearch,
|
||||
} = useGetSearchQuery(query, parsedFilterData);
|
||||
// Delete state
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: "bulk" | "single";
|
||||
filenames: string[];
|
||||
} | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data = [], isFetching } = useGetSearchQuery(query, parsedFilterData);
|
||||
|
||||
// Use refs to access current values in event handler
|
||||
const currentQueryRef = useRef(query);
|
||||
const currentParsedFilterRef = useRef(parsedFilterData);
|
||||
const currentDataRef = useRef(data);
|
||||
|
||||
currentQueryRef.current = query;
|
||||
currentParsedFilterRef.current = parsedFilterData;
|
||||
currentDataRef.current = data;
|
||||
|
||||
// Update query when global filter changes
|
||||
useEffect(() => {
|
||||
|
|
@ -55,18 +93,203 @@ function SearchPage() {
|
|||
}
|
||||
}, [parsedFilterData]);
|
||||
|
||||
// Listen for knowledge updates from other sources (uploads, ingestion, etc.)
|
||||
useEffect(() => {
|
||||
const handleKnowledgeUpdate = async () => {
|
||||
// Get the current effective query that matches what the UI is showing (using refs to get current values)
|
||||
const currentEffectiveQuery =
|
||||
currentQueryRef.current || currentParsedFilterRef.current?.query || "*";
|
||||
|
||||
// Be very aggressive about clearing the cache and refetching
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["search"],
|
||||
exact: false,
|
||||
});
|
||||
|
||||
// Force an immediate refetch of the current query
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ["search", currentEffectiveQuery],
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Also trigger a state change to force re-render (backup plan)
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
window.addEventListener("knowledgeUpdated", handleKnowledgeUpdate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("knowledgeUpdated", handleKnowledgeUpdate);
|
||||
};
|
||||
}, [queryClient]); // Only depend on queryClient which is stable
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(e?: FormEvent<HTMLFormElement>) => {
|
||||
if (e) e.preventDefault();
|
||||
if (query.trim() === queryInputText.trim()) {
|
||||
refetchSearch();
|
||||
// If same query, invalidate cache to ensure fresh data
|
||||
const effectiveQuery =
|
||||
currentQueryRef.current ||
|
||||
currentParsedFilterRef.current?.query ||
|
||||
"*";
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["search", effectiveQuery],
|
||||
exact: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setQuery(queryInputText);
|
||||
},
|
||||
[queryInputText, refetchSearch, query],
|
||||
[queryInputText, query, queryClient]
|
||||
);
|
||||
|
||||
// Delete handlers
|
||||
const handleBulkDelete = () => {
|
||||
const filenames = Array.from(selectedDocuments);
|
||||
setDeleteTarget({
|
||||
type: "bulk",
|
||||
filenames,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSingleDelete = (filename: string) => {
|
||||
setDeleteTarget({
|
||||
type: "single",
|
||||
filenames: [filename],
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
setOpenDropdown(null); // Close the dropdown
|
||||
};
|
||||
|
||||
const handleRename = (filename: string) => {
|
||||
setOpenDropdown(null); // Close the dropdown
|
||||
alert(`Rename functionality not implemented yet for ${filename}`);
|
||||
};
|
||||
|
||||
const handleSync = (filename: string) => {
|
||||
setOpenDropdown(null); // Close the dropdown
|
||||
alert(`Sync functionality not implemented yet for ${filename}`);
|
||||
};
|
||||
|
||||
const performDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
// Use the same effective query normalization as the search hook (using current refs)
|
||||
const effectiveQuery =
|
||||
currentQueryRef.current || currentParsedFilterRef.current?.query || "*";
|
||||
|
||||
// Store the original data before optimistic update
|
||||
const originalData =
|
||||
queryClient.getQueryData<File[]>(["search", effectiveQuery]) || [];
|
||||
const filesToDelete = new Set(deleteTarget.filenames);
|
||||
|
||||
// Optimistically update the UI - immediately filter out the files being deleted
|
||||
queryClient.setQueryData<File[]>(["search", effectiveQuery], oldData => {
|
||||
if (!oldData) return [];
|
||||
return oldData.filter(file => !filesToDelete.has(file.filename));
|
||||
});
|
||||
|
||||
try {
|
||||
// Delete documents by filename (since we only have filenames, not document IDs)
|
||||
const filenames = deleteTarget.filenames;
|
||||
const results = [];
|
||||
let successCount = 0;
|
||||
const failedDeletes: string[] = [];
|
||||
|
||||
for (const filename of filenames) {
|
||||
try {
|
||||
const response = await fetch("/api/documents/delete-by-filename", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: filename,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
results.push({ filename, success: true, ...result });
|
||||
successCount++;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
results.push({ filename, success: false, error: error.error });
|
||||
failedDeletes.push(filename);
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({ filename, success: false, error: String(error) });
|
||||
failedDeletes.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
// Log results for user feedback
|
||||
console.info(`Deleted ${successCount}/${filenames.length} documents`);
|
||||
|
||||
// If any deletes failed, restore the failed items to the UI
|
||||
if (failedDeletes.length > 0) {
|
||||
console.warn(
|
||||
`Failed to delete ${failedDeletes.length} documents:`,
|
||||
failedDeletes
|
||||
);
|
||||
|
||||
// Restore failed items using the original data
|
||||
queryClient.setQueryData<File[]>(
|
||||
["search", effectiveQuery],
|
||||
currentData => {
|
||||
if (!currentData) return originalData;
|
||||
|
||||
const failedSet = new Set(failedDeletes);
|
||||
const restoredItems = originalData.filter(file =>
|
||||
failedSet.has(file.filename)
|
||||
);
|
||||
|
||||
// Merge current optimistically updated data with restored failed items
|
||||
return [...currentData, ...restoredItems];
|
||||
}
|
||||
);
|
||||
}
|
||||
// If all deletes succeeded, keep the optimistic update - no need to refetch
|
||||
|
||||
// Clear selection
|
||||
setSelectedDocuments(new Set());
|
||||
} catch (error) {
|
||||
console.error("Delete failed:", error);
|
||||
// Restore the original data on complete failure
|
||||
queryClient.setQueryData<File[]>(
|
||||
["search", effectiveQuery],
|
||||
originalData
|
||||
);
|
||||
// TODO: Add toast notification for error
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDocumentSelection = (filename: string) => {
|
||||
const newSelection = new Set(selectedDocuments);
|
||||
if (newSelection.has(filename)) {
|
||||
newSelection.delete(filename);
|
||||
} else {
|
||||
newSelection.add(filename);
|
||||
}
|
||||
setSelectedDocuments(newSelection);
|
||||
};
|
||||
|
||||
const selectAllDocuments = () => {
|
||||
const allFilenames = new Set(fileResults.map(file => file.filename));
|
||||
setSelectedDocuments(allFilenames);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedDocuments(new Set());
|
||||
};
|
||||
|
||||
const fileResults = data as File[];
|
||||
|
||||
return (
|
||||
|
|
@ -87,17 +310,25 @@ function SearchPage() {
|
|||
<div className="flex-1 flex flex-col min-h-0 px-6 py-6">
|
||||
{/* Search Input Area */}
|
||||
<div className="flex-shrink-0 mb-6">
|
||||
<div className="flex items-center gap-2 mb-5 justify-between">
|
||||
<span className="text-xl font-semibold text-primary">
|
||||
Knowledge Project
|
||||
</span>
|
||||
<KnowledgeDropdown variant="button" />
|
||||
</div>
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<Input
|
||||
name="search-query"
|
||||
id="search-query"
|
||||
type="text"
|
||||
defaultValue={parsedFilterData?.query}
|
||||
value={queryInputText}
|
||||
onChange={(e) => setQueryInputText(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<div className="lg:w-1/2 w-full">
|
||||
<Input
|
||||
name="search-query"
|
||||
id="search-query"
|
||||
type="text"
|
||||
defaultValue={parsedFilterData?.query}
|
||||
value={queryInputText}
|
||||
onChange={e => setQueryInputText(e.target.value)}
|
||||
placeholder="Search your documents..."
|
||||
className="flex-2 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 h-12 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
|
|
@ -109,9 +340,22 @@ function SearchPage() {
|
|||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-shrink-0">
|
||||
<KnowledgeDropdown variant="button" />
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="rounded-lg h-12 px-4 flex items-center gap-2 hover:bg-secondary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => alert("Sync not implemented yet")}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={selectedDocuments.size === 0}
|
||||
className="rounded-lg h-12 px-4 flex items-center gap-2 hover:bg-destructive/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -130,8 +374,8 @@ function SearchPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Results Count */}
|
||||
<div className="mb-4">
|
||||
{/* Results Count and Bulk Actions */}
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{fileResults.length} file
|
||||
{fileResults.length !== 1 ? "s" : ""} found
|
||||
|
|
@ -158,8 +402,8 @@ function SearchPage() {
|
|||
</span>
|
||||
</div>
|
||||
{fileResults
|
||||
.filter((file) => file.filename === selectedFile)
|
||||
.flatMap((file) => file.chunks)
|
||||
.filter(file => file.filename === selectedFile)
|
||||
.flatMap(file => file.chunks)
|
||||
.map((chunk, index) => (
|
||||
<div
|
||||
key={chunk.filename + index}
|
||||
|
|
@ -187,40 +431,94 @@ function SearchPage() {
|
|||
</>
|
||||
) : (
|
||||
// Show files table
|
||||
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-x-auto">
|
||||
<table className="w-full min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/10">
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground pl-5 w-12">
|
||||
<Checkbox
|
||||
className="w-4 h-4 flex"
|
||||
checked={
|
||||
selectedDocuments.size > 0 &&
|
||||
selectedDocuments.size ===
|
||||
new Set(fileResults.map(f => f.filename))
|
||||
.size
|
||||
}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
selectAllDocuments();
|
||||
} else {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground min-w-[200px] max-w-[300px]">
|
||||
Source
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-32">
|
||||
Type
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-20">
|
||||
Size
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
Matching chunks
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-20">
|
||||
Chunks
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
Average score
|
||||
</th>
|
||||
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
|
||||
Owner
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-20">
|
||||
Score
|
||||
</th>
|
||||
|
||||
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fileResults.map((file) => (
|
||||
{fileResults.map(file => (
|
||||
<tr
|
||||
key={file.filename}
|
||||
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
className="border-b border-border/30 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{getSourceIcon(file.connector_type)}
|
||||
<td className="pl-5 w-12">
|
||||
<div
|
||||
className="w-4 h-4 cursor-pointer group relative flex items-center justify-center"
|
||||
onClick={() =>
|
||||
toggleDocumentSelection(file.filename)
|
||||
}
|
||||
>
|
||||
{selectedDocuments.has(file.filename) ? (
|
||||
<Checkbox
|
||||
checked={true}
|
||||
onCheckedChange={() =>
|
||||
toggleDocumentSelection(file.filename)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="group-hover:opacity-0 transition-opacity duration-200">
|
||||
{getSourceIcon(file.connector_type)}
|
||||
</div>
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() =>
|
||||
toggleDocumentSelection(
|
||||
file.filename
|
||||
)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="p-3 cursor-pointer min-w-[200px] max-w-[300px]"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
title={file.filename}
|
||||
|
|
@ -229,27 +527,90 @@ function SearchPage() {
|
|||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{file.mimetype}
|
||||
<td
|
||||
className="p-3 text-center text-sm text-muted-foreground cursor-pointer w-32"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
>
|
||||
<span className="truncate block">
|
||||
{file.mimetype}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
<td
|
||||
className="p-3 text-center text-sm text-muted-foreground cursor-pointer w-20"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
>
|
||||
{file.size
|
||||
? `${Math.round(file.size / 1024)} KB`
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
<td
|
||||
className="p-3 text-center text-sm text-muted-foreground cursor-pointer w-20"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
>
|
||||
{file.chunkCount}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<td
|
||||
className="p-3 text-center cursor-pointer w-20"
|
||||
onClick={() => setSelectedFile(file.filename)}
|
||||
>
|
||||
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
|
||||
{file.avgScore.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="p-3 text-sm text-muted-foreground"
|
||||
title={file.owner_email}
|
||||
>
|
||||
{file.owner_name || file.owner || "—"}
|
||||
|
||||
<td className="p-3 text-center text-sm text-muted-foreground w-12">
|
||||
<Popover
|
||||
open={openDropdown === file.filename}
|
||||
onOpenChange={open =>
|
||||
setOpenDropdown(open ? file.filename : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<FaEllipsisVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-40 p-0"
|
||||
align="end"
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
handleRename(file.filename)
|
||||
}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
handleSync(file.filename)
|
||||
}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Sync</span>
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
handleSingleDelete(file.filename)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -263,6 +624,32 @@ function SearchPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmationDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={
|
||||
deleteTarget?.type === "single"
|
||||
? `Delete "${deleteTarget.filenames[0]}"?`
|
||||
: `Delete ${deleteTarget?.filenames?.length || 0} documents?`
|
||||
}
|
||||
description={
|
||||
deleteTarget?.type === "single"
|
||||
? `Are you sure you want to delete "${deleteTarget.filenames[0]}"? This action cannot be undone and will remove all associated chunks.`
|
||||
: `Are you sure you want to delete ${
|
||||
deleteTarget?.filenames?.length || 0
|
||||
} selected documents? This action cannot be undone and will remove all associated chunks.`
|
||||
}
|
||||
confirmText={
|
||||
deleteTarget?.type === "single"
|
||||
? "Delete File"
|
||||
: "Delete All Selected"
|
||||
}
|
||||
onConfirm={performDelete}
|
||||
isLoading={isDeleting}
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +1,124 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft, AlertCircle } from "lucide-react"
|
||||
import { GoogleDrivePicker } from "@/components/google-drive-picker"
|
||||
import { OneDrivePicker } from "@/components/onedrive-picker"
|
||||
import { useTask } from "@/contexts/task-context"
|
||||
import { Toast } from "@/components/ui/toast"
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, AlertCircle } from "lucide-react";
|
||||
import { GoogleDrivePicker } from "@/components/google-drive-picker";
|
||||
import { OneDrivePicker } from "@/components/onedrive-picker";
|
||||
import { useTask } from "@/contexts/task-context";
|
||||
import { Toast } from "@/components/ui/toast";
|
||||
|
||||
interface GoogleDriveFile {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webViewLink?: string
|
||||
iconLink?: string
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
webViewLink?: string;
|
||||
iconLink?: string;
|
||||
}
|
||||
|
||||
interface OneDriveFile {
|
||||
id: string
|
||||
name: string
|
||||
mimeType?: string
|
||||
webUrl?: string
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
webUrl?: string;
|
||||
driveItem?: {
|
||||
file?: { mimeType: string }
|
||||
folder?: unknown
|
||||
}
|
||||
file?: { mimeType: string };
|
||||
folder?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface CloudConnector {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: "not_connected" | "connecting" | "connected" | "error"
|
||||
type: string
|
||||
connectionId?: string
|
||||
hasAccessToken: boolean
|
||||
accessTokenError?: string
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "not_connected" | "connecting" | "connected" | "error";
|
||||
type: string;
|
||||
connectionId?: string;
|
||||
hasAccessToken: boolean;
|
||||
accessTokenError?: string;
|
||||
}
|
||||
|
||||
export default function UploadProviderPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const provider = params.provider as string
|
||||
const { addTask, tasks } = useTask()
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const provider = params.provider as string;
|
||||
const { addTask, tasks } = useTask();
|
||||
|
||||
const [connector, setConnector] = useState<CloudConnector | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null)
|
||||
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[] | OneDriveFile[]>([])
|
||||
const [isIngesting, setIsIngesting] = useState<boolean>(false)
|
||||
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(null)
|
||||
const [showSuccessToast, setShowSuccessToast] = useState(false)
|
||||
const [connector, setConnector] = useState<CloudConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState<
|
||||
GoogleDriveFile[] | OneDriveFile[]
|
||||
>([]);
|
||||
const [isIngesting, setIsIngesting] = useState<boolean>(false);
|
||||
const [currentSyncTaskId, setCurrentSyncTaskId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [showSuccessToast, setShowSuccessToast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnectorInfo = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch available connectors to validate the provider
|
||||
const connectorsResponse = await fetch('/api/connectors')
|
||||
const connectorsResponse = await fetch("/api/connectors");
|
||||
if (!connectorsResponse.ok) {
|
||||
throw new Error('Failed to load connectors')
|
||||
throw new Error("Failed to load connectors");
|
||||
}
|
||||
|
||||
const connectorsResult = await connectorsResponse.json()
|
||||
const providerInfo = connectorsResult.connectors[provider]
|
||||
const connectorsResult = await connectorsResponse.json();
|
||||
const providerInfo = connectorsResult.connectors[provider];
|
||||
|
||||
if (!providerInfo || !providerInfo.available) {
|
||||
setError(`Cloud provider "${provider}" is not available or configured.`)
|
||||
return
|
||||
setError(
|
||||
`Cloud provider "${provider}" is not available or configured.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check connector status
|
||||
const statusResponse = await fetch(`/api/connectors/${provider}/status`)
|
||||
const statusResponse = await fetch(
|
||||
`/api/connectors/${provider}/status`
|
||||
);
|
||||
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 connections = statusData.connections || []
|
||||
const activeConnection = connections.find((conn: {is_active: boolean, connection_id: string}) => conn.is_active)
|
||||
const isConnected = activeConnection !== undefined
|
||||
const statusData = await statusResponse.json();
|
||||
const connections = statusData.connections || [];
|
||||
const activeConnection = connections.find(
|
||||
(conn: { is_active: boolean; connection_id: string }) =>
|
||||
conn.is_active
|
||||
);
|
||||
const isConnected = activeConnection !== undefined;
|
||||
|
||||
let hasAccessToken = false
|
||||
let accessTokenError: string | undefined = undefined
|
||||
let hasAccessToken = false;
|
||||
let accessTokenError: string | undefined = undefined;
|
||||
|
||||
// Try to get access token for connected connectors
|
||||
if (isConnected && activeConnection) {
|
||||
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) {
|
||||
const tokenData = await tokenResponse.json()
|
||||
const tokenData = await tokenResponse.json();
|
||||
if (tokenData.access_token) {
|
||||
hasAccessToken = true
|
||||
setAccessToken(tokenData.access_token)
|
||||
hasAccessToken = true;
|
||||
setAccessToken(tokenData.access_token);
|
||||
}
|
||||
} else {
|
||||
const errorData = await tokenResponse.json().catch(() => ({ error: 'Token unavailable' }))
|
||||
accessTokenError = errorData.error || 'Access token unavailable'
|
||||
const errorData = await tokenResponse
|
||||
.json()
|
||||
.catch(() => ({ error: "Token unavailable" }));
|
||||
accessTokenError = errorData.error || "Access token unavailable";
|
||||
}
|
||||
} catch {
|
||||
accessTokenError = 'Failed to fetch access token'
|
||||
accessTokenError = "Failed to fetch access token";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,61 +130,71 @@ export default function UploadProviderPage() {
|
|||
type: provider,
|
||||
connectionId: activeConnection?.connection_id,
|
||||
hasAccessToken,
|
||||
accessTokenError
|
||||
})
|
||||
|
||||
accessTokenError,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load connector info:', error)
|
||||
setError(error instanceof Error ? error.message : 'Failed to load connector information')
|
||||
console.error("Failed to load connector info:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load connector information"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (provider) {
|
||||
fetchConnectorInfo()
|
||||
fetchConnectorInfo();
|
||||
}
|
||||
}, [provider])
|
||||
}, [provider]);
|
||||
|
||||
// Watch for sync task completion and redirect
|
||||
useEffect(() => {
|
||||
if (!currentSyncTaskId) return
|
||||
|
||||
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId)
|
||||
|
||||
if (currentTask && currentTask.status === 'completed') {
|
||||
if (!currentSyncTaskId) return;
|
||||
|
||||
const currentTask = tasks.find(task => task.task_id === currentSyncTaskId);
|
||||
|
||||
if (currentTask && currentTask.status === "completed") {
|
||||
// Task completed successfully, show toast and redirect
|
||||
setIsIngesting(false)
|
||||
setShowSuccessToast(true)
|
||||
setIsIngesting(false);
|
||||
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(() => {
|
||||
router.push('/knowledge')
|
||||
}, 2000) // 2 second delay to let user see toast
|
||||
} else if (currentTask && currentTask.status === 'failed') {
|
||||
router.push("/knowledge");
|
||||
}, 2000); // 2 second delay to let user see toast
|
||||
} else if (currentTask && currentTask.status === "failed") {
|
||||
// Task failed, clear the tracking but don't redirect
|
||||
setIsIngesting(false)
|
||||
setCurrentSyncTaskId(null)
|
||||
setIsIngesting(false);
|
||||
setCurrentSyncTaskId(null);
|
||||
}
|
||||
}, [tasks, currentSyncTaskId, router])
|
||||
}, [tasks, currentSyncTaskId, router]);
|
||||
|
||||
const handleFileSelected = (files: GoogleDriveFile[] | OneDriveFile[]) => {
|
||||
setSelectedFiles(files)
|
||||
console.log(`Selected ${files.length} files from ${provider}:`, files)
|
||||
setSelectedFiles(files);
|
||||
console.log(`Selected ${files.length} files from ${provider}:`, files);
|
||||
// You can add additional handling here like triggering sync, etc.
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleDriveFileSelected = (files: GoogleDriveFile[]) => {
|
||||
handleFileSelected(files)
|
||||
}
|
||||
handleFileSelected(files);
|
||||
};
|
||||
|
||||
const handleOneDriveFileSelected = (files: OneDriveFile[]) => {
|
||||
handleFileSelected(files)
|
||||
}
|
||||
handleFileSelected(files);
|
||||
};
|
||||
|
||||
const handleSync = async (connector: CloudConnector) => {
|
||||
if (!connector.connectionId || selectedFiles.length === 0) return
|
||||
|
||||
setIsIngesting(true)
|
||||
|
||||
if (!connector.connectionId || selectedFiles.length === 0) return;
|
||||
|
||||
setIsIngesting(true);
|
||||
|
||||
try {
|
||||
const syncBody: {
|
||||
connection_id: string;
|
||||
|
|
@ -177,43 +202,43 @@ export default function UploadProviderPage() {
|
|||
selected_files?: string[];
|
||||
} = {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(syncBody),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.status === 201) {
|
||||
const taskIds = result.task_ids
|
||||
const taskIds = result.task_ids;
|
||||
if (taskIds && taskIds.length > 0) {
|
||||
const taskId = taskIds[0] // Use the first task ID
|
||||
addTask(taskId)
|
||||
setCurrentSyncTaskId(taskId)
|
||||
const taskId = taskIds[0]; // Use the first task ID
|
||||
addTask(taskId);
|
||||
setCurrentSyncTaskId(taskId);
|
||||
}
|
||||
} else {
|
||||
console.error('Sync failed:', result.error)
|
||||
console.error("Sync failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
setIsIngesting(false)
|
||||
console.error("Sync error:", error);
|
||||
setIsIngesting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderDisplayName = () => {
|
||||
const nameMap: { [key: string]: string } = {
|
||||
'google_drive': 'Google Drive',
|
||||
'onedrive': 'OneDrive',
|
||||
'sharepoint': 'SharePoint'
|
||||
}
|
||||
return nameMap[provider] || provider
|
||||
}
|
||||
google_drive: "Google Drive",
|
||||
onedrive: "OneDrive",
|
||||
sharepoint: "SharePoint",
|
||||
};
|
||||
return nameMap[provider] || provider;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -225,15 +250,15 @@ export default function UploadProviderPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !connector) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -241,27 +266,29 @@ export default function UploadProviderPage() {
|
|||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center max-w-md">
|
||||
<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>
|
||||
<Button onClick={() => router.push('/settings')}>
|
||||
<Button onClick={() => router.push("/settings")}>
|
||||
Configure Connectors
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (connector.status !== "connected") {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -269,29 +296,32 @@ export default function UploadProviderPage() {
|
|||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center max-w-md">
|
||||
<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">
|
||||
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>
|
||||
<Button onClick={() => router.push('/settings')}>
|
||||
<Button onClick={() => router.push("/settings")}>
|
||||
Connect {connector.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!connector.hasAccessToken) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
className="mb-4"
|
||||
>
|
||||
|
|
@ -299,30 +329,30 @@ export default function UploadProviderPage() {
|
|||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center max-w-md">
|
||||
<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">
|
||||
{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>
|
||||
<Button onClick={() => router.push('/settings')}>
|
||||
<Button onClick={() => router.push("/settings")}>
|
||||
Reconnect {connector.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl p-6">
|
||||
<div className="mb-6 flex gap-2 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4 scale-125 mr-2" />
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
|
||||
|
|
@ -337,7 +367,7 @@ export default function UploadProviderPage() {
|
|||
accessToken={accessToken || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
|
||||
<OneDrivePicker
|
||||
onFileSelected={handleOneDriveFileSelected}
|
||||
|
|
@ -352,7 +382,7 @@ export default function UploadProviderPage() {
|
|||
{selectedFiles.length > 0 && (
|
||||
<div className="max-w-3xl mx-auto mt-8">
|
||||
<div className="flex justify-end gap-3 mb-4">
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => handleSync(connector)}
|
||||
disabled={selectedFiles.length === 0 || isIngesting}
|
||||
>
|
||||
|
|
@ -365,14 +395,14 @@ export default function UploadProviderPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Success toast notification */}
|
||||
<Toast
|
||||
<Toast
|
||||
message="Ingested successfully!."
|
||||
show={showSuccessToast}
|
||||
onHide={() => setShowSuccessToast(false)}
|
||||
duration={20000}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
const refetchSearch = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["search"],
|
||||
exact: false,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
|
|
@ -71,12 +74,12 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
const newTasks = data.tasks || [];
|
||||
|
||||
// Update tasks and check for status changes in the same state update
|
||||
setTasks((prevTasks) => {
|
||||
setTasks(prevTasks => {
|
||||
// Check for newly completed tasks to show toasts
|
||||
if (prevTasks.length > 0) {
|
||||
newTasks.forEach((newTask: Task) => {
|
||||
const oldTask = prevTasks.find(
|
||||
(t) => t.task_id === newTask.task_id,
|
||||
t => t.task_id === newTask.task_id
|
||||
);
|
||||
if (
|
||||
oldTask &&
|
||||
|
|
@ -92,6 +95,11 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
},
|
||||
});
|
||||
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 (
|
||||
oldTask &&
|
||||
oldTask.status !== "failed" &&
|
||||
|
|
@ -130,21 +138,19 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
const data = await response.json();
|
||||
const newTasks = data.tasks || [];
|
||||
const foundTask = newTasks.find(
|
||||
(task: Task) => task.task_id === taskId,
|
||||
(task: Task) => task.task_id === taskId
|
||||
);
|
||||
|
||||
if (foundTask) {
|
||||
// Task found! Update the tasks state
|
||||
setTasks((prevTasks) => {
|
||||
setTasks(prevTasks => {
|
||||
// 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) {
|
||||
return [...prevTasks, foundTask];
|
||||
}
|
||||
// Update existing task
|
||||
return prevTasks.map((t) =>
|
||||
t.task_id === taskId ? foundTask : t,
|
||||
);
|
||||
return prevTasks.map(t => (t.task_id === taskId ? foundTask : t));
|
||||
});
|
||||
return; // Stop polling, we found it
|
||||
}
|
||||
|
|
@ -169,7 +175,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
}, [fetchTasks]);
|
||||
|
||||
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(
|
||||
|
|
@ -196,11 +202,11 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
|
|||
});
|
||||
}
|
||||
},
|
||||
[fetchTasks],
|
||||
[fetchTasks]
|
||||
);
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
setIsMenuOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Periodic polling for task updates
|
||||
|
|
|
|||
|
|
@ -373,3 +373,5 @@ async def connector_token(request: Request, connector_service, session_manager):
|
|||
except Exception as e:
|
||||
logger.error("Error getting connector token", error=str(e))
|
||||
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)
|
||||
13
src/main.py
13
src/main.py
|
|
@ -30,6 +30,7 @@ from api import (
|
|||
auth,
|
||||
chat,
|
||||
connectors,
|
||||
documents,
|
||||
flows,
|
||||
knowledge_filter,
|
||||
langflow_files,
|
||||
|
|
@ -872,6 +873,18 @@ async def create_app():
|
|||
),
|
||||
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
|
||||
Route(
|
||||
"/.well-known/openid-configuration",
|
||||
|
|
|
|||
|
|
@ -435,3 +435,4 @@ class DocumentService:
|
|||
|
||||
if upload_task.processed_files >= upload_task.total_files:
|
||||
upload_task.status = TaskStatus.COMPLETED
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ class SessionManager:
|
|||
|
||||
def get_user_opensearch_client(self, user_id: str, jwt_token: str):
|
||||
"""Get or create OpenSearch client for user with their JWT"""
|
||||
from config.settings import is_no_auth_mode
|
||||
from config.settings import is_no_auth_mode, clients
|
||||
|
||||
logger.debug(
|
||||
"get_user_opensearch_client",
|
||||
|
|
@ -200,8 +200,13 @@ class SessionManager:
|
|||
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)):
|
||||
# In no-auth mode, use admin client directly (no JWT tokens)
|
||||
if is_no_auth_mode():
|
||||
logger.debug("Using admin OpenSearch client in no-auth mode")
|
||||
return clients.opensearch
|
||||
|
||||
# In auth mode, create anonymous JWT for OpenSearch DLS if needed
|
||||
if jwt_token is None and 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")
|
||||
|
|
@ -214,8 +219,6 @@ class SessionManager:
|
|||
|
||||
# Check if we have a cached client for this user
|
||||
if user_id not in self.user_opensearch_clients:
|
||||
from config.settings import clients
|
||||
|
||||
self.user_opensearch_clients[user_id] = (
|
||||
clients.create_user_opensearch_client(jwt_token)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue