This commit is contained in:
phact 2025-09-19 11:06:50 -04:00
commit 9d57c352e8
19 changed files with 999 additions and 347 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,5 +1,6 @@
"use client";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@ -8,20 +9,72 @@ import {
} from "@/components/ui/dropdown-menu";
import { EllipsisVertical } from "lucide-react";
import { Button } from "./ui/button";
import { DeleteConfirmationDialog } from "./confirmation-dialog";
import { useDeleteDocument } from "@/app/api/mutations/useDeleteDocument";
import { toast } from "sonner";
export function KnowledgeActionsDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="hover:bg-transparent">
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={-10}>
<DropdownMenuItem className="text-destructive focus:text-destructive-foreground focus:bg-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
interface KnowledgeActionsDropdownProps {
filename: string;
}
export const KnowledgeActionsDropdown = ({
filename,
}: KnowledgeActionsDropdownProps) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument();
const handleDelete = async () => {
try {
await deleteDocumentMutation.mutateAsync({ filename });
toast.success(`Successfully deleted "${filename}"`);
setShowDeleteDialog(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete document"
);
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" className="hover:bg-transparent">
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={-10}>
{/* //TODO: Implement rename and sync */}
{/* <DropdownMenuItem
className="text-primary focus:text-primary"
onClick={() => alert("Not implemented")}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="text-primary focus:text-primary"
onClick={() => alert("Not implemented")}
>
Sync
</DropdownMenuItem> */}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteConfirmationDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title="Delete Document"
description={`Are you sure you want to delete "${filename}"? This will remove all chunks and data associated with this document. This action cannot be undone.`}
confirmText="Delete"
onConfirm={handleDelete}
isLoading={deleteDocumentMutation.isPending}
/>
</>
);
};

View file

@ -1,10 +1,10 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
Cloud,
FolderOpen,
Loader2,
PlugZap,
Plus,
Upload,
@ -45,6 +45,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;
@ -56,12 +57,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 () => {
@ -108,7 +103,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;
@ -118,7 +113,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();
@ -179,7 +174,7 @@ export function KnowledgeDropdown({
window.dispatchEvent(
new CustomEvent("fileUploadStart", {
detail: { filename: files[0].name },
}),
})
);
try {
@ -191,21 +186,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
@ -213,12 +225,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
);
}
}
@ -236,8 +248,9 @@ export function KnowledgeDropdown({
unified: true,
},
},
}),
})
);
// Trigger search refresh after successful ingestion
window.dispatchEvent(new CustomEvent("knowledgeUpdated"));
} catch (error) {
@ -247,12 +260,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
}
}
@ -289,9 +302,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);
@ -305,7 +324,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
}
};
@ -336,6 +355,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);
@ -349,7 +369,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
}
};
@ -358,10 +378,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");
}
@ -403,14 +430,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"
@ -419,44 +448,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) => (
@ -469,7 +522,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}
@ -508,7 +561,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">
@ -550,7 +603,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">

View file

@ -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 font-mono",
icon ? "left-9" : "left-3",
props.value && "hidden",
props.value && "hidden"
)}
>
{placeholder}
</span>
</label>
);
},
}
);
Input.displayName = "Input";

View file

@ -0,0 +1,45 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface DeleteDocumentRequest {
filename: string;
}
interface DeleteDocumentResponse {
success: boolean;
deleted_chunks: number;
filename: string;
message: string;
}
const deleteDocument = async (
data: DeleteDocumentRequest
): Promise<DeleteDocumentResponse> => {
const response = await fetch("/api/documents/delete-by-filename", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete document");
}
return response.json();
};
export const useDeleteDocument = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteDocument,
onSuccess: () => {
// Invalidate and refetch search queries to update the UI
queryClient.invalidateQueries({ queryKey: ["search"] });
},
});
};

View file

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

View file

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

View file

