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:
Deon Sanchez 2025-09-16 16:07:49 -06:00
parent 05a394a805
commit f28ba54da3
13 changed files with 949 additions and 293 deletions

View 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>
);
};

View file

@ -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">

View file

@ -1,7 +1,8 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
icon?: React.ReactNode; icon?: React.ReactNode;
inputClassName?: string; inputClassName?: string;
} }
@ -9,7 +10,12 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, inputClassName, icon, type, placeholder, ...props }, ref) => { ({ className, inputClassName, icon, type, placeholder, ...props }, ref) => {
return ( 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 && ( {icon && (
<div className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"> <div className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted-foreground">
{icon} {icon}
@ -22,7 +28,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}
@ -31,14 +37,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn( className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground", "pointer-events-none absolute top-1/2 -translate-y-1/2 pl-px text-placeholder-foreground",
icon ? "left-9" : "left-3", icon ? "left-9" : "left-3",
props.value && "hidden", props.value && "hidden"
)} )}
> >
{placeholder} {placeholder}
</span> </span>
</label> </label>
); );
}, }
); );
Input.displayName = "Input"; Input.displayName = "Input";

View file

@ -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 };

View file

@ -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;

View file

@ -7,17 +7,35 @@ import {
HardDrive, HardDrive,
Loader2, Loader2,
Search, Search,
Trash2,
Edit,
RefreshCw,
} from "lucide-react"; } 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 { SiGoogledrive } from "react-icons/si";
import { TbBrandOnedrive } from "react-icons/tb"; 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 { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { 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 { KnowledgeDropdown } from "@/components/knowledge-dropdown";
// 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) {
@ -41,12 +59,32 @@ function SearchPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [queryInputText, setQueryInputText] = useState(""); const [queryInputText, setQueryInputText] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const { // Delete state
data = [], const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(
isFetching, new Set()
refetch: refetchSearch, );
} = useGetSearchQuery(query, parsedFilterData); 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 // Update query when global filter changes
useEffect(() => { useEffect(() => {
@ -55,18 +93,203 @@ function SearchPage() {
} }
}, [parsedFilterData]); }, [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( const handleSearch = useCallback(
(e?: FormEvent<HTMLFormElement>) => { (e?: FormEvent<HTMLFormElement>) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
if (query.trim() === queryInputText.trim()) { 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; return;
} }
setQuery(queryInputText); 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[]; const fileResults = data as File[];
return ( return (
@ -87,17 +310,25 @@ function SearchPage() {
<div className="flex-1 flex flex-col min-h-0 px-6 py-6"> <div className="flex-1 flex flex-col min-h-0 px-6 py-6">
{/* Search Input Area */} {/* Search Input Area */}
<div className="flex-shrink-0 mb-6"> <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"> <form onSubmit={handleSearch} className="flex gap-3">
<Input <div className="lg:w-1/2 w-full">
name="search-query" <Input
id="search-query" name="search-query"
type="text" id="search-query"
defaultValue={parsedFilterData?.query} type="text"
value={queryInputText} defaultValue={parsedFilterData?.query}
onChange={(e) => setQueryInputText(e.target.value)} value={queryInputText}
placeholder="Search your documents..." onChange={e => setQueryInputText(e.target.value)}
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" 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 <Button
type="submit" type="submit"
variant="secondary" variant="secondary"
@ -109,9 +340,22 @@ function SearchPage() {
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
)} )}
</Button> </Button>
<div className="flex-shrink-0"> <Button
<KnowledgeDropdown variant="button" /> variant="secondary"
</div> 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> </form>
</div> </div>
@ -130,8 +374,8 @@ function SearchPage() {
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Results Count */} {/* Results Count and Bulk Actions */}
<div className="mb-4"> <div className="mb-4 space-y-4">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{fileResults.length} file {fileResults.length} file
{fileResults.length !== 1 ? "s" : ""} found {fileResults.length !== 1 ? "s" : ""} found
@ -158,8 +402,8 @@ function SearchPage() {
</span> </span>
</div> </div>
{fileResults {fileResults
.filter((file) => file.filename === selectedFile) .filter(file => file.filename === selectedFile)
.flatMap((file) => file.chunks) .flatMap(file => file.chunks)
.map((chunk, index) => ( .map((chunk, index) => (
<div <div
key={chunk.filename + index} key={chunk.filename + index}
@ -187,40 +431,94 @@ function SearchPage() {
</> </>
) : ( ) : (
// Show files table // Show files table
<div className="bg-muted/20 rounded-lg border border-border/50 overflow-hidden"> <div className="bg-muted/20 rounded-lg border border-border/50 overflow-x-auto">
<table className="w-full"> <table className="w-full min-w-[800px]">
<thead> <thead>
<tr className="border-b border-border/50 bg-muted/10"> <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 Source
</th> </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 Type
</th> </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 Size
</th> </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">
Matching chunks Chunks
</th> </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">
Average score Score
</th>
<th className="text-left p-3 text-sm font-medium text-muted-foreground">
Owner
</th> </th>
<th className="text-center p-3 text-sm font-medium text-muted-foreground w-12" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{fileResults.map((file) => ( {fileResults.map(file => (
<tr <tr
key={file.filename} key={file.filename}
className="border-b border-border/30 hover:bg-muted/20 cursor-pointer transition-colors" className="border-b border-border/30 hover:bg-muted/20 transition-colors"
onClick={() => setSelectedFile(file.filename)}
> >
<td className="p-3"> <td className="pl-5 w-12">
<div className="flex items-center gap-2"> <div
{getSourceIcon(file.connector_type)} 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 <span
className="font-medium truncate" className="font-medium truncate"
title={file.filename} title={file.filename}
@ -229,27 +527,90 @@ function SearchPage() {
</span> </span>
</div> </div>
</td> </td>
<td className="p-3 text-sm text-muted-foreground"> <td
{file.mimetype} 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>
<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 {file.size
? `${Math.round(file.size / 1024)} KB` ? `${Math.round(file.size / 1024)} KB`
: "—"} : "—"}
</td> </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} {file.chunkCount}
</td> </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"> <span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{file.avgScore.toFixed(2)} {file.avgScore.toFixed(2)}
</span> </span>
</td> </td>
<td
className="p-3 text-sm text-muted-foreground" <td className="p-3 text-center text-sm text-muted-foreground w-12">
title={file.owner_email} <Popover
> open={openDropdown === file.filename}
{file.owner_name || file.owner || "—"} 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> </td>
</tr> </tr>
))} ))}
@ -263,6 +624,32 @@ function SearchPage() {
</div> </div>
</div> </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> </div>
); );
} }

View file

@ -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>
) );
} }

View file

@ -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

View file

@ -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
View 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)

View file

@ -30,6 +30,7 @@ from api import (
auth, auth,
chat, chat,
connectors, connectors,
documents,
flows, flows,
knowledge_filter, knowledge_filter,
langflow_files, langflow_files,
@ -872,6 +873,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",

View file

@ -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

View file

@ -191,7 +191,7 @@ 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 from config.settings import is_no_auth_mode, clients
logger.debug( logger.debug(
"get_user_opensearch_client", "get_user_opensearch_client",
@ -200,8 +200,13 @@ class SessionManager:
no_auth_mode=is_no_auth_mode(), no_auth_mode=is_no_auth_mode(),
) )
# In no-auth mode, create anonymous JWT for OpenSearch DLS # In no-auth mode, use admin client directly (no JWT tokens)
if jwt_token is None and (is_no_auth_mode() or user_id in (None, AnonymousUser().user_id)): 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"): if not hasattr(self, "_anonymous_jwt"):
# Create anonymous JWT token for OpenSearch OIDC # Create anonymous JWT token for OpenSearch OIDC
logger.debug("Creating anonymous JWT") logger.debug("Creating anonymous JWT")
@ -214,8 +219,6 @@ class SessionManager:
# 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:
from config.settings import clients
self.user_opensearch_clients[user_id] = ( self.user_opensearch_clients[user_id] = (
clients.create_user_opensearch_client(jwt_token) clients.create_user_opensearch_client(jwt_token)
) )