Implement unified cloud picker with support for Google Drive and OneDrive, replacing individual pickers. Add new components for file listing, ingest settings, and file item display. Update package dependencies to include @radix-ui/react-separator.

This commit is contained in:
Deon Sanchez 2025-09-18 12:04:07 -06:00
parent 994b71b40f
commit 9a8818a81b
16 changed files with 1239 additions and 1005 deletions

View file

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View file

@ -19,6 +19,7 @@
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
@ -1855,6 +1856,28 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",

View file

@ -20,6 +20,7 @@
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",

View file

@ -1,20 +1,14 @@
"use client"
"use client";
import React, { useState } from "react";
import { GoogleDrivePicker } from "@/components/google-drive-picker"
import { useTask } from "@/contexts/task-context"
import { UnifiedCloudPicker, CloudFile } from "@/components/cloud-picker";
import { useTask } from "@/contexts/task-context";
interface GoogleDriveFile {
id: string;
name: string;
mimeType: string;
webViewLink?: string;
iconLink?: string;
}
// CloudFile interface is now imported from the unified cloud picker
export default function ConnectorsPage() {
const { addTask } = useTask()
const [selectedFiles, setSelectedFiles] = useState<GoogleDriveFile[]>([]);
const { addTask } = useTask();
const [selectedFiles, setSelectedFiles] = useState<CloudFile[]>([]);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [syncResult, setSyncResult] = useState<{
processed?: number;
@ -25,16 +19,19 @@ export default function ConnectorsPage() {
errors?: number;
} | null>(null);
const handleFileSelection = (files: GoogleDriveFile[]) => {
const handleFileSelection = (files: CloudFile[]) => {
setSelectedFiles(files);
};
const handleSync = async (connector: { connectionId: string, type: string }) => {
if (!connector.connectionId || selectedFiles.length === 0) return
setIsSyncing(true)
setSyncResult(null)
const handleSync = async (connector: {
connectionId: string;
type: string;
}) => {
if (!connector.connectionId || selectedFiles.length === 0) return;
setIsSyncing(true);
setSyncResult(null);
try {
const syncBody: {
connection_id: string;
@ -42,54 +39,55 @@ export default function ConnectorsPage() {
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 taskId = result.task_id
const taskId = result.task_id;
if (taskId) {
addTask(taskId)
setSyncResult({
processed: 0,
addTask(taskId);
setSyncResult({
processed: 0,
total: selectedFiles.length,
status: 'started'
})
status: "started",
});
}
} else if (response.ok) {
setSyncResult(result)
setSyncResult(result);
} else {
console.error('Sync failed:', result.error)
setSyncResult({ error: result.error || 'Sync failed' })
console.error("Sync failed:", result.error);
setSyncResult({ error: result.error || "Sync failed" });
}
} catch (error) {
console.error('Sync error:', error)
setSyncResult({ error: 'Network error occurred' })
console.error("Sync error:", error);
setSyncResult({ error: "Network error occurred" });
} finally {
setIsSyncing(false)
setIsSyncing(false);
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Connectors</h1>
<div className="mb-6">
<p className="text-sm text-gray-600 mb-4">
This is a demo page for the Google Drive picker component.
For full connector functionality, visit the Settings page.
This is a demo page for the Google Drive picker component. For full
connector functionality, visit the Settings page.
</p>
<GoogleDrivePicker
<UnifiedCloudPicker
provider="google_drive"
onFileSelected={handleFileSelection}
selectedFiles={selectedFiles}
isAuthenticated={false} // This would come from auth context in real usage
@ -99,8 +97,13 @@ export default function ConnectorsPage() {
{selectedFiles.length > 0 && (
<div className="space-y-4">
<button
onClick={() => handleSync({ connectionId: "google-drive-connection-id", type: "google-drive" })}
<button
onClick={() =>
handleSync({
connectionId: "google-drive-connection-id",
type: "google-drive",
})
}
disabled={isSyncing}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
@ -110,14 +113,15 @@ export default function ConnectorsPage() {
<>Sync {selectedFiles.length} Selected Items</>
)}
</button>
{syncResult && (
<div className="p-3 bg-gray-100 rounded text-sm">
{syncResult.error ? (
<div className="text-red-600">Error: {syncResult.error}</div>
) : syncResult.status === 'started' ? (
) : syncResult.status === "started" ? (
<div className="text-blue-600">
Sync started for {syncResult.total} files. Check the task notification for progress.
Sync started for {syncResult.total} files. Check the task
notification for progress.
</div>
) : (
<div className="text-green-600">

View file

@ -1,110 +1,105 @@
"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 { UnifiedCloudPicker, CloudFile } from "@/components/cloud-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
}
interface OneDriveFile {
id: string
name: string
mimeType?: string
webUrl?: string
driveItem?: {
file?: { mimeType: string }
folder?: unknown
}
}
// CloudFile interface is now imported from the unified cloud picker
interface CloudConnector {
id: string
name: string
description: string
status: "not_connected" | "connecting" | "connected" | "error"
type: string
connectionId?: string
clientId: string
hasAccessToken: boolean
accessTokenError?: string
id: string;
name: string;
description: string;
status: "not_connected" | "connecting" | "connected" | "error";
type: string;
connectionId?: string;
clientId: 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<CloudFile[]>([]);
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";
}
}
@ -117,61 +112,56 @@ export default function UploadProviderPage() {
connectionId: activeConnection?.connection_id,
clientId: activeConnection?.client_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);
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)
const handleFileSelected = (files: CloudFile[]) => {
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)
}
const handleOneDriveFileSelected = (files: OneDriveFile[]) => {
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;
@ -179,43 +169,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 (
@ -227,15 +217,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"
>
@ -243,27 +233,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"
>
@ -271,29 +263,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"
>
@ -301,81 +296,80 @@ 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()}
>
<ArrowLeft className="h-4 w-4 scale-125 mr-2" />
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 scale-125" />
</Button>
<h2 className="text-2xl font-bold">Add Cloud Knowledge</h2>
<h2 className="text-2xl font-bold">
Add from {getProviderDisplayName()}
</h2>
</div>
<div className="max-w-3xl mx-auto">
{connector.type === "google_drive" && (
<GoogleDrivePicker
onFileSelected={handleGoogleDriveFileSelected}
selectedFiles={selectedFiles as GoogleDriveFile[]}
isAuthenticated={true}
accessToken={accessToken || undefined}
/>
)}
{(connector.type === "onedrive" || connector.type === "sharepoint") && (
<OneDrivePicker
onFileSelected={handleOneDriveFileSelected}
selectedFiles={selectedFiles as OneDriveFile[]}
isAuthenticated={true}
accessToken={accessToken || undefined}
connectorType={connector.type as "onedrive" | "sharepoint"}
clientId={connector.clientId}
/>
)}
<UnifiedCloudPicker
provider={
connector.type as "google_drive" | "onedrive" | "sharepoint"
}
onFileSelected={handleFileSelected}
selectedFiles={selectedFiles}
isAuthenticated={true}
accessToken={accessToken || undefined}
clientId={connector.clientId}
/>
</div>
{selectedFiles.length > 0 && (
<div className="max-w-3xl mx-auto mt-8">
<div className="flex justify-end gap-3 mb-4">
<Button
onClick={() => handleSync(connector)}
disabled={selectedFiles.length === 0 || isIngesting}
>
{isIngesting ? (
<>Ingesting {selectedFiles.length} Files...</>
) : (
<>Ingest Files ({selectedFiles.length})</>
)}
</Button>
</div>
<div className="max-w-3xl mx-auto mt-6">
<div className="flex justify-between gap-3 mb-4">
<Button
variant="ghost"
className=" border bg-transparent border-border rounded-lg text-secondary-foreground"
onClick={() => router.back()}
>
Back
</Button>
<Button
variant="secondary"
onClick={() => handleSync(connector)}
disabled={selectedFiles.length === 0 || isIngesting}
>
{isIngesting ? (
<>Ingesting {selectedFiles.length} Files...</>
) : (
<>Start ingest</>
)}
</Button>
</div>
)}
</div>
{/* Success toast notification */}
<Toast
<Toast
message="Ingested successfully!."
show={showSuccessToast}
onHide={() => setShowSuccessToast(false)}
duration={20000}
/>
</div>
)
}
);
}

View file

@ -1,112 +1,101 @@
"use client"
"use client";
import { useState, useEffect, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { GoogleDrivePicker } from "@/components/google-drive-picker"
import { OneDrivePicker } from "@/components/onedrive-picker"
import { Loader2 } from "lucide-react"
} from "@/components/ui/dialog";
import { UnifiedCloudPicker, CloudFile } from "@/components/cloud-picker";
import { Loader2 } from "lucide-react";
interface GoogleDriveFile {
id: string
name: string
mimeType: string
webViewLink?: string
iconLink?: string
}
interface OneDriveFile {
id: string
name: string
mimeType?: string
webUrl?: string
driveItem?: {
file?: { mimeType: string }
folder?: unknown
}
}
// CloudFile interface is now imported from the unified cloud picker
interface CloudConnector {
id: string
name: string
description: string
icon: React.ReactNode
status: "not_connected" | "connecting" | "connected" | "error"
type: string
connectionId?: string
clientId: string
hasAccessToken: boolean
accessTokenError?: string
id: string;
name: string;
description: string;
icon: React.ReactNode;
status: "not_connected" | "connecting" | "connected" | "error";
type: string;
connectionId?: string;
clientId: string;
hasAccessToken: boolean;
accessTokenError?: string;
}
interface CloudConnectorsDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onFileSelected?: (files: GoogleDriveFile[] | OneDriveFile[], connectorType: string) => void
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onFileSelected?: (files: CloudFile[], connectorType: string) => void;
}
export function CloudConnectorsDialog({
isOpen,
export function CloudConnectorsDialog({
isOpen,
onOpenChange,
onFileSelected
onFileSelected,
}: CloudConnectorsDialogProps) {
const [connectors, setConnectors] = useState<CloudConnector[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedFiles, setSelectedFiles] = useState<{[connectorId: string]: GoogleDriveFile[] | OneDriveFile[]}>({})
const [connectorAccessTokens, setConnectorAccessTokens] = useState<{[connectorType: string]: string}>({})
const [activePickerType, setActivePickerType] = useState<string | null>(null)
const [connectors, setConnectors] = useState<CloudConnector[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<{
[connectorId: string]: CloudFile[];
}>({});
const [connectorAccessTokens, setConnectorAccessTokens] = useState<{
[connectorType: string]: string;
}>({});
const [activePickerType, setActivePickerType] = useState<string | null>(null);
const getConnectorIcon = (iconName: string) => {
const iconMap: { [key: string]: React.ReactElement } = {
'google-drive': (
"google-drive": (
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
G
</div>
),
'sharepoint': (
sharepoint: (
<div className="w-8 h-8 bg-blue-700 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
SP
</div>
),
'onedrive': (
onedrive: (
<div className="w-8 h-8 bg-blue-400 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
OD
</div>
),
}
return iconMap[iconName] || (
<div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
?
</div>
)
}
};
return (
iconMap[iconName] || (
<div className="w-8 h-8 bg-gray-500 rounded flex items-center justify-center text-white font-bold leading-none shrink-0">
?
</div>
)
);
};
const fetchConnectorStatuses = useCallback(async () => {
if (!isOpen) return
setIsLoading(true)
if (!isOpen) return;
setIsLoading(true);
try {
// Fetch available connectors from backend
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 connectorTypes = Object.keys(connectorsResult.connectors)
const connectorsResult = await connectorsResponse.json();
const connectorTypes = Object.keys(connectorsResult.connectors);
// Filter to only cloud connectors
const cloudConnectorTypes = connectorTypes.filter(type =>
['google_drive', 'onedrive', 'sharepoint'].includes(type) &&
connectorsResult.connectors[type].available
)
const cloudConnectorTypes = connectorTypes.filter(
type =>
["google_drive", "onedrive", "sharepoint"].includes(type) &&
connectorsResult.connectors[type].available
);
// Initialize connectors list
const initialConnectors = cloudConnectorTypes.map(type => ({
id: type,
@ -117,83 +106,94 @@ export function CloudConnectorsDialog({
type: type,
hasAccessToken: false,
accessTokenError: undefined,
clientId: ""
}))
setConnectors(initialConnectors)
clientId: "",
}));
setConnectors(initialConnectors);
// Check status for each cloud connector type
for (const connectorType of cloudConnectorTypes) {
try {
const response = await fetch(`/api/connectors/${connectorType}/status`)
const response = await fetch(
`/api/connectors/${connectorType}/status`
);
if (response.ok) {
const data = await response.json()
const connections = data.connections || []
const activeConnection = connections.find((conn: { connection_id: string; is_active: boolean }) => conn.is_active)
const isConnected = activeConnection !== undefined
let hasAccessToken = false
let accessTokenError: string | undefined = undefined
const data = await response.json();
const connections = data.connections || [];
const activeConnection = connections.find(
(conn: { connection_id: string; is_active: boolean }) =>
conn.is_active
);
const isConnected = activeConnection !== 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/${connectorType}/token?connection_id=${activeConnection.connection_id}`)
const tokenResponse = await fetch(
`/api/connectors/${connectorType}/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
hasAccessToken = true;
setConnectorAccessTokens(prev => ({
...prev,
[connectorType]: tokenData.access_token
}))
[connectorType]: 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";
}
}
setConnectors(prev => prev.map(c =>
c.type === connectorType
? {
...c,
status: isConnected ? "connected" : "not_connected",
connectionId: activeConnection?.connection_id,
clientId: activeConnection?.client_id,
hasAccessToken,
accessTokenError
}
: c
))
setConnectors(prev =>
prev.map(c =>
c.type === connectorType
? {
...c,
status: isConnected ? "connected" : "not_connected",
connectionId: activeConnection?.connection_id,
clientId: activeConnection?.client_id,
hasAccessToken,
accessTokenError,
}
: c
)
);
}
} catch (error) {
console.error(`Failed to check status for ${connectorType}:`, error)
console.error(`Failed to check status for ${connectorType}:`, error);
}
}
} catch (error) {
console.error('Failed to load cloud connectors:', error)
console.error("Failed to load cloud connectors:", error);
} finally {
setIsLoading(false)
setIsLoading(false);
}
}, [isOpen])
}, [isOpen]);
const handleFileSelection = (connectorId: string, files: GoogleDriveFile[] | OneDriveFile[]) => {
const handleFileSelection = (connectorId: string, files: CloudFile[]) => {
setSelectedFiles(prev => ({
...prev,
[connectorId]: files
}))
onFileSelected?.(files, connectorId)
}
[connectorId]: files,
}));
onFileSelected?.(files, connectorId);
};
useEffect(() => {
fetchConnectorStatuses()
}, [fetchConnectorStatuses])
fetchConnectorStatuses();
}, [fetchConnectorStatuses]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
@ -221,19 +221,24 @@ export function CloudConnectorsDialog({
<div className="flex flex-wrap gap-3 justify-center">
{connectors
.filter(connector => connector.status === "connected")
.map((connector) => (
.map(connector => (
<Button
key={connector.id}
variant={connector.hasAccessToken ? "default" : "secondary"}
variant={
connector.hasAccessToken ? "default" : "secondary"
}
disabled={!connector.hasAccessToken}
title={!connector.hasAccessToken ?
(connector.accessTokenError || "Access token required - try reconnecting your account")
: `Select files from ${connector.name}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
title={
!connector.hasAccessToken
? connector.accessTokenError ||
"Access token required - try reconnecting your account"
: `Select files from ${connector.name}`
}
onClick={e => {
e.preventDefault();
e.stopPropagation();
if (connector.hasAccessToken) {
setActivePickerType(connector.id)
setActivePickerType(connector.id);
}
}}
className="min-w-[120px]"
@ -246,55 +251,46 @@ export function CloudConnectorsDialog({
{connectors.every(c => c.status !== "connected") && (
<div className="text-center py-8 text-muted-foreground">
<p>No connected cloud providers found.</p>
<p className="text-sm mt-1">Go to Settings to connect your cloud storage accounts.</p>
<p className="text-sm mt-1">
Go to Settings to connect your cloud storage accounts.
</p>
</div>
)}
{/* Render pickers inside dialog */}
{activePickerType && connectors.find(c => c.id === activePickerType) && (() => {
const connector = connectors.find(c => c.id === activePickerType)!
if (connector.type === "google_drive") {
{/* Render unified picker inside dialog */}
{activePickerType &&
connectors.find(c => c.id === activePickerType) &&
(() => {
const connector = connectors.find(
c => c.id === activePickerType
)!;
return (
<div className="mt-6">
<GoogleDrivePicker
onFileSelected={(files) => {
handleFileSelection(connector.id, files)
setActivePickerType(null)
<UnifiedCloudPicker
provider={
connector.type as
| "google_drive"
| "onedrive"
| "sharepoint"
}
onFileSelected={files => {
handleFileSelection(connector.id, files);
setActivePickerType(null);
}}
selectedFiles={selectedFiles[connector.id] as GoogleDriveFile[] || []}
selectedFiles={selectedFiles[connector.id] || []}
isAuthenticated={connector.status === "connected"}
accessToken={connectorAccessTokens[connector.type]}
onPickerStateChange={() => {}}
/>
</div>
)
}
if (connector.type === "onedrive" || connector.type === "sharepoint") {
return (
<div className="mt-6">
<OneDrivePicker
onFileSelected={(files) => {
handleFileSelection(connector.id, files)
setActivePickerType(null)
}}
selectedFiles={selectedFiles[connector.id] as OneDriveFile[] || []}
isAuthenticated={connector.status === "connected"}
accessToken={connectorAccessTokens[connector.type]}
connectorType={connector.type as "onedrive" | "sharepoint"}
clientId={connector.clientId}
/>
</div>
)
}
return null
})()}
);
})()}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
);
}

View file

@ -0,0 +1,67 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { FileText, Folder, Trash } from "lucide-react";
import { CloudFile } from "./types";
interface FileItemProps {
file: CloudFile;
onRemove: (fileId: string) => void;
}
const getFileIcon = (mimeType: string) => {
if (mimeType.includes("folder")) {
return <Folder className="h-6 w-6" />;
}
return <FileText className="h-6 w-6" />;
};
const getMimeTypeLabel = (mimeType: string) => {
const typeMap: { [key: string]: string } = {
"application/vnd.google-apps.document": "Google Doc",
"application/vnd.google-apps.spreadsheet": "Google Sheet",
"application/vnd.google-apps.presentation": "Google Slides",
"application/vnd.google-apps.folder": "Folder",
"application/pdf": "PDF",
"text/plain": "Text",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"Word Doc",
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
"PowerPoint",
};
return typeMap[mimeType] || mimeType?.split("/").pop() || "Document";
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return "";
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
};
export const FileItem = ({ file, onRemove }: FileItemProps) => (
<div
key={file.id}
className="flex items-center justify-between p-2 rounded-md text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{getFileIcon(file.mimeType)}
<span className="truncate font-medium text-sm mr-2">{file.name}</span>
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
{getMimeTypeLabel(file.mimeType)}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground mr-4" title="file size">
{formatFileSize(file.size) || "—"}
</span>
<Trash
className="text-muted-foreground w-5 h-5 cursor-pointer hover:text-destructive"
onClick={() => onRemove(file.id)}
/>
</div>
</div>
);

View file

@ -0,0 +1,42 @@
"use client";
import { Button } from "@/components/ui/button";
import { CloudFile } from "./types";
import { FileItem } from "./file-item";
interface FileListProps {
files: CloudFile[];
onClearAll: () => void;
onRemoveFile: (fileId: string) => void;
}
export const FileList = ({
files,
onClearAll,
onRemoveFile,
}: FileListProps) => {
if (files.length === 0) {
return null;
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Added files</p>
<Button
onClick={onClearAll}
size="sm"
variant="ghost"
className="text-sm text-muted-foreground"
>
Clear all
</Button>
</div>
<div className="max-h-64 overflow-y-auto space-y-1">
{files.map(file => (
<FileItem key={file.id} file={file} onRemove={onRemoveFile} />
))}
</div>
</div>
);
};

View file

@ -0,0 +1,7 @@
export { UnifiedCloudPicker } from "./unified-cloud-picker";
export { PickerHeader } from "./picker-header";
export { FileList } from "./file-list";
export { FileItem } from "./file-item";
export { IngestSettings } from "./ingest-settings";
export * from "./types";
export * from "./provider-handlers";

View file

@ -0,0 +1,82 @@
"use client";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronRight, Info } from "lucide-react";
interface IngestSettingsProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export const IngestSettings = ({
isOpen,
onOpenChange,
}: IngestSettingsProps) => (
<Collapsible
open={isOpen}
onOpenChange={onOpenChange}
className="border rounded-md p-4 border-muted-foreground/20"
>
<CollapsibleTrigger className="flex items-center gap-2 justify-between w-full -m-4 p-4 rounded-md transition-colors">
<div className="flex items-center gap-2">
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isOpen ? "rotate-90" : ""
}`}
/>
<span className="text-sm font-medium">Ingest settings</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2">
<div className="pt-5 space-y-5">
<div className="flex items-center gap-4 w-full">
<div className="w-full">
<div className="text-sm mb-2 font-semibold">Chunk size</div>
<Input type="number" />
</div>
<div className="w-full">
<div className="text-sm mb-2 font-semibold">Chunk overlap</div>
<Input type="number" />
</div>
</div>
<div className="flex gap-2 items-center justify-between">
<div>
<div className="text-sm font-semibold pb-2">OCR</div>
<div className="text-sm text-muted-foreground">
Extracts text from images/PDFs. Ingest is slower when enabled.
</div>
</div>
<Switch />
</div>
<div className="flex gap-2 items-center justify-between">
<div>
<div className="text-sm pb-2 font-semibold">
Picture descriptions
</div>
<div className="text-sm text-muted-foreground">
Adds captions for images. Ingest is more expensive when enabled.
</div>
</div>
<Switch />
</div>
<div>
<div className="text-sm font-semibold pb-2 flex items-center">
Embedding model
<Info className="w-3.5 h-3.5 text-muted-foreground ml-2" />
</div>
<Input value="text-embedding-3-small" disabled />
</div>
</div>
</CollapsibleContent>
</Collapsible>
);

View file

@ -0,0 +1,70 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Plus } from "lucide-react";
import { CloudProvider } from "./types";
interface PickerHeaderProps {
provider: CloudProvider;
onAddFiles: () => void;
isPickerLoaded: boolean;
isPickerOpen: boolean;
accessToken?: string;
isAuthenticated: boolean;
}
const getProviderName = (provider: CloudProvider): string => {
switch (provider) {
case "google_drive":
return "Google Drive";
case "onedrive":
return "OneDrive";
case "sharepoint":
return "SharePoint";
default:
return "Cloud Storage";
}
};
export const PickerHeader = ({
provider,
onAddFiles,
isPickerLoaded,
isPickerOpen,
accessToken,
isAuthenticated,
}: PickerHeaderProps) => {
if (!isAuthenticated) {
return (
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
Please connect to {getProviderName(provider)} first to select specific
files.
</div>
);
}
return (
<Card>
<CardContent className="flex flex-col items-center text-center py-8">
<p className="text-sm text-primary mb-4">
Select files from {getProviderName(provider)} to ingest.
</p>
<Button
size="sm"
onClick={onAddFiles}
disabled={!isPickerLoaded || isPickerOpen || !accessToken}
className="bg-foreground text-background hover:bg-foreground/90 font-semibold"
>
<Plus className="h-4 w-4" />
{isPickerOpen ? "Opening Picker..." : "Add Files"}
</Button>
<div className="text-xs text-muted-foreground pt-4">
csv, json, pdf,{" "}
<a className="underline dark:text-pink-400 text-pink-600">+16 more</a>{" "}
<b>150 MB</b> max
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,245 @@
"use client";
import {
CloudFile,
CloudProvider,
GooglePickerData,
GooglePickerDocument,
} from "./types";
export class GoogleDriveHandler {
private accessToken: string;
private onPickerStateChange?: (isOpen: boolean) => void;
constructor(
accessToken: string,
onPickerStateChange?: (isOpen: boolean) => void
) {
this.accessToken = accessToken;
this.onPickerStateChange = onPickerStateChange;
}
async loadPickerApi(): Promise<boolean> {
return new Promise(resolve => {
if (typeof window !== "undefined" && window.gapi) {
window.gapi.load("picker", {
callback: () => resolve(true),
onerror: () => resolve(false),
});
} else {
// Load Google API script
const script = document.createElement("script");
script.src = "https://apis.google.com/js/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
window.gapi.load("picker", {
callback: () => resolve(true),
onerror: () => resolve(false),
});
};
script.onerror = () => resolve(false);
document.head.appendChild(script);
}
});
}
openPicker(onFileSelected: (files: CloudFile[]) => void): void {
if (!window.google?.picker) {
return;
}
try {
this.onPickerStateChange?.(true);
const picker = new window.google.picker.PickerBuilder()
.addView(window.google.picker.ViewId.DOCS)
.addView(window.google.picker.ViewId.FOLDERS)
.setOAuthToken(this.accessToken)
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
.setTitle("Select files from Google Drive")
.setCallback(data => this.pickerCallback(data, onFileSelected))
.build();
picker.setVisible(true);
// Apply z-index fix
setTimeout(() => {
const pickerElements = document.querySelectorAll(
".picker-dialog, .goog-modalpopup"
);
pickerElements.forEach(el => {
(el as HTMLElement).style.zIndex = "10000";
});
const bgElements = document.querySelectorAll(
".picker-dialog-bg, .goog-modalpopup-bg"
);
bgElements.forEach(el => {
(el as HTMLElement).style.zIndex = "9999";
});
}, 100);
} catch (error) {
console.error("Error creating picker:", error);
this.onPickerStateChange?.(false);
}
}
private async pickerCallback(
data: GooglePickerData,
onFileSelected: (files: CloudFile[]) => void
): Promise<void> {
if (data.action === window.google.picker.Action.PICKED) {
const files: CloudFile[] = data.docs.map((doc: GooglePickerDocument) => ({
id: doc[window.google.picker.Document.ID],
name: doc[window.google.picker.Document.NAME],
mimeType: doc[window.google.picker.Document.MIME_TYPE],
webViewLink: doc[window.google.picker.Document.URL],
iconLink: doc[window.google.picker.Document.ICON_URL],
size: doc["sizeBytes"] ? parseInt(doc["sizeBytes"]) : undefined,
modifiedTime: doc["lastEditedUtc"],
isFolder:
doc[window.google.picker.Document.MIME_TYPE] ===
"application/vnd.google-apps.folder",
}));
// Enrich with additional file data if needed
if (files.some(f => !f.size && !f.isFolder)) {
try {
const enrichedFiles = await Promise.all(
files.map(async file => {
if (!file.size && !file.isFolder) {
try {
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${file.id}?fields=size,modifiedTime`,
{
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
}
);
if (response.ok) {
const fileDetails = await response.json();
return {
...file,
size: fileDetails.size
? parseInt(fileDetails.size)
: undefined,
modifiedTime:
fileDetails.modifiedTime || file.modifiedTime,
};
}
} catch (error) {
console.warn("Failed to fetch file details:", error);
}
}
return file;
})
);
onFileSelected(enrichedFiles);
} catch (error) {
console.warn("Failed to enrich file data:", error);
onFileSelected(files);
}
} else {
onFileSelected(files);
}
}
this.onPickerStateChange?.(false);
}
}
export class OneDriveHandler {
private accessToken: string;
private clientId: string;
private provider: CloudProvider;
private baseUrl?: string;
constructor(
accessToken: string,
clientId: string,
provider: CloudProvider = "onedrive",
baseUrl?: string
) {
this.accessToken = accessToken;
this.clientId = clientId;
this.provider = provider;
this.baseUrl = baseUrl;
}
async loadPickerApi(): Promise<boolean> {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = "https://js.live.net/v7.2/OneDrive.js";
script.onload = () => resolve(true);
script.onerror = () => resolve(false);
document.head.appendChild(script);
});
}
openPicker(onFileSelected: (files: CloudFile[]) => void): void {
if (!window.OneDrive) {
return;
}
window.OneDrive.open({
clientId: this.clientId,
action: "query",
multiSelect: true,
advanced: {
endpointHint: "api.onedrive.com",
accessToken: this.accessToken,
},
success: (response: any) => {
const newFiles: CloudFile[] =
response.value?.map((item: any, index: number) => ({
id: item.id,
name:
item.name ||
`${this.getProviderName()} File ${index + 1} (${item.id.slice(
-8
)})`,
mimeType: item.file?.mimeType || "application/octet-stream",
webUrl: item.webUrl || "",
downloadUrl: item["@microsoft.graph.downloadUrl"] || "",
size: item.size,
modifiedTime: item.lastModifiedDateTime,
isFolder: !!item.folder,
})) || [];
onFileSelected(newFiles);
},
cancel: () => {
console.log("Picker cancelled");
},
error: (error: any) => {
console.error("Picker error:", error);
},
});
}
private getProviderName(): string {
return this.provider === "sharepoint" ? "SharePoint" : "OneDrive";
}
}
export const createProviderHandler = (
provider: CloudProvider,
accessToken: string,
onPickerStateChange?: (isOpen: boolean) => void,
clientId?: string,
baseUrl?: string
) => {
switch (provider) {
case "google_drive":
return new GoogleDriveHandler(accessToken, onPickerStateChange);
case "onedrive":
case "sharepoint":
if (!clientId) {
throw new Error("Client ID required for OneDrive/SharePoint");
}
return new OneDriveHandler(accessToken, clientId, provider, baseUrl);
default:
throw new Error(`Unsupported provider: ${provider}`);
}
};

View file

@ -0,0 +1,96 @@
export interface CloudFile {
id: string;
name: string;
mimeType: string;
webViewLink?: string;
iconLink?: string;
size?: number;
modifiedTime?: string;
isFolder?: boolean;
webUrl?: string;
downloadUrl?: string;
}
export type CloudProvider = "google_drive" | "onedrive" | "sharepoint";
export interface UnifiedCloudPickerProps {
provider: CloudProvider;
onFileSelected: (files: CloudFile[]) => void;
selectedFiles?: CloudFile[];
isAuthenticated: boolean;
accessToken?: string;
onPickerStateChange?: (isOpen: boolean) => void;
// OneDrive/SharePoint specific props
clientId?: string;
baseUrl?: string;
}
export interface GoogleAPI {
load: (
api: string,
options: { callback: () => void; onerror?: () => void }
) => void;
}
export interface GooglePickerData {
action: string;
docs: GooglePickerDocument[];
}
export interface GooglePickerDocument {
[key: string]: string;
}
declare global {
interface Window {
gapi: GoogleAPI;
google: {
picker: {
api: {
load: (callback: () => void) => void;
};
PickerBuilder: new () => GooglePickerBuilder;
ViewId: {
DOCS: string;
FOLDERS: string;
DOCS_IMAGES_AND_VIDEOS: string;
DOCUMENTS: string;
PRESENTATIONS: string;
SPREADSHEETS: string;
};
Feature: {
MULTISELECT_ENABLED: string;
NAV_HIDDEN: string;
SIMPLE_UPLOAD_ENABLED: string;
};
Action: {
PICKED: string;
CANCEL: string;
};
Document: {
ID: string;
NAME: string;
MIME_TYPE: string;
URL: string;
ICON_URL: string;
};
};
};
OneDrive?: any;
}
}
export interface GooglePickerBuilder {
addView: (view: string) => GooglePickerBuilder;
setOAuthToken: (token: string) => GooglePickerBuilder;
setCallback: (
callback: (data: GooglePickerData) => void
) => GooglePickerBuilder;
enableFeature: (feature: string) => GooglePickerBuilder;
setTitle: (title: string) => GooglePickerBuilder;
build: () => GooglePicker;
}
export interface GooglePicker {
setVisible: (visible: boolean) => void;
}

View file

@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { UnifiedCloudPickerProps, CloudFile } from "./types";
import { PickerHeader } from "./picker-header";
import { FileList } from "./file-list";
import { IngestSettings } from "./ingest-settings";
import { createProviderHandler } from "./provider-handlers";
export const UnifiedCloudPicker = ({
provider,
onFileSelected,
selectedFiles = [],
isAuthenticated,
accessToken,
onPickerStateChange,
clientId,
baseUrl,
}: UnifiedCloudPickerProps) => {
const [isPickerLoaded, setIsPickerLoaded] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [isIngestSettingsOpen, setIsIngestSettingsOpen] = useState(false);
const [isLoadingBaseUrl, setIsLoadingBaseUrl] = useState(false);
const [autoBaseUrl, setAutoBaseUrl] = useState<string | undefined>(undefined);
const effectiveBaseUrl = baseUrl || autoBaseUrl;
// Auto-detect base URL for OneDrive personal accounts
useEffect(() => {
if (
(provider === "onedrive" || provider === "sharepoint") &&
!baseUrl &&
accessToken &&
!autoBaseUrl
) {
const getBaseUrl = async () => {
setIsLoadingBaseUrl(true);
try {
setAutoBaseUrl("https://onedrive.live.com/picker");
} catch (error) {
console.error("Auto-detect baseUrl failed:", error);
} finally {
setIsLoadingBaseUrl(false);
}
};
getBaseUrl();
}
}, [accessToken, baseUrl, autoBaseUrl, provider]);
// Load picker API
useEffect(() => {
if (!accessToken || !isAuthenticated) return;
const loadApi = async () => {
try {
const handler = createProviderHandler(
provider,
accessToken,
onPickerStateChange,
clientId,
effectiveBaseUrl
);
const loaded = await handler.loadPickerApi();
setIsPickerLoaded(loaded);
} catch (error) {
console.error("Failed to create provider handler:", error);
setIsPickerLoaded(false);
}
};
loadApi();
}, [
accessToken,
isAuthenticated,
provider,
clientId,
effectiveBaseUrl,
onPickerStateChange,
]);
const handleAddFiles = () => {
if (!isPickerLoaded || !accessToken) {
return;
}
if ((provider === "onedrive" || provider === "sharepoint") && !clientId) {
console.error("Client ID required for OneDrive/SharePoint");
return;
}
try {
setIsPickerOpen(true);
onPickerStateChange?.(true);
const handler = createProviderHandler(
provider,
accessToken,
isOpen => {
setIsPickerOpen(isOpen);
onPickerStateChange?.(isOpen);
},
clientId,
effectiveBaseUrl
);
handler.openPicker((files: CloudFile[]) => {
// Merge new files with existing ones, avoiding duplicates
const existingIds = new Set(selectedFiles.map(f => f.id));
const newFiles = files.filter(f => !existingIds.has(f.id));
onFileSelected([...selectedFiles, ...newFiles]);
});
} catch (error) {
console.error("Error opening picker:", error);
setIsPickerOpen(false);
onPickerStateChange?.(false);
}
};
const handleRemoveFile = (fileId: string) => {
const updatedFiles = selectedFiles.filter(file => file.id !== fileId);
onFileSelected(updatedFiles);
};
const handleClearAll = () => {
onFileSelected([]);
};
if (isLoadingBaseUrl) {
return (
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
Loading...
</div>
);
}
if (
(provider === "onedrive" || provider === "sharepoint") &&
!clientId &&
isAuthenticated
) {
return (
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
Configuration required: Client ID missing for{" "}
{provider === "sharepoint" ? "SharePoint" : "OneDrive"}.
</div>
);
}
return (
<div className="space-y-6">
<PickerHeader
provider={provider}
onAddFiles={handleAddFiles}
isPickerLoaded={isPickerLoaded}
isPickerOpen={isPickerOpen}
accessToken={accessToken}
isAuthenticated={isAuthenticated}
/>
<FileList
files={selectedFiles}
onClearAll={handleClearAll}
onRemoveFile={handleRemoveFile}
/>
<IngestSettings
isOpen={isIngestSettingsOpen}
onOpenChange={setIsIngestSettingsOpen}
/>
</div>
);
};

View file

@ -1,341 +0,0 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { FileText, Folder, Plus, Trash2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
interface GoogleDrivePickerProps {
onFileSelected: (files: GoogleDriveFile[]) => void
selectedFiles?: GoogleDriveFile[]
isAuthenticated: boolean
accessToken?: string
onPickerStateChange?: (isOpen: boolean) => void
}
interface GoogleDriveFile {
id: string
name: string
mimeType: string
webViewLink?: string
iconLink?: string
size?: number
modifiedTime?: string
isFolder?: boolean
}
interface GoogleAPI {
load: (api: string, options: { callback: () => void; onerror?: () => void }) => void
}
interface GooglePickerData {
action: string
docs: GooglePickerDocument[]
}
interface GooglePickerDocument {
[key: string]: string
}
declare global {
interface Window {
gapi: GoogleAPI
google: {
picker: {
api: {
load: (callback: () => void) => void
}
PickerBuilder: new () => GooglePickerBuilder
ViewId: {
DOCS: string
FOLDERS: string
DOCS_IMAGES_AND_VIDEOS: string
DOCUMENTS: string
PRESENTATIONS: string
SPREADSHEETS: string
}
Feature: {
MULTISELECT_ENABLED: string
NAV_HIDDEN: string
SIMPLE_UPLOAD_ENABLED: string
}
Action: {
PICKED: string
CANCEL: string
}
Document: {
ID: string
NAME: string
MIME_TYPE: string
URL: string
ICON_URL: string
}
}
}
}
}
interface GooglePickerBuilder {
addView: (view: string) => GooglePickerBuilder
setOAuthToken: (token: string) => GooglePickerBuilder
setCallback: (callback: (data: GooglePickerData) => void) => GooglePickerBuilder
enableFeature: (feature: string) => GooglePickerBuilder
setTitle: (title: string) => GooglePickerBuilder
build: () => GooglePicker
}
interface GooglePicker {
setVisible: (visible: boolean) => void
}
export function GoogleDrivePicker({
onFileSelected,
selectedFiles = [],
isAuthenticated,
accessToken,
onPickerStateChange
}: GoogleDrivePickerProps) {
const [isPickerLoaded, setIsPickerLoaded] = useState(false)
const [isPickerOpen, setIsPickerOpen] = useState(false)
useEffect(() => {
const loadPickerApi = () => {
if (typeof window !== 'undefined' && window.gapi) {
window.gapi.load('picker', {
callback: () => {
setIsPickerLoaded(true)
},
onerror: () => {
console.error('Failed to load Google Picker API')
}
})
}
}
// Load Google API script if not already loaded
if (typeof window !== 'undefined') {
if (!window.gapi) {
const script = document.createElement('script')
script.src = 'https://apis.google.com/js/api.js'
script.async = true
script.defer = true
script.onload = loadPickerApi
script.onerror = () => {
console.error('Failed to load Google API script')
}
document.head.appendChild(script)
return () => {
if (document.head.contains(script)) {
document.head.removeChild(script)
}
}
} else {
loadPickerApi()
}
}
}, [])
const openPicker = () => {
if (!isPickerLoaded || !accessToken || !window.google?.picker) {
return
}
try {
setIsPickerOpen(true)
onPickerStateChange?.(true)
// Create picker with higher z-index and focus handling
const picker = new window.google.picker.PickerBuilder()
.addView(window.google.picker.ViewId.DOCS)
.addView(window.google.picker.ViewId.FOLDERS)
.setOAuthToken(accessToken)
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
.setTitle('Select files from Google Drive')
.setCallback(pickerCallback)
.build()
picker.setVisible(true)
// Apply z-index fix after a short delay to ensure picker is rendered
setTimeout(() => {
const pickerElements = document.querySelectorAll('.picker-dialog, .goog-modalpopup')
pickerElements.forEach(el => {
(el as HTMLElement).style.zIndex = '10000'
})
const bgElements = document.querySelectorAll('.picker-dialog-bg, .goog-modalpopup-bg')
bgElements.forEach(el => {
(el as HTMLElement).style.zIndex = '9999'
})
}, 100)
} catch (error) {
console.error('Error creating picker:', error)
setIsPickerOpen(false)
onPickerStateChange?.(false)
}
}
const pickerCallback = async (data: GooglePickerData) => {
if (data.action === window.google.picker.Action.PICKED) {
const files: GoogleDriveFile[] = data.docs.map((doc: GooglePickerDocument) => ({
id: doc[window.google.picker.Document.ID],
name: doc[window.google.picker.Document.NAME],
mimeType: doc[window.google.picker.Document.MIME_TYPE],
webViewLink: doc[window.google.picker.Document.URL],
iconLink: doc[window.google.picker.Document.ICON_URL],
size: doc['sizeBytes'] ? parseInt(doc['sizeBytes']) : undefined,
modifiedTime: doc['lastEditedUtc'],
isFolder: doc[window.google.picker.Document.MIME_TYPE] === 'application/vnd.google-apps.folder'
}))
// If size is still missing, try to fetch it via Google Drive API
if (accessToken && files.some(f => !f.size && !f.isFolder)) {
try {
const enrichedFiles = await Promise.all(files.map(async (file) => {
if (!file.size && !file.isFolder) {
try {
const response = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}?fields=size,modifiedTime`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
})
if (response.ok) {
const fileDetails = await response.json()
return {
...file,
size: fileDetails.size ? parseInt(fileDetails.size) : undefined,
modifiedTime: fileDetails.modifiedTime || file.modifiedTime
}
}
} catch (error) {
console.warn('Failed to fetch file details:', error)
}
}
return file
}))
onFileSelected(enrichedFiles)
} catch (error) {
console.warn('Failed to enrich file data:', error)
onFileSelected(files)
}
} else {
onFileSelected(files)
}
}
setIsPickerOpen(false)
onPickerStateChange?.(false)
}
const removeFile = (fileId: string) => {
const updatedFiles = selectedFiles.filter(file => file.id !== fileId)
onFileSelected(updatedFiles)
}
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('folder')) {
return <Folder className="h-4 w-4" />
}
return <FileText className="h-4 w-4" />
}
const getMimeTypeLabel = (mimeType: string) => {
const typeMap: { [key: string]: string } = {
'application/vnd.google-apps.document': 'Google Doc',
'application/vnd.google-apps.spreadsheet': 'Google Sheet',
'application/vnd.google-apps.presentation': 'Google Slides',
'application/vnd.google-apps.folder': 'Folder',
'application/pdf': 'PDF',
'text/plain': 'Text',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word Doc',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint'
}
return typeMap[mimeType] || 'Document'
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`
}
if (!isAuthenticated) {
return (
<div className="text-sm text-muted-foreground p-4 bg-muted/20 rounded-md">
Please connect to Google Drive first to select specific files.
</div>
)
}
return (
<div className="space-y-4">
<Card>
<CardContent className="flex flex-col items-center text-center p-6">
<p className="text-sm text-muted-foreground mb-4">
Select files from Google Drive to ingest.
</p>
<Button
onClick={openPicker}
disabled={!isPickerLoaded || isPickerOpen || !accessToken}
className="bg-foreground text-background hover:bg-foreground/90"
>
<Plus className="h-4 w-4" />
{isPickerOpen ? 'Opening Picker...' : 'Add Files'}
</Button>
</CardContent>
</Card>
{selectedFiles.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Added files
</p>
<Button
onClick={() => onFileSelected([])}
size="sm"
variant="ghost"
className="text-xs h-6"
>
Clear all
</Button>
</div>
<div className="max-h-64 overflow-y-auto space-y-1">
{selectedFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-2 bg-muted/30 rounded-md text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{getFileIcon(file.mimeType)}
<span className="truncate font-medium">{file.name}</span>
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
{getMimeTypeLabel(file.mimeType)}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
<Button
onClick={() => removeFile(file.id)}
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,256 +0,0 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Plus, Trash2, FileText } from "lucide-react"
interface OneDrivePickerProps {
onFileSelected: (files: SelectedFile[]) => void
selectedFiles?: SelectedFile[]
isAuthenticated: boolean
accessToken?: string
connectorType?: "onedrive" | "sharepoint"
baseUrl?: string // e.g., "https://tenant.sharepoint.com/sites/sitename" or "https://tenant-my.sharepoint.com"
clientId: string
}
interface SelectedFile {
id: string
name: string
mimeType?: string
webUrl?: string
downloadUrl?: string
}
export function OneDrivePicker({
onFileSelected,
selectedFiles = [],
isAuthenticated,
accessToken,
connectorType = "onedrive",
baseUrl: providedBaseUrl,
clientId
}: OneDrivePickerProps) {
// Debug all props
console.log('All OneDrivePicker props:', {
onFileSelected: !!onFileSelected,
selectedFiles: selectedFiles?.length,
isAuthenticated,
accessToken: !!accessToken,
connectorType,
providedBaseUrl,
clientId
})
const [isPickerOpen, setIsPickerOpen] = useState(false)
const [autoBaseUrl, setAutoBaseUrl] = useState<string | null>(null)
const [isLoadingBaseUrl, setIsLoadingBaseUrl] = useState(false)
const baseUrl = providedBaseUrl || autoBaseUrl
useEffect(() => {
if (providedBaseUrl || !accessToken || autoBaseUrl) return
const getBaseUrl = async () => {
setIsLoadingBaseUrl(true)
try {
// For personal accounts, use the picker URL directly
setAutoBaseUrl("https://onedrive.live.com/picker")
} catch (error) {
console.error('Auto-detect baseUrl failed:', error)
} finally {
setIsLoadingBaseUrl(false)
}
}
getBaseUrl()
}, [accessToken, providedBaseUrl, autoBaseUrl])
// Add this loading check before your existing checks:
if (isLoadingBaseUrl) {
return (
<div className="border rounded-lg shadow-sm bg-white">
<div className="flex flex-col items-center text-center p-6">
<p className="text-sm text-gray-600">Loading...</p>
</div>
</div>
)
}
const openPicker = () => {
if (!accessToken || !clientId) {
console.error('Access token and client ID required')
return
}
setIsPickerOpen(true)
const script = document.createElement('script')
script.src = 'https://js.live.net/v7.2/OneDrive.js'
script.onload = () => {
// @ts-ignore
const OneDrive = window.OneDrive
if (OneDrive) {
OneDrive.open({
clientId: clientId,
action: 'query',
multiSelect: true,
advanced: {
endpointHint: 'api.onedrive.com',
accessToken: accessToken,
},
success: (response: any) => {
console.log('Raw OneDrive response:', response)
const newFiles: SelectedFile[] = response.value?.map((item: any, index: number) => ({
id: item.id,
name: `OneDrive File ${index + 1} (${item.id.slice(-8)})`,
mimeType: 'application/pdf',
webUrl: item.webUrl || '',
downloadUrl: ''
})) || []
console.log('Mapped files:', newFiles)
onFileSelected([...selectedFiles, ...newFiles])
setIsPickerOpen(false)
},
cancel: () => {
console.log('Picker cancelled')
setIsPickerOpen(false)
},
error: (error: any) => {
console.error('Picker error:', error)
setIsPickerOpen(false)
}
})
}
}
document.head.appendChild(script)
}
const closePicker = () => {
setIsPickerOpen(false)
}
const removeFile = (fileId: string) => {
const updatedFiles = selectedFiles.filter(file => file.id !== fileId)
onFileSelected(updatedFiles)
}
const serviceName = connectorType === "sharepoint" ? "SharePoint" : "OneDrive"
if (!isAuthenticated) {
return (
<div className="border rounded-lg shadow-sm bg-white">
<div className="flex flex-col items-center text-center p-6">
<p className="text-sm text-gray-600">
Please connect to {serviceName} first to select files.
</p>
</div>
</div>
)
}
if (!accessToken || !baseUrl) {
return (
<div className="border rounded-lg shadow-sm bg-white">
<div className="flex flex-col items-center text-center p-6">
<p className="text-sm text-gray-600 mb-2">
Configuration required
</p>
<p className="text-xs text-amber-600">
{!accessToken && "Access token required. "}
{!baseUrl && "Base URL required."}
</p>
</div>
</div>
)
}
return (
<div className="space-y-4">
{isPickerOpen ? (
<div className="border rounded-lg shadow-sm bg-white">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">OneDrive Picker is open in popup</h3>
<Button
onClick={closePicker}
size="sm"
variant="outline"
className="text-black"
style={{ color: '#000000' }}
>
Cancel
</Button>
</div>
<p className="text-sm text-gray-600">
Please select your files in the popup window. This window will update when you're done.
</p>
</div>
</div>
) : (
<div className="border rounded-lg shadow-sm bg-white">
<div className="flex flex-col items-center text-center p-6">
<p className="text-sm text-gray-600 mb-4">
Select files from {serviceName} to ingest into OpenRAG.
</p>
<Button
onClick={openPicker}
className="bg-blue-600 text-white hover:bg-blue-700 border-0"
style={{ backgroundColor: '#2563eb', color: '#ffffff' }}
>
<Plus className="h-4 w-4 mr-2" />
Add Files
</Button>
</div>
</div>
)}
{selectedFiles.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-600">
Selected files ({selectedFiles.length})
</p>
<Button
onClick={() => onFileSelected([])}
size="sm"
variant="ghost"
className="text-xs h-6"
>
Clear all
</Button>
</div>
<div className="max-h-64 overflow-y-auto space-y-1">
{selectedFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-2 bg-gray-100 rounded-md text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<FileText className="h-4 w-4 text-gray-600" />
<span className="truncate font-medium text-black" style={{ color: '#000000' }}>{file.name}</span>
{file.mimeType && (
<Badge variant="secondary" className="text-xs px-1 py-0.5 h-auto">
{file.mimeType.split('/').pop() || 'File'}
</Badge>
)}
</div>
<Button
onClick={() => removeFile(file.id)}
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)
}