@ -0,0 +1,212 @@
"use client";
import {
Building2,
Cloud,
FileText,
HardDrive,
Loader2,
Search,
} from "lucide-react";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { SiGoogledrive } from "react-icons/si";
import { TbBrandOnedrive } from "react-icons/tb";
import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context";
import {
type ChunkResult,
type File,
useGetSearchQuery,
} from "../../api/queries/useGetSearchQuery";
// Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) {
switch (connectorType) {
case "google_drive":
return <SiGoogledrive className="h-4 w-4 text-foreground" />;
case "onedrive":
return <TbBrandOnedrive className="h-4 w-4 text-foreground" />;
case "sharepoint":
return <Building2 className="h-4 w-4 text-foreground" />;
case "s3":
return <Cloud className="h-4 w-4 text-foreground" />;
default:
return <HardDrive className="h-4 w-4 text-muted-foreground" />;
}
}
function ChunksPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { isMenuOpen } = useTask();
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter();
const filename = searchParams.get("filename");
const [chunks, setChunks] = useState<ChunkResult[]>([]);
// Use the same search query as the knowledge page, but we'll filter for the specific file
const { data = [], isFetching } = useGetSearchQuery("*", parsedFilterData);
// Extract chunks for the specific file
useEffect(() => {
if (!filename || !(data as File[]).length) {
setChunks([]);
return;
}
const fileData = (data as File[]).find(
(file: File) => file.filename === filename
);
setChunks(fileData?.chunks || []);
}, [data, filename]);
const handleBack = useCallback(() => {
router.back();
}, [router]);
if (!filename) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">No file specified</p>
<p className="text-sm text-muted-foreground/70 mt-2">
Please select a file from the knowledge page
</p>
</div>
</div>
);
}
return (
<div
className={`fixed inset-0 md:left-72 top-[53px] flex flex-col transition-all duration-300 ${
isMenuOpen && isPanelOpen
? "md:right-[704px]"
: // Both open: 384px (menu) + 320px (KF panel)
isMenuOpen
? "md:right-96"
: // Only menu open: 384px
isPanelOpen
? "md:right-80"
: // Only KF panel open: 320px
"md:right-6" // Neither open: 24px
}`}
>
<div className="flex-1 flex flex-col min-h-0 px-6 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="text-muted-foreground hover:text-foreground px-2"
>
Back
</Button>
<div className="flex flex-col">
<h2 className="text-lg font-semibold">Document Chunks</h2>
<p className="text-sm text-muted-foreground truncate max-w-md">
{decodeURIComponent(filename)}
</p>
</div>
</div>
<div className="text-sm text-muted-foreground">
{!isFetching && chunks.length > 0 && (
<span>
{chunks.length} chunk{chunks.length !== 1 ? "s" : ""} found
</span>
)}
</div>
</div>
{/* Content Area - matches knowledge page structure */}
<div className="flex-1 overflow-auto">
{isFetching ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Loader2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50 animate-spin" />
<p className="text-lg text-muted-foreground">
Loading chunks...
</p>
</div>
</div>
) : chunks.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">No chunks found</p>
<p className="text-sm text-muted-foreground/70 mt-2">
This file may not have been indexed yet
</p>
</div>
</div>
) : (
<div className="space-y-4 pb-6">
{chunks.map((chunk, index) => (
<div
key={chunk.filename + index}
className="bg-muted/20 rounded-lg p-4 border border-border/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">
{chunk.filename}
</span>
{chunk.connector_type && (
<div className="ml-2">
{getSourceIcon(chunk.connector_type)}
</div>
)}
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
<span>{chunk.mimetype}</span>
<span>Page {chunk.page}</span>
{chunk.owner_name && <span>Owner: {chunk.owner_name}</span>}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
function ChunksPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Loader2 className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50 animate-spin" />
<p className="text-lg text-muted-foreground">Loading...</p>
</div>
</div>
}
>
<ChunksPageContent />
</Suspense>
);
}
export default function ProtectedChunksPage() {
return (
<ProtectedRoute>
<ChunksPage />
</ProtectedRoute>
);
}

View file

@ -3,10 +3,10 @@
import {
Building2,
Cloud,
FileText,
HardDrive,
Loader2,
Search,
Trash2,
} from "lucide-react";
import { AgGridReact, CustomCellRendererProps } from "ag-grid-react";
import {
@ -16,6 +16,7 @@ import {
useState,
useRef,
} from "react";
import { useRouter } from "next/navigation";
import { SiGoogledrive } from "react-icons/si";
import { TbBrandOnedrive } from "react-icons/tb";
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
@ -25,33 +26,46 @@ import { Input } from "@/components/ui/input";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context";
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
import { ColDef, RowClickedEvent } from "ag-grid-community";
import { ColDef } from "ag-grid-community";
import "@/components/AgGrid/registerAgGridModules";
import "@/components/AgGrid/agGridStyles.css";
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
import { DeleteConfirmationDialog } from "../../../components/confirmation-dialog";
import { useDeleteDocument } from "../api/mutations/useDeleteDocument";
import { toast } from "sonner";
// Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) {
switch (connectorType) {
case "google_drive":
return <SiGoogledrive className="h-4 w-4 text-foreground" />;
return (
<SiGoogledrive className="h-4 w-4 text-foreground flex-shrink-0" />
);
case "onedrive":
return <TbBrandOnedrive className="h-4 w-4 text-foreground" />;
return (
<TbBrandOnedrive className="h-4 w-4 text-foreground flex-shrink-0" />
);
case "sharepoint":
return <Building2 className="h-4 w-4 text-foreground" />;
return <Building2 className="h-4 w-4 text-foreground flex-shrink-0" />;
case "s3":
return <Cloud className="h-4 w-4 text-foreground" />;
return <Cloud className="h-4 w-4 text-foreground flex-shrink-0" />;
default:
return <HardDrive className="h-4 w-4 text-muted-foreground" />;
return (
<HardDrive className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
}
}
function SearchPage() {
const router = useRouter();
const { isMenuOpen } = useTask();
const { parsedFilterData, isPanelOpen } = useKnowledgeFilter();
const [query, setQuery] = useState("");
const [queryInputText, setQueryInputText] = useState("");
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [selectedRows, setSelectedRows] = useState<File[]>([]);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument();
const {
data = [],
@ -86,9 +100,22 @@ function SearchPage() {
{
field: "filename",
headerName: "Source",
checkboxSelection: true,
headerCheckboxSelection: true,
initialFlex: 2,
minWidth: 220,
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
return (
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 cursor-pointer hover:text-blue-600 transition-colors"
onClick={() => {
router.push(
`/knowledge/chunks?filename=${encodeURIComponent(
data?.filename ?? ""
)}`
);
}}
>
{getSourceIcon(data?.connector_type)}
<span className="font-medium text-foreground truncate">
{value}
@ -111,12 +138,8 @@ function SearchPage() {
field: "owner",
headerName: "Owner",
valueFormatter: (params) =>
params.value ||
params.data?.owner_name ||
params.data?.owner_email ||
"—",
params.data?.owner_name || params.data?.owner_email || "—",
},
{
field: "chunkCount",
headerName: "Chunks",
@ -133,19 +156,20 @@ function SearchPage() {
},
},
{
cellRenderer: () => {
return <KnowledgeActionsDropdown />;
cellRenderer: ({ data }: CustomCellRendererProps<File>) => {
return <KnowledgeActionsDropdown filename={data?.filename || ""} />;
},
cellStyle: {
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: "center",
display: "flex",
justifyContent: "center",
padding: 0,
},
colId: 'actions',
colId: "actions",
filter: false,
maxWidth: 60,
width: 60,
minWidth: 60,
maxWidth: 60,
resizable: false,
sortable: false,
initialFlex: 0,
@ -153,14 +177,49 @@ function SearchPage() {
]);
const defaultColDef: ColDef<File> = {
cellStyle: () => ({
display: "flex",
alignItems: "center",
}),
initialFlex: 1,
minWidth: 100,
resizable: false,
suppressMovable: true,
initialFlex: 1,
minWidth: 100,
};
const onSelectionChanged = useCallback(() => {
if (gridRef.current) {
const selectedNodes = gridRef.current.api.getSelectedRows();
setSelectedRows(selectedNodes);
}
}, []);
const handleBulkDelete = async () => {
if (selectedRows.length === 0) return;
try {
// Delete each file individually since the API expects one filename at a time
const deletePromises = selectedRows.map((row) =>
deleteDocumentMutation.mutateAsync({ filename: row.filename })
);
await Promise.all(deletePromises);
toast.success(
`Successfully deleted ${selectedRows.length} document${
selectedRows.length > 1 ? "s" : ""
}`
);
setSelectedRows([]);
setShowBulkDeleteDialog(false);
// Clear selection in the grid
if (gridRef.current) {
gridRef.current.api.deselectAll();
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to delete some documents"
);
}
};
return (
@ -183,6 +242,7 @@ function SearchPage() {
<h2 className="text-lg font-semibold">Project Knowledge</h2>
<KnowledgeDropdown variant="button" />
</div>
{/* Search Input Area */}
<div className="flex-shrink-0 mb-6 lg:max-w-[75%] xl:max-w-[50%]">
<form onSubmit={handleSearch} className="flex gap-3">
@ -194,12 +254,12 @@ function SearchPage() {
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"
className="flex-1 bg-muted/20 rounded-lg border border-border/50 px-4 py-3 focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="submit"
variant="outline"
className="rounded-lg h-12 w-12 p-0 flex-shrink-0"
className="rounded-lg p-0 flex-shrink-0"
>
{isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -207,75 +267,70 @@ function SearchPage() {
<Search className="h-4 w-4" />
)}
</Button>
{/* //TODO: Implement sync button */}
{/* <Button
type="button"
variant="outline"
className="rounded-lg flex-shrink-0"
onClick={() => alert("Not implemented")}
>
Sync
</Button> */}
<Button
type="button"
variant="destructive"
className="rounded-lg flex-shrink-0"
onClick={() => setShowBulkDeleteDialog(true)}
disabled={selectedRows.length === 0}
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</form>
</div>
{selectedFile ? (
// Show chunks for selected file
<>
<div className="flex items-center gap-2 mb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedFile(null)}
>
Back to files
</Button>
<span className="text-sm text-muted-foreground">
Chunks from {selectedFile}
</span>
<AgGridReact
className="w-full overflow-auto"
columnDefs={columnDefs}
defaultColDef={defaultColDef}
loading={isFetching}
ref={gridRef}
rowData={fileResults}
rowSelection="multiple"
rowMultiSelectWithClick={false}
suppressRowClickSelection={true}
getRowId={(params) => params.data.filename}
onSelectionChanged={onSelectionChanged}
suppressHorizontalScroll={false}
noRowsOverlayComponent={() => (
<div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 mt-2">
Try adjusting your search terms
</p>
</div>
{fileResults
.filter((file) => file.filename === selectedFile)
.flatMap((file) => file.chunks)
.map((chunk, index) => (
<div
key={chunk.filename + index}
className="bg-muted/20 rounded-lg p-4 border border-border/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="font-medium truncate">
{chunk.filename}
</span>
</div>
<span className="text-xs text-green-400 bg-green-400/20 px-2 py-1 rounded">
{chunk.score.toFixed(2)}
</span>
</div>
<div className="text-sm text-muted-foreground mb-2">
{chunk.mimetype} Page {chunk.page}
</div>
<p className="text-sm text-foreground/90 leading-relaxed">
{chunk.text}
</p>
</div>
))}
</>
) : (
<AgGridReact
columnDefs={columnDefs}
defaultColDef={defaultColDef}
loading={isFetching}
ref={gridRef}
rowData={fileResults}
onRowClicked={(params: RowClickedEvent<File>) => {
setSelectedFile(params.data?.filename ?? "");
}}
noRowsOverlayComponent={() => (
<div className="text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg text-muted-foreground">
No documents found
</p>
<p className="text-sm text-muted-foreground/70 mt-2">
Try adjusting your search terms
</p>
</div>
)}
/>
)}
)}
/>
</div>
{/* Bulk Delete Confirmation Dialog */}
<DeleteConfirmationDialog
open={showBulkDeleteDialog}
onOpenChange={setShowBulkDeleteDialog}
title="Delete Documents"
description={`Are you sure you want to delete ${
selectedRows.length
} document${
selectedRows.length > 1 ? "s" : ""
}? This will remove all chunks and data associated with these documents. This action cannot be undone.
Documents to be deleted:
${selectedRows.map((row) => `${row.filename}`).join("\n")}`}
confirmText="Delete All"
onConfirm={handleBulkDelete}
isLoading={deleteDocumentMutation.isPending}
/>
</div>
);
}

View file

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

View file

@ -11,11 +11,30 @@ body {
--ag-wrapper-border: none;
--ag-font-family: var(--font-sans);
/* Checkbox styling */
--ag-checkbox-background-color: hsl(var(--background));
--ag-checkbox-border-color: hsl(var(--border));
--ag-checkbox-checked-color: hsl(var(--primary));
--ag-checkbox-unchecked-color: transparent;
.ag-header {
border-bottom: 1px solid hsl(var(--border));
margin-bottom: 0.5rem;
}
.ag-row {
cursor: pointer;
/* Make sure checkboxes are visible */
.ag-selection-checkbox,
.ag-header-select-all {
opacity: 1 !important;
}
.ag-checkbox-input-wrapper {
border: 1px solid hsl(var(--border));
background-color: hsl(var(--background));
}
.ag-checkbox-input-wrapper.ag-checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
}

View file

@ -11,6 +11,7 @@ import {
DateFilterModule,
EventApiModule,
GridStateModule,
RowSelectionModule,
} from 'ag-grid-community';
// Importing necessary modules from ag-grid-community
@ -27,6 +28,7 @@ import {
DateFilterModule,
EventApiModule,
GridStateModule,
RowSelectionModule,
// The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
]);

View file

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

View file

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

@ -398,4 +398,4 @@ class AppClients:
# Global clients instance
clients = AppClients()
clients = AppClients()

View file

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

View file

@ -435,3 +435,4 @@ class DocumentService:
if upload_task.processed_files >= upload_task.total_files:
upload_task.status = TaskStatus.COMPLETED

View file

@ -232,4 +232,4 @@ class SessionManager:
def _create_anonymous_jwt(self) -> str:
"""Create JWT token for anonymous user in no-auth mode"""
anonymous_user = AnonymousUser()
return self.create_jwt_token(anonymous_user)
return self.create_jwt_token(anonymous_user